Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,374 @@
// Twitch tests cover access control plugin behavior.
import { describe, expect, it } from "vitest";
import { checkTwitchAccessControl } from "./access-control.js";
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
describe("checkTwitchAccessControl", () => {
const mockAccount: TwitchAccountConfig = {
username: "testbot",
accessToken: "test",
clientId: "test-client-id",
channel: "testchannel",
};
const mockMessage: TwitchChatMessage = {
username: "testuser",
userId: "123456",
message: "hello bot",
channel: "testchannel",
};
function runAccessCheck(params: {
account?: Partial<TwitchAccountConfig>;
message?: Partial<TwitchChatMessage>;
}) {
return checkTwitchAccessControl({
message: {
...mockMessage,
...params.message,
},
account: {
...mockAccount,
...params.account,
},
botUsername: "testbot",
});
}
async function expectSingleRoleAllowed(params: {
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
message: Partial<TwitchChatMessage>;
}) {
const result = await runAccessCheck({
account: { allowedRoles: [params.role] },
message: {
message: "@testbot hello",
...params.message,
},
});
expect(result.allowed).toBe(true);
return result;
}
async function expectAllowedAccessCheck(params: {
account?: Partial<TwitchAccountConfig>;
message?: Partial<TwitchChatMessage>;
}) {
const result = await runAccessCheck({
account: params.account,
message: {
message: "@testbot hello",
...params.message,
},
});
expect(result.allowed).toBe(true);
return result;
}
async function expectAllowFromBlocked(params: {
allowFrom: string[];
allowedRoles?: NonNullable<TwitchAccountConfig["allowedRoles"]>;
message?: Partial<TwitchChatMessage>;
reason: string;
}) {
const result = await runAccessCheck({
account: {
allowFrom: params.allowFrom,
allowedRoles: params.allowedRoles,
},
message: {
message: "@testbot hello",
...params.message,
},
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain(params.reason);
}
describe("when no restrictions are configured", () => {
it("allows messages that mention the bot (default requireMention)", async () => {
const result = await runAccessCheck({
message: {
message: "@testbot hello",
},
});
expect(result.allowed).toBe(true);
});
});
describe("requireMention default", () => {
it("defaults to true when undefined", async () => {
const result = await runAccessCheck({
message: {
message: "hello bot",
},
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("allows mention when requireMention is undefined", async () => {
const result = await runAccessCheck({
message: {
message: "@testbot hello",
},
});
expect(result.allowed).toBe(true);
});
});
describe("requireMention", () => {
it("allows messages that mention the bot", async () => {
const result = await runAccessCheck({
account: { requireMention: true },
message: { message: "@testbot hello" },
});
expect(result.allowed).toBe(true);
});
it("blocks messages that don't mention the bot", async () => {
const result = await runAccessCheck({
account: { requireMention: true },
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("is case-insensitive for bot username", async () => {
const result = await runAccessCheck({
account: { requireMention: true },
message: { message: "@TestBot hello" },
});
expect(result.allowed).toBe(true);
});
});
describe("allowFrom allowlist", () => {
it("allows users in the allowlist", async () => {
const result = await expectAllowedAccessCheck({
account: {
allowFrom: ["123456", "789012"],
},
});
expect(result.matchKey).toBe("123456");
expect(result.matchSource).toBe("allowlist");
});
it("blocks users not in allowlist when allowFrom is set", async () => {
await expectAllowFromBlocked({
allowFrom: ["789012"],
reason: "allowFrom",
});
});
it("blocks everyone when allowFrom is explicitly empty", async () => {
await expectAllowFromBlocked({
allowFrom: [],
reason: "allowFrom",
});
});
it("blocks messages without userId", async () => {
await expectAllowFromBlocked({
allowFrom: ["123456"],
message: { userId: undefined },
reason: "user ID not available",
});
});
it("bypasses role checks when user is in allowlist", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["123456"],
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: false,
};
const result = await checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("blocks user with role when not in allowlist", async () => {
await expectAllowFromBlocked({
allowFrom: ["789012"],
allowedRoles: ["moderator"],
message: { userId: "123456", isMod: true },
reason: "allowFrom",
});
});
it("blocks user not in allowlist even when roles configured", async () => {
await expectAllowFromBlocked({
allowFrom: ["789012"],
allowedRoles: ["moderator"],
message: { userId: "123456", isMod: false },
reason: "allowFrom",
});
});
});
describe("allowedRoles", () => {
it("allows users with matching role", async () => {
const result = await expectSingleRoleAllowed({
role: "moderator",
message: { isMod: true },
});
expect(result.matchSource).toBe("role");
});
it("allows users with any of multiple roles", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator", "vip", "subscriber"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isVip: true,
isMod: false,
isSub: false,
};
const result = await checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("blocks users without matching role", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: false,
};
const result = await checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not have any of the required roles");
});
it("allows all users when role is 'all'", async () => {
const result = await expectAllowedAccessCheck({
account: {
allowedRoles: ["all"],
},
});
expect(result.matchKey).toBe("all");
});
it("handles moderator role", async () => {
await expectSingleRoleAllowed({
role: "moderator",
message: { isMod: true },
});
});
it("handles subscriber role", async () => {
await expectSingleRoleAllowed({
role: "subscriber",
message: { isSub: true },
});
});
it("handles owner role", async () => {
await expectSingleRoleAllowed({
role: "owner",
message: { isOwner: true },
});
});
it("handles vip role", async () => {
await expectSingleRoleAllowed({
role: "vip",
message: { isVip: true },
});
});
});
describe("combined restrictions", () => {
it("checks requireMention before allowlist", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
allowFrom: ["123456"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "hello", // No mention
};
const result = await checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("checks requireMention before sender allowlists for unauthorized chat", async () => {
const result = await runAccessCheck({
account: {
requireMention: true,
allowFrom: ["789012"],
},
message: {
message: "ordinary chat",
userId: "123456",
},
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("checks requireMention before role gates for unauthorized chat", async () => {
const result = await runAccessCheck({
account: {
requireMention: true,
allowedRoles: ["moderator"],
},
message: {
message: "ordinary chat",
isMod: false,
},
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("checks allowlist before allowedRoles", async () => {
const result = await runAccessCheck({
account: {
allowFrom: ["123456"],
allowedRoles: ["owner"],
},
message: {
message: "@testbot hello",
isOwner: false,
},
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("allowlist");
});
});
});

View File

@@ -0,0 +1,196 @@
// Twitch plugin module implements access control behavior.
import {
createChannelIngressResolver,
defineStableChannelIngressIdentity,
type ChannelIngressIdentitySubjectInput,
type IngressReasonCode,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
type TwitchAccessControlResult = {
allowed: boolean;
reason?: string;
matchKey?: string;
matchSource?: string;
};
type TwitchPolicyKind = "open" | "allowFrom" | "role";
const twitchUserIdentity = defineStableChannelIngressIdentity({
key: "sender-id",
entryIdPrefix: "twitch-user-entry",
});
const twitchRoleIdentity = defineStableChannelIngressIdentity({
key: "role-moderator",
kind: "role",
normalizeEntry: normalizeTwitchRole,
normalizeSubject: normalizeTwitchRole,
aliases: ["owner", "vip", "subscriber"].map((role) => ({
key: `role-${role}`,
kind: "role",
normalizeEntry: () => null,
normalizeSubject: normalizeTwitchRole,
})),
isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`,
});
export async function checkTwitchAccessControl(params: {
message: TwitchChatMessage;
account: TwitchAccountConfig;
botUsername: string;
}): Promise<TwitchAccessControlResult> {
const { message, account, botUsername } = params;
const policyKind = resolveTwitchPolicyKind(account);
const resolved = await createChannelIngressResolver({
channelId: "twitch",
accountId: "default",
identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity,
}).message({
subject:
policyKind === "role"
? twitchRoleSubject(message)
: ({ stableId: message.userId } satisfies ChannelIngressIdentitySubjectInput),
conversation: {
kind: "group",
id: message.channel,
},
event: { mayPair: false },
mentionFacts: {
canDetectMention: true,
wasMentioned: mentionsBot(message.message, botUsername),
},
dmPolicy: "open",
groupPolicy: policyKind === "open" ? "open" : "allowlist",
policy: {
activation: {
requireMention: account.requireMention ?? true,
allowTextCommands: false,
order: "before-sender",
},
},
groupAllowFrom:
policyKind === "allowFrom"
? account.allowFrom
: policyKind === "role"
? account.allowedRoles
: undefined,
});
const decision = resolved.ingress;
if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") {
return {
allowed: false,
reason: "message does not mention the bot (requireMention is enabled)",
};
}
if (decision.admission === "dispatch") {
if (policyKind === "allowFrom") {
return {
allowed: true,
matchKey: params.message.userId,
matchSource: "allowlist",
};
}
if (policyKind === "role") {
return {
allowed: true,
matchKey: params.account.allowedRoles?.join(","),
matchSource: "role",
};
}
return {
allowed: true,
};
}
if (policyKind === "allowFrom") {
if (!params.message.userId) {
return {
allowed: false,
reason: "sender user ID not available for allowlist check",
};
}
return {
allowed: false,
reason: "sender is not in allowFrom allowlist",
};
}
if (policyKind === "role") {
return {
allowed: false,
reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`,
};
}
return {
allowed: false,
reason: reasonForTwitchIngressDecision(decision),
};
}
function resolveTwitchPolicyKind(account: TwitchAccountConfig): TwitchPolicyKind {
if (account.allowFrom !== undefined) {
return "allowFrom";
}
if (account.allowedRoles && account.allowedRoles.length > 0) {
return "role";
}
return "open";
}
function twitchRoleSubject(message: TwitchChatMessage): ChannelIngressIdentitySubjectInput {
return {
stableId: message.isMod ? "moderator" : undefined,
aliases: {
"role-owner": message.isOwner ? "owner" : undefined,
"role-vip": message.isVip ? "vip" : undefined,
"role-subscriber": message.isSub ? "subscriber" : undefined,
},
};
}
function normalizeTwitchRole(value: string): string | null {
const role = normalizeLowercaseStringOrEmpty(value);
if (role === "*") {
return "all";
}
return role === "moderator" ||
role === "owner" ||
role === "vip" ||
role === "subscriber" ||
role === "all"
? role
: null;
}
function reasonForTwitchIngressDecision(decision: { reasonCode: IngressReasonCode }): string {
switch (decision.reasonCode) {
case "activation_skipped":
return "message does not mention the bot (requireMention is enabled)";
case "group_policy_empty_allowlist":
case "group_policy_not_allowlisted":
return "sender is not in allowFrom allowlist";
default:
return decision.reasonCode;
}
}
function mentionsBot(message: string, botUsername: string): boolean {
const expected = normalizeLowercaseStringOrEmpty(botUsername);
const mentionRegex = /@(\w+)/g;
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(message)) !== null) {
const username = match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "";
if (username === expected) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,76 @@
// Twitch tests cover actions plugin behavior.
import { describe, expect, it, vi, beforeEach } from "vitest";
import { twitchMessageActions } from "./actions.js";
import type { ResolvedTwitchAccountContext } from "./config.js";
import { resolveTwitchAccountContext } from "./config.js";
import { twitchOutbound } from "./outbound.js";
vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default",
resolveTwitchAccountContext: vi.fn(),
}));
vi.mock("./outbound.js", () => ({
twitchOutbound: {
sendText: vi.fn(),
},
}));
function createSecondaryAccountContext(accountId = "secondary"): ResolvedTwitchAccountContext {
return {
accountId,
account: {
channel: "secondary-channel",
username: "secondary",
accessToken: "oauth:secondary-token",
clientId: "secondary-client",
enabled: true,
},
tokenResolution: { source: "config", token: "oauth:secondary-token" },
configured: true,
availableAccountIds: ["default", "secondary"],
};
}
describe("twitchMessageActions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses configured defaultAccount when action accountId is omitted", async () => {
vi.mocked(resolveTwitchAccountContext)
.mockImplementationOnce(() => createSecondaryAccountContext())
.mockImplementation((_cfg, accountId) =>
createSecondaryAccountContext(accountId?.trim() || "secondary"),
);
const sendText = twitchOutbound.sendText;
if (!sendText) {
throw new Error("twitchOutbound.sendText is unavailable");
}
vi.mocked(sendText).mockResolvedValue({
channel: "twitch",
messageId: "msg-1",
timestamp: 1,
});
const cfg = {
channels: {
twitch: {
defaultAccount: "secondary",
},
},
};
await twitchMessageActions.handleAction!({
action: "send",
params: { message: "Hello!" },
cfg,
} as never);
expect(twitchOutbound.sendText).toHaveBeenCalledWith({
cfg,
to: "secondary-channel",
text: "Hello!",
accountId: "secondary",
});
});
});

View File

@@ -0,0 +1,175 @@
/**
* Twitch message actions adapter.
*
* Handles tool-based actions for Twitch, such as sending messages.
*/
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveTwitchAccountContext } from "./config.js";
import { twitchOutbound } from "./outbound.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
/**
* Create a tool result with error content.
*/
function errorResponse(error: string) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ ok: false, error }),
},
],
details: { ok: false },
};
}
/**
* Read a string parameter from action arguments.
*
* @param args - Action arguments
* @param key - Parameter key
* @param options - Options for reading the parameter
* @returns The parameter value or undefined if not found
*/
function readStringParam(
args: Record<string, unknown>,
key: string,
options: { required?: boolean; trim?: boolean } = {},
): string | undefined {
const value = args[key];
if (value === undefined || value === null) {
if (options.required) {
throw new Error(`Missing required parameter: ${key}`);
}
return undefined;
}
// Convert value to string safely
if (typeof value === "string") {
return options.trim !== false ? value.trim() : value;
}
if (typeof value === "number" || typeof value === "boolean") {
const str = String(value);
return options.trim !== false ? str.trim() : str;
}
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
}
/** Supported Twitch actions */
const TWITCH_ACTIONS = new Set(["send" as const]);
type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
/**
* Twitch message actions adapter.
*/
export const twitchMessageActions: ChannelMessageActionAdapter = {
/**
* List available actions for this channel.
*/
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
/**
* Check if an action is supported.
*/
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
/**
* Extract tool send parameters from action arguments.
*
* Parses and validates the "to" and "message" parameters for sending.
*
* @param params - Arguments from the tool call
* @returns Parsed send parameters or null if invalid
*
* @example
* const result = twitchMessageActions.extractToolSend!({
* args: { to: "#mychannel", message: "Hello!" }
* });
* // Returns: { to: "#mychannel", message: "Hello!" }
*/
extractToolSend: ({ args }) => {
try {
const to = readStringParam(args, "to", { required: true });
const message = readStringParam(args, "message", { required: true });
if (!to || !message) {
return null;
}
return { to, message };
} catch {
return null;
}
},
/**
* Handle an action execution.
*
* Processes the "send" action to send messages to Twitch.
*
* @param ctx - Action context including action type, parameters, and config
* @returns Tool result with content or null if action not supported
*
* @example
* const result = await twitchMessageActions.handleAction!({
* action: "send",
* params: { message: "Hello Twitch!", to: "#mychannel" },
* cfg: openclawConfig,
* accountId: "default",
* });
*/
handleAction: async (ctx: ChannelMessageActionContext) => {
if (ctx.action !== "send") {
return {
content: [{ type: "text" as const, text: "Unsupported action" }],
details: { ok: false, error: "Unsupported action" },
};
}
const message = readStringParam(ctx.params, "message", { required: true });
const to = readStringParam(ctx.params, "to", { required: false });
const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
if (!account) {
return errorResponse(
`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
);
}
// Use the channel from account config (or override with `to` parameter)
const targetChannel = to || account.channel;
if (!targetChannel) {
return errorResponse("No channel specified and no default channel in account config");
}
if (!twitchOutbound.sendText) {
return errorResponse("sendText not implemented");
}
try {
const result = await twitchOutbound.sendText({
cfg: ctx.cfg,
to: targetChannel,
text: message ?? "",
accountId,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result),
},
],
details: { ok: true },
};
} catch (error) {
const errorMsg = formatErrorMessage(error);
return errorResponse(errorMsg);
}
},
};

View File

@@ -0,0 +1,52 @@
// Twitch tests cover client manager registry plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearRegistryForTest,
getClientManager,
getOrCreateClientManager,
removeClientManager,
} from "./client-manager-registry.js";
import type { ChannelLogSink } from "./types.js";
function makeLogger(): ChannelLogSink {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
describe("client manager registry", () => {
afterEach(async () => {
await clearRegistryForTest();
});
it("clears cached managers for hot module test isolation", async () => {
const firstManager = getOrCreateClientManager("default", makeLogger());
const disconnectAll = vi.spyOn(firstManager, "disconnectAll");
expect(getClientManager("default")).toBe(firstManager);
expect(getOrCreateClientManager("default", makeLogger())).toBe(firstManager);
await clearRegistryForTest();
expect(disconnectAll).toHaveBeenCalledOnce();
expect(getClientManager("default")).toBeUndefined();
expect(getOrCreateClientManager("default", makeLogger())).not.toBe(firstManager);
});
it("removes cached managers even when disconnectAll rejects", async () => {
const firstManager = getOrCreateClientManager("default", makeLogger());
const disconnectError = new Error("disconnect failed");
const disconnectAll = vi
.spyOn(firstManager, "disconnectAll")
.mockRejectedValueOnce(disconnectError);
await expect(removeClientManager("default")).rejects.toBe(disconnectError);
expect(disconnectAll).toHaveBeenCalledOnce();
expect(getClientManager("default")).toBeUndefined();
expect(getOrCreateClientManager("default", makeLogger())).not.toBe(firstManager);
});
});

View File

@@ -0,0 +1,109 @@
/**
* Client manager registry for Twitch plugin.
*
* Manages the lifecycle of TwitchClientManager instances across the plugin,
* ensuring proper cleanup when accounts are stopped or reconfigured.
*/
import { TwitchClientManager } from "./twitch-client.js";
import type { ChannelLogSink } from "./types.js";
/**
* Registry entry tracking a client manager and its associated account.
*/
type RegistryEntry = {
/** The client manager instance */
manager: TwitchClientManager;
/** The account ID this manager is for */
accountId: string;
/** Logger for this entry */
logger: ChannelLogSink;
/** When this entry was created */
createdAt: number;
};
/**
* Global registry of client managers.
* Keyed by account ID.
*/
const registry = new Map<string, RegistryEntry>();
/**
* Get or create a client manager for an account.
*
* @param accountId - The account ID
* @param logger - Logger instance
* @returns The client manager
*/
export function getOrCreateClientManager(
accountId: string,
logger: ChannelLogSink,
): TwitchClientManager {
const existing = registry.get(accountId);
if (existing) {
return existing.manager;
}
const manager = new TwitchClientManager(logger);
registry.set(accountId, {
manager,
accountId,
logger,
createdAt: Date.now(),
});
logger.info(`Registered client manager for account: ${accountId}`);
return manager;
}
/**
* Get an existing client manager for an account.
*
* @param accountId - The account ID
* @returns The client manager, or undefined if not registered
*/
export function getClientManager(accountId: string): TwitchClientManager | undefined {
return registry.get(accountId)?.manager;
}
/**
* Disconnect and remove a client manager from the registry.
*
* @param accountId - The account ID
* @returns Promise that resolves when cleanup is complete
*/
export async function removeClientManager(accountId: string): Promise<void> {
const entry = registry.get(accountId);
if (!entry) {
return;
}
try {
await entry.manager.disconnectAll();
} finally {
registry.delete(accountId);
entry.logger.info(`Unregistered client manager for account: ${accountId}`);
}
}
/**
* Test-only: clear the module-level registry of all client manager entries.
*
* Mirrors the `clearForTest` escape hatch on `TwitchClientManager`. Without
* this, the module-level `registry` Map survives across tests when vitest
* is run with `--isolate=false` (or any harness that does not tear the
* module graph down between cases), and a stale entry from one test will
* shadow `getOrCreateClientManager` calls in subsequent tests, silently
* handing back another test's mocked logger/manager. See #83887.
*
* Production code MUST NOT call this. It disconnects cached managers before
* clearing the registry so tests do not leave handlers or clients behind.
*/
export async function clearRegistryForTest(): Promise<void> {
const entries = [...registry.values()];
try {
await Promise.all(entries.map((entry) => entry.manager.disconnectAll()));
} finally {
registry.clear();
}
}

View File

@@ -0,0 +1,49 @@
// Twitch tests cover config schema plugin behavior.
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime";
import { describe, expect, it } from "vitest";
import { TwitchConfigSchema } from "./config-schema.js";
function validateTwitchConfig(value: unknown): boolean {
const schema = buildChannelConfigSchema(TwitchConfigSchema).schema;
const result = validateJsonSchemaValue({
cacheKey: "twitch.config-schema.test",
schema,
value,
});
if (!result.ok) {
throw new Error(`expected valid Twitch config: ${JSON.stringify(result.errors)}`);
}
return true;
}
describe("TwitchConfigSchema JSON schema", () => {
it("accepts single-account channel config with base fields", () => {
expect(
validateTwitchConfig({
enabled: false,
username: "openclaw",
accessToken: "oauth:test",
clientId: "test-client-id",
channel: "openclaw-test",
}),
).toBe(true);
});
it("accepts multi-account channel config with defaultAccount", () => {
expect(
validateTwitchConfig({
enabled: true,
defaultAccount: "stream",
accounts: {
stream: {
username: "openclaw",
accessToken: "oauth:test",
clientId: "test-client-id",
channel: "openclaw-test",
},
},
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,89 @@
// Twitch helper module supports config schema behavior.
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
import { z } from "zod";
/**
* Twitch user roles that can be allowed to interact with the bot
*/
const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
const TwitchAccountShape = {
/** Twitch username */
username: z.string(),
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
accessToken: z.string(),
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
clientId: z.string().optional(),
/** Channel name to join */
channel: z.string().min(1),
/** Enable this account */
enabled: z.boolean().optional(),
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
allowFrom: z.array(z.string()).optional(),
/** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
allowedRoles: z.array(TwitchRoleSchema).optional(),
/** Require @mention to trigger bot responses */
requireMention: z.boolean().optional(),
/** Outbound response prefix override for this channel/account. */
responsePrefix: z.string().optional(),
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
clientSecret: z.string().optional(),
/** Refresh token (required for automatic token refresh) */
refreshToken: z.string().optional(),
/** Token expiry time in seconds (optional, for token refresh tracking) */
expiresIn: z.number().nullable().optional(),
/** Timestamp when token was obtained (optional, for token refresh tracking) */
obtainmentTimestamp: z.number().optional(),
};
/**
* Twitch account configuration schema
*/
const TwitchAccountSchema = z.object(TwitchAccountShape);
/**
* Base configuration properties shared by both single and multi-account modes
*/
const TwitchConfigBaseShape = {
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema.optional(),
defaultAccount: z.string().optional(),
};
/**
* Simplified single-account configuration schema
*
* Use this for single-account setups. Properties are at the top level,
* creating an implicit "default" account.
*/
const SimplifiedSchema = z.object({
...TwitchConfigBaseShape,
...TwitchAccountShape,
});
/**
* Multi-account configuration schema
*
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
*/
const MultiAccountSchema = z
.object({
...TwitchConfigBaseShape,
/** Per-account configuration (for multi-account setups) */
accounts: z.record(z.string(), TwitchAccountSchema),
})
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
message: "accounts must contain at least one entry",
});
/**
* Twitch plugin configuration schema
*
* Supports two mutually exclusive patterns:
* 1. Simplified single-account: username, accessToken, clientId, channel at top level
* 2. Multi-account: accounts object with named account configs
*
* The union ensures clear discrimination between the two modes.
*/
export const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);

View File

@@ -0,0 +1,234 @@
// Twitch tests cover config plugin behavior.
import { describe, expect, it } from "vitest";
import {
getAccountConfig,
listAccountIds,
resolveDefaultTwitchAccountId,
resolveTwitchAccountContext,
} from "./config.js";
describe("getAccountConfig", () => {
const mockMultiAccountConfig = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
accessToken: "oauth:test123",
},
secondary: {
username: "secondbot",
accessToken: "oauth:secondary",
},
},
},
},
};
const mockSimplifiedConfig = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
},
},
};
it("returns account config for valid account ID (multi-account)", () => {
const result = getAccountConfig(mockMultiAccountConfig, "default");
expect(result?.username).toBe("testbot");
});
it("returns account config for default account (simplified config)", () => {
const result = getAccountConfig(mockSimplifiedConfig, "default");
expect(result?.username).toBe("testbot");
});
it("returns non-default account from multi-account config", () => {
const result = getAccountConfig(mockMultiAccountConfig, "secondary");
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")).toEqual({
username: "secondbot",
accessToken: "oauth:secondary",
});
expect(getAccountConfig(cfg, "inherited")).toBeNull();
});
it("returns null for non-existent account ID", () => {
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
expect(result).toBeNull();
});
it("returns null when core config is null", () => {
const result = getAccountConfig(null, "default");
expect(result).toBeNull();
});
it("returns null when core config is undefined", () => {
const result = getAccountConfig(undefined, "default");
expect(result).toBeNull();
});
it("returns null when channels are not defined", () => {
const result = getAccountConfig({}, "default");
expect(result).toBeNull();
});
it("returns null when twitch is not defined", () => {
const result = getAccountConfig({ channels: {} }, "default");
expect(result).toBeNull();
});
it("returns null when accounts are not defined", () => {
const result = getAccountConfig({ channels: { twitch: {} } }, "default");
expect(result).toBeNull();
});
});
describe("listAccountIds", () => {
it("includes the implicit default account from simplified config", () => {
expect(
listAccountIds({
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
},
},
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["default"]);
});
it("combines explicit accounts with the implicit default account once", () => {
expect(
listAccountIds({
channels: {
twitch: {
username: "testbot",
accounts: {
default: { username: "testbot" },
secondary: { username: "secondbot" },
},
},
},
} 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", () => {
it("prefers channels.twitch.defaultAccount when configured", () => {
expect(
resolveDefaultTwitchAccountId({
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: { username: "default" },
secondary: { username: "secondary" },
},
},
},
} as Parameters<typeof resolveDefaultTwitchAccountId>[0]),
).toBe("secondary");
});
});
describe("resolveTwitchAccountContext", () => {
it("uses configured defaultAccount when accountId is omitted", () => {
const context = resolveTwitchAccountContext({
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default-token",
},
secondary: {
username: "second-bot",
accessToken: "oauth:second-token",
},
},
},
},
} as Parameters<typeof resolveTwitchAccountContext>[0]);
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

@@ -0,0 +1,178 @@
// Twitch helper module supports config behavior.
import {
listCombinedAccountIds,
normalizeAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
import type { TwitchAccountConfig } from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
/**
* Default account ID for Twitch
*/
export const DEFAULT_ACCOUNT_ID = "default";
export type ResolvedTwitchAccountContext = {
accountId: string;
account: TwitchAccountConfig | null;
tokenResolution: TwitchTokenResolution;
configured: boolean;
availableAccountIds: string[];
};
/**
* Get account config from core config
*
* Handles two patterns:
* 1. Simplified single-account: base-level properties create implicit "default" account
* 2. Multi-account: explicit accounts object
*
* For "default" account, base-level properties take precedence over accounts.default
* For other accounts, only the accounts object is checked
*/
export function getAccountConfig(
coreConfig: unknown,
accountId: string,
): TwitchAccountConfig | null {
if (!coreConfig || typeof coreConfig !== "object") {
return null;
}
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 (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = resolveNormalizedAccountEntry(
accounts,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
);
// Base-level properties that can form an implicit default account
const baseLevel = {
username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
requireMention:
typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
clientSecret:
typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
refreshToken:
typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
obtainmentTimestamp:
typeof twitchRaw?.obtainmentTimestamp === "number"
? twitchRaw.obtainmentTimestamp
: undefined,
};
// Merge: base-level takes precedence over accounts.default
const merged: Partial<TwitchAccountConfig> = {
...accountFromAccounts,
...baseLevel,
} as Partial<TwitchAccountConfig>;
// Only return if we have at least username
if (merged.username) {
return merged as TwitchAccountConfig;
}
// Fall through to accounts.default if no base-level username
if (accountFromAccounts) {
return accountFromAccounts;
}
return null;
}
// For non-default accounts, only check accounts object
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
if (!account) {
return null;
}
return account;
}
/**
* List all configured account IDs
*
* Includes both explicit accounts and implicit "default" from base-level config
*/
export function listAccountIds(cfg: OpenClawConfig): string[] {
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 accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
// Add implicit "default" if base-level config exists and "default" not already present
const hasBaseLevelConfig =
twitchRaw &&
(typeof twitchRaw.username === "string" ||
typeof twitchRaw.accessToken === "string" ||
typeof twitchRaw.channel === "string");
return listCombinedAccountIds({
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
normalizeAccountId(accountId),
),
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
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;
}
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveTwitchAccountContext(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedTwitchAccountContext {
const resolvedAccountId = accountId?.trim()
? normalizeAccountId(accountId)
: resolveDefaultTwitchAccountId(cfg);
const account = getAccountConfig(cfg, resolvedAccountId);
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
return {
accountId: resolvedAccountId,
account,
tokenResolution,
configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
availableAccountIds: listAccountIds(cfg),
};
}
export function resolveTwitchSnapshotAccountId(
cfg: OpenClawConfig,
account: TwitchAccountConfig,
): string {
const twitch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
return (
Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? DEFAULT_ACCOUNT_ID
);
}

View File

@@ -0,0 +1,305 @@
/**
* Twitch message monitor - processes incoming messages and routes to agents.
*
* This monitor connects to the Twitch client manager, processes incoming messages,
* resolves agent routes, and handles replies.
*/
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { checkTwitchAccessControl } from "./access-control.js";
import { getOrCreateClientManager } from "./client-manager-registry.js";
import { getTwitchRuntime } from "./runtime.js";
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
import { stripMarkdownForTwitch } from "./utils/markdown.js";
export type TwitchRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type TwitchMonitorOptions = {
account: TwitchAccountConfig;
accountId: string;
config: unknown; // OpenClawConfig
runtime: TwitchRuntimeEnv;
abortSignal: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export type TwitchMonitorResult = {
stop: () => void;
};
type TwitchCoreRuntime = ReturnType<typeof getTwitchRuntime>;
/**
* Process an incoming Twitch message and dispatch to agent.
*/
async function processTwitchMessage(params: {
message: TwitchChatMessage;
account: TwitchAccountConfig;
accountId: string;
config: unknown;
runtime: TwitchRuntimeEnv;
core: TwitchCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { message, account, accountId, config, runtime, core, statusSink } = params;
const cfg = config as OpenClawConfig;
await core.channel.inbound.run({
channel: "twitch",
accountId,
raw: message,
adapter: {
ingest: (incoming) => ({
id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
timestamp: incoming.timestamp?.getTime(),
rawText: incoming.message,
textForAgent: incoming.message,
textForCommands: incoming.message,
raw: incoming,
}),
resolveTurn: async (input) => {
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "twitch",
accountId,
peer: {
kind: "group",
id: message.channel,
},
});
const senderId = message.userId ?? message.username;
const fromLabel = message.displayName ?? message.username;
const body = core.channel.reply.formatAgentEnvelope({
channel: "Twitch",
from: fromLabel,
timestamp: input.timestamp,
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
body: input.rawText,
});
const ctxPayload = core.channel.inbound.buildContext({
channel: "twitch",
accountId,
messageId: input.id,
timestamp: input.timestamp,
from: `twitch:user:${senderId}`,
sender: {
id: senderId,
name: fromLabel,
username: message.username,
},
conversation: {
kind: "group",
id: message.channel,
label: message.channel,
},
route: {
agentId: route.agentId,
accountId: route.accountId,
routeSessionKey: route.sessionKey,
},
reply: {
to: `twitch:channel:${message.channel}`,
},
message: {
body,
rawBody: input.rawText,
bodyForAgent: input.textForAgent,
commandBody: input.textForCommands,
},
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "twitch",
accountId,
});
return {
cfg,
channel: "twitch",
accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: () => ({
to: `twitch:channel:${message.channel}`,
}),
deliver: async (payload) => {
return await deliverTwitchReply({
payload,
channel: message.channel,
account,
accountId,
config,
tableMode,
runtime,
});
},
onDelivered: (_payload, _info, result) => {
if (result?.visibleReplySent !== false) {
statusSink?.({ lastOutboundAt: Date.now() });
}
},
onError: (err, info) => {
runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
},
},
replyPipeline: {},
record: {
onRecordError: (err) => {
runtime.error?.(`Failed updating session meta: ${String(err)}`);
},
},
};
},
},
});
}
/**
* Deliver a reply to Twitch chat.
*/
async function deliverTwitchReply(params: {
payload: ReplyPayload;
channel: string;
account: TwitchAccountConfig;
accountId: string;
config: unknown;
tableMode: MarkdownTableMode;
runtime: TwitchRuntimeEnv;
}): Promise<{ visibleReplySent: boolean }> {
const { payload, channel, account, accountId, config, runtime } = params;
try {
const clientManager = getOrCreateClientManager(accountId, {
info: (msg) => runtime.log?.(msg),
warn: (msg) => runtime.log?.(msg),
error: (msg) => runtime.error?.(msg),
debug: (msg) => runtime.log?.(msg),
});
const client = await clientManager.getClient(
account,
config as Parameters<typeof clientManager.getClient>[1],
accountId,
);
if (!client) {
runtime.error?.(`No client available for sending reply`);
return { visibleReplySent: false };
}
// Send the reply
if (!payload.text) {
runtime.error?.(`No text to send in reply payload`);
return { visibleReplySent: false };
}
const textToSend = stripMarkdownForTwitch(payload.text);
await client.say(channel, textToSend);
return { visibleReplySent: true };
} catch (err) {
runtime.error?.(`Failed to send reply: ${String(err)}`);
return { visibleReplySent: false };
}
}
/**
* Main monitor provider for Twitch.
*
* Sets up message handlers and processes incoming messages.
*/
export async function monitorTwitchProvider(
options: TwitchMonitorOptions,
): Promise<TwitchMonitorResult> {
const { account, accountId, config, runtime, abortSignal, statusSink } = options;
const core = getTwitchRuntime();
let stopped = false;
const coreLogger = core.logging.getChildLogger({ module: "twitch" });
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;
}
coreLogger.debug?.(message);
};
const logger = {
info: (msg: string) => coreLogger.info(msg),
warn: (msg: string) => coreLogger.warn(msg),
error: (msg: string) => coreLogger.error(msg),
debug: logVerboseMessage,
};
const clientManager = getOrCreateClientManager(accountId, logger);
try {
await clientManager.getClient(
account,
config as Parameters<typeof clientManager.getClient>[1],
accountId,
);
} catch (error) {
const errorMsg = formatErrorMessage(error);
runtime.error?.(`Failed to connect: ${errorMsg}`);
throw error;
}
const unregisterHandler = clientManager.onMessage(account, (message) => {
if (stopped) {
return;
}
void (async () => {
const botUsername = normalizeLowercaseStringOrEmpty(account.username);
if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) {
return;
}
const access = await checkTwitchAccessControl({
message,
account,
botUsername,
});
if (stopped || !access.allowed) {
return;
}
statusSink?.({ lastInboundAt: Date.now() });
await processTwitchMessage({
message,
account,
accountId,
config,
runtime,
core,
statusSink,
});
})().catch((err: unknown) => {
runtime.error?.(`Message processing failed: ${String(err)}`);
});
});
const stop = () => {
stopped = true;
unregisterHandler();
};
abortSignal.addEventListener("abort", stop, { once: true });
return { stop };
}

View File

@@ -0,0 +1,607 @@
/**
* Tests for outbound.ts module
*
* Tests cover:
* - resolveTarget with various modes (explicit, implicit, heartbeat)
* - sendText with markdown stripping
* - sendMedia delegation to sendText
* - Error handling for missing accounts/channels
* - Abort signal handling
*/
import {
createMessageReceiptFromOutboundResults,
verifyChannelMessageAdapterCapabilityProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { describe, expect, it, vi } from "vitest";
import { resolveTwitchAccountContext } from "./config.js";
import { twitchMessageAdapter, twitchOutbound } from "./outbound.js";
import {
BASE_TWITCH_TEST_ACCOUNT,
installTwitchTestHooks,
makeTwitchTestConfig,
} from "./test-fixtures.js";
// Mock dependencies
vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default",
resolveTwitchAccountContext: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendMessageTwitchInternal: vi.fn(),
}));
vi.mock("./utils/markdown.js", () => ({
chunkTextForTwitch: vi.fn(chunkMockTextForTwitch),
}));
vi.mock("./utils/twitch.js", () => ({
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
missingTargetError: (channel: string, hint: string) =>
new Error(`Missing target for ${channel}. Provide ${hint}`),
}));
function chunkMockTextForTwitch(text: string): string[] {
const chunks: string[] = [];
for (const chunk of text.split(/(.{500})/)) {
if (chunk.length > 0) {
chunks.push(chunk);
}
}
return chunks;
}
function assertResolvedTarget(
result: ReturnType<NonNullable<typeof twitchOutbound.resolveTarget>>,
): string {
if (!result.ok) {
throw result.error;
}
return result.to;
}
function expectTargetError(
resolveTarget: NonNullable<typeof twitchOutbound.resolveTarget>,
params: Parameters<NonNullable<typeof twitchOutbound.resolveTarget>>[0],
expectedMessage: string,
) {
const result = resolveTarget(params);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected resolveTarget to fail");
}
expect(result.error.message).toContain(expectedMessage);
}
function twitchTestReceipt(messageId: string) {
return createMessageReceiptFromOutboundResults({
results: [
{
channel: "twitch",
conversationId: "testchannel",
messageId,
},
],
kind: "text",
});
}
describe("outbound", () => {
const mockAccount = {
...BASE_TWITCH_TEST_ACCOUNT,
accessToken: "oauth:test123",
};
const resolveTarget = twitchOutbound.resolveTarget!;
const mockConfig = makeTwitchTestConfig(mockAccount);
installTwitchTestHooks();
function setupAccountContext(params?: {
account?: typeof mockAccount | null;
availableAccountIds?: string[];
}) {
const account = params?.account === undefined ? mockAccount : params.account;
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
accountId: accountId?.trim() || "default",
account,
tokenResolution: { source: "config", token: account?.accessToken ?? "" },
configured: account !== null,
availableAccountIds: params?.availableAccountIds ?? ["default"],
}));
}
const abortedSendCases = [
{
name: "sendText",
invoke: (signal: AbortSignal) =>
twitchOutbound.sendText!({
cfg: mockConfig,
to: "#testchannel",
text: "Hello!",
accountId: "default",
signal,
} as Parameters<NonNullable<typeof twitchOutbound.sendText>>[0]),
},
{
name: "sendMedia",
invoke: (signal: AbortSignal) =>
twitchOutbound.sendMedia!({
cfg: mockConfig,
to: "#testchannel",
text: "Check this:",
mediaUrl: "https://example.com/image.png",
accountId: "default",
signal,
} as Parameters<NonNullable<typeof twitchOutbound.sendMedia>>[0]),
},
];
describe("abort handling", () => {
it.each(abortedSendCases)("$name should handle abort signal", async ({ invoke }) => {
const abortController = new AbortController();
abortController.abort();
await expect(invoke(abortController.signal)).rejects.toThrow("Outbound delivery aborted");
});
});
describe("metadata", () => {
it("should have direct delivery mode", () => {
expect(twitchOutbound.deliveryMode).toBe("direct");
});
it("should have 500 character text chunk limit", () => {
expect(twitchOutbound.textChunkLimit).toBe(500);
});
it("should chunk long messages at 500 characters", () => {
const chunker = twitchOutbound.chunker;
if (!chunker) {
throw new Error("twitch outbound.chunker unavailable");
}
expect(chunker("a".repeat(600), 500)).toEqual(["a".repeat(500), "a".repeat(100)]);
});
it("declares message adapter durable text and media with receipt proofs", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
setupAccountContext();
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "twitch-msg-123",
receipt: twitchTestReceipt("twitch-msg-123"),
});
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "twitch",
adapter: twitchMessageAdapter,
proofs: {
text: async () => {
const result = await twitchMessageAdapter.send?.text?.({
cfg: mockConfig,
to: "#testchannel",
text: "Hello Twitch!",
accountId: "default",
});
expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
},
media: async () => {
const result = await twitchMessageAdapter.send?.media?.({
cfg: mockConfig,
to: "#testchannel",
text: "image",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
expect(sendMessageTwitchInternal).toHaveBeenLastCalledWith(
"testchannel",
"image https://example.com/image.png",
mockConfig,
"default",
true,
console,
);
},
messageSendingHooks: () => {
expect(twitchMessageAdapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true);
},
},
});
expect(proofResults).toEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "not_declared" },
{ capability: "thread", status: "not_declared" },
{ capability: "nativeQuote", status: "not_declared" },
{ capability: "messageSendingHooks", status: "verified" },
{ capability: "batch", status: "not_declared" },
{ capability: "reconcileUnknownSend", status: "not_declared" },
{ capability: "afterSendSuccess", status: "not_declared" },
{ capability: "afterCommit", status: "not_declared" },
]);
});
it("adapts outbound progress into message receipts", async () => {
const progress = {
channel: "twitch",
messageId: "twitch-progress-1",
receipt: twitchTestReceipt("twitch-progress-1"),
};
const sendText = twitchOutbound.sendText;
if (!sendText) {
throw new Error("Twitch text sending is not available.");
}
const sendSpy = vi.spyOn(twitchOutbound, "sendText").mockImplementationOnce(async (ctx) => {
await ctx.onDeliveryResult?.(progress);
return progress;
});
const onDeliveryResult = vi.fn();
try {
await twitchMessageAdapter.send?.text?.({
cfg: mockConfig,
to: "#testchannel",
text: "Hello Twitch!",
accountId: "default",
onDeliveryResult,
});
} finally {
sendSpy.mockRestore();
}
expect(onDeliveryResult).toHaveBeenCalledOnce();
expect(onDeliveryResult.mock.calls[0]?.[0]?.receipt.platformMessageIds).toEqual([
"twitch-progress-1",
]);
});
});
describe("resolveTarget", () => {
it("should normalize and return target in explicit mode", () => {
const result = resolveTarget({
to: "#MyChannel",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(assertResolvedTarget(result)).toBe("mychannel");
});
it("should return target in implicit mode with wildcard allowlist", () => {
const result = resolveTarget({
to: "#AnyChannel",
mode: "implicit",
allowFrom: ["*"],
});
expect(result.ok).toBe(true);
expect(assertResolvedTarget(result)).toBe("anychannel");
});
it("should return target in implicit mode when in allowlist", () => {
const result = resolveTarget({
to: "#allowed",
mode: "implicit",
allowFrom: ["#allowed", "#other"],
});
expect(result.ok).toBe(true);
expect(assertResolvedTarget(result)).toBe("allowed");
});
it("should error when target not in allowlist (implicit mode)", () => {
expectTargetError(
resolveTarget,
{
to: "#notallowed",
mode: "implicit",
allowFrom: ["#primary", "#secondary"],
},
"Twitch",
);
});
it("should accept any target when allowlist is empty", () => {
const result = resolveTarget({
to: "#anychannel",
mode: "heartbeat",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(assertResolvedTarget(result)).toBe("anychannel");
});
it("should error when no target provided with allowlist", () => {
expectTargetError(
resolveTarget,
{
to: undefined,
mode: "implicit",
allowFrom: ["#fallback", "#other"],
},
"Twitch",
);
});
it("should return error when no target and no allowlist", () => {
expectTargetError(
resolveTarget,
{
to: undefined,
mode: "explicit",
allowFrom: [],
},
"Missing target",
);
});
it("should handle whitespace-only target", () => {
expectTargetError(
resolveTarget,
{
to: " ",
mode: "explicit",
allowFrom: [],
},
"Missing target",
);
});
it("should error when target normalizes to empty string", () => {
expectTargetError(
resolveTarget,
{
to: "#",
mode: "explicit",
allowFrom: [],
},
"Twitch",
);
});
it("should filter wildcard from allowlist when checking membership", () => {
const result = resolveTarget({
to: "#mychannel",
mode: "implicit",
allowFrom: ["*", "#specific"],
});
// With wildcard, any target is accepted
expect(result.ok).toBe(true);
expect(assertResolvedTarget(result)).toBe("mychannel");
});
});
describe("sendText", () => {
it("should send message successfully", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
setupAccountContext();
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "twitch-msg-123",
receipt: twitchTestReceipt("twitch-msg-123"),
});
const result = await twitchOutbound.sendText!({
cfg: mockConfig,
to: "#testchannel",
text: "Hello Twitch!",
accountId: "default",
});
expect(result.channel).toBe("twitch");
expect(result.messageId).toBe("twitch-msg-123");
expect(result.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel",
"Hello Twitch!",
mockConfig,
"default",
true,
console,
);
expect(result.timestamp).toBeGreaterThan(0);
});
it("should throw when account not found", async () => {
setupAccountContext({ account: null });
await expect(
twitchOutbound.sendText!({
cfg: mockConfig,
to: "#testchannel",
text: "Hello!",
accountId: "nonexistent",
}),
).rejects.toThrow("Twitch account not found: nonexistent");
});
it("should throw when no channel specified", async () => {
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
setupAccountContext({ account: accountWithoutChannel });
await expect(
twitchOutbound.sendText!({
cfg: mockConfig,
to: "",
text: "Hello!",
accountId: "default",
}),
).rejects.toThrow("No channel specified");
});
it("should use account channel when target not provided", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
setupAccountContext();
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "msg-456",
receipt: twitchTestReceipt("msg-456"),
});
await twitchOutbound.sendText!({
cfg: mockConfig,
to: "",
text: "Hello!",
accountId: "default",
});
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel",
"Hello!",
mockConfig,
"default",
true,
console,
);
});
it("uses configured defaultAccount when accountId is omitted", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(resolveTwitchAccountContext)
.mockImplementationOnce(() => ({
accountId: "secondary",
account: {
...mockAccount,
channel: "secondary-channel",
},
tokenResolution: { source: "config", token: mockAccount.accessToken },
configured: true,
availableAccountIds: ["default", "secondary"],
}))
.mockImplementation((_cfg, accountId) => ({
accountId: accountId?.trim() || "secondary",
account: {
...mockAccount,
channel: "secondary-channel",
},
tokenResolution: { source: "config", token: mockAccount.accessToken },
configured: true,
availableAccountIds: ["default", "secondary"],
}));
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "msg-secondary",
receipt: twitchTestReceipt("msg-secondary"),
});
const defaultAccountConfig = {
channels: {
twitch: {
defaultAccount: "secondary",
},
},
} as typeof mockConfig;
await twitchOutbound.sendText!({
cfg: defaultAccountConfig,
to: "#secondary-channel",
text: "Hello!",
});
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"secondary-channel",
"Hello!",
defaultAccountConfig,
"secondary",
true,
console,
);
});
it("should throw on send failure", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
setupAccountContext();
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: false,
messageId: "failed-msg",
receipt: createMessageReceiptFromOutboundResults({ results: [] }),
error: "Connection lost",
});
await expect(
twitchOutbound.sendText!({
cfg: mockConfig,
to: "#testchannel",
text: "Hello!",
accountId: "default",
}),
).rejects.toThrow("Connection lost");
});
});
describe("sendMedia", () => {
it("should combine text and media URL", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
setupAccountContext();
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "media-msg-123",
receipt: twitchTestReceipt("media-msg-123"),
});
const result = await twitchOutbound.sendMedia!({
cfg: mockConfig,
to: "#testchannel",
text: "Check this:",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(result.channel).toBe("twitch");
expect(result.messageId).toBe("media-msg-123");
expect(result.receipt?.platformMessageIds).toEqual(["media-msg-123"]);
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel",
"Check this: https://example.com/image.png",
mockConfig,
"default",
true,
console,
);
});
it("should send media URL only when no text", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
setupAccountContext();
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "media-only-msg",
receipt: twitchTestReceipt("media-only-msg"),
});
await twitchOutbound.sendMedia!({
cfg: mockConfig,
to: "#testchannel",
text: "",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel",
"https://example.com/image.png",
mockConfig,
"default",
true,
console,
);
});
});
});

View File

@@ -0,0 +1,263 @@
/**
* Twitch outbound adapter for sending messages.
*
* Implements the ChannelOutboundAdapter interface for Twitch chat.
* Supports text and media (URL) sending with markdown stripping and chunking.
*/
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
type ChannelMessageSendResult,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveTwitchAccountContext } from "./config.js";
import { sendMessageTwitchInternal } from "./send.js";
import type {
ChannelOutboundAdapter,
ChannelOutboundContext,
OutboundDeliveryResult,
} from "./types.js";
import { chunkTextForTwitch } from "./utils/markdown.js";
import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
/**
* Twitch outbound adapter.
*
* Handles sending text and media to Twitch channels with automatic
* markdown stripping and message chunking.
*/
export const twitchOutbound: ChannelOutboundAdapter = {
/** Direct delivery mode - messages are sent immediately */
deliveryMode: "direct",
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
messageSendingHooks: true,
},
},
/** Twitch chat message limit is 500 characters */
textChunkLimit: 500,
/** Word-boundary chunker with markdown stripping */
chunker: chunkTextForTwitch,
/**
* Resolve target from context.
*
* Handles target resolution with allowlist support for implicit/heartbeat modes.
* For explicit mode, accepts any valid channel name.
*
* @param params - Resolution parameters
* @returns Resolved target or error
*/
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = normalizeStringEntries(allowFrom ?? []);
const hasWildcard = allowListRaw.includes("*");
const allowList = allowListRaw
.filter((entry: string) => entry !== "*")
.map((entry: string) => normalizeTwitchChannel(entry))
.filter((entry): entry is string => entry.length > 0);
// If target is provided, normalize and validate it
if (trimmed) {
const normalizedTo = normalizeTwitchChannel(trimmed);
if (!normalizedTo) {
return {
ok: false,
error: missingTargetError("Twitch", "<channel-name>"),
};
}
// For implicit/heartbeat modes with allowList, check against allowlist
if (mode === "implicit" || mode === "heartbeat") {
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return {
ok: false,
error: missingTargetError("Twitch", "<channel-name>"),
};
}
// For explicit mode, accept any valid channel name
return { ok: true, to: normalizedTo };
}
// No target provided - error
// No target and no allowFrom - error
return {
ok: false,
error: missingTargetError("Twitch", "<channel-name>"),
};
},
/**
* Send a text message to a Twitch channel.
*
* Strips markdown if enabled, validates account configuration,
* and sends the message via the Twitch client.
*
* @param params - Send parameters including target, text, and config
* @returns Delivery result with message ID and status
*
* @example
* const result = await twitchOutbound.sendText({
* cfg: openclawConfig,
* to: "#mychannel",
* text: "Hello Twitch!",
* accountId: "default",
* });
*/
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
const { cfg, to, text, accountId } = params;
const signal = (params as { signal?: AbortSignal }).signal;
if (signal?.aborted) {
throw new Error("Outbound delivery aborted");
}
const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
if (!account) {
throw new Error(
`Twitch account not found: ${resolvedAccountId}. ` +
`Available accounts: ${availableAccountIds.join(", ") || "none"}`,
);
}
const channel = to || account.channel;
if (!channel) {
throw new Error("No channel specified and no default channel in account config");
}
const result = await sendMessageTwitchInternal(
normalizeTwitchChannel(channel),
text,
cfg,
resolvedAccountId,
true, // stripMarkdown
console,
);
if (!result.ok) {
throw new Error(result.error ?? "Send failed");
}
return {
channel: "twitch",
messageId: result.messageId,
receipt: result.receipt,
timestamp: Date.now(),
};
},
/**
* Send media to a Twitch channel.
*
* Note: Twitch chat doesn't support direct media uploads.
* This sends the media URL as text instead.
*
* @param params - Send parameters including media URL
* @returns Delivery result with message ID and status
*
* @example
* const result = await twitchOutbound.sendMedia({
* cfg: openclawConfig,
* to: "#mychannel",
* text: "Check this out!",
* mediaUrl: "https://example.com/image.png",
* accountId: "default",
* });
*/
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
const { text, mediaUrl } = params;
const signal = (params as { signal?: AbortSignal }).signal;
if (signal?.aborted) {
throw new Error("Outbound delivery aborted");
}
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
if (!twitchOutbound.sendText) {
throw new Error("sendText not implemented");
}
return twitchOutbound.sendText({
...params,
text: message,
});
},
};
function toTwitchMessageSendResult(
result: OutboundDeliveryResult,
kind: MessageReceiptPartKind,
): ChannelMessageSendResult {
const receipt =
result.receipt ??
createMessageReceiptFromOutboundResults({
results: result.messageId ? [{ channel: "twitch", messageId: result.messageId }] : [],
kind,
});
return {
messageId: result.messageId || receipt.primaryPlatformMessageId,
receipt,
};
}
export const twitchMessageAdapter = defineChannelMessageAdapter({
id: "twitch",
durableFinal: {
capabilities: {
text: true,
media: true,
messageSendingHooks: true,
},
},
send: {
text: async (ctx) => {
if (!twitchOutbound.sendText) {
throw new Error("Twitch text sending is not available.");
}
const { onDeliveryResult, ...outboundCtx } = ctx;
const result = await twitchOutbound.sendText({
...outboundCtx,
...(onDeliveryResult
? {
onDeliveryResult: async (progress) => {
await onDeliveryResult(toTwitchMessageSendResult(progress, "text"));
},
}
: {}),
});
return toTwitchMessageSendResult(result, "text");
},
media: async (ctx) => {
if (!twitchOutbound.sendMedia) {
throw new Error("Twitch media sending is not available.");
}
const { onDeliveryResult, ...outboundCtx } = ctx;
const result = await twitchOutbound.sendMedia({
...outboundCtx,
...(onDeliveryResult
? {
onDeliveryResult: async (progress) => {
await onDeliveryResult(toTwitchMessageSendResult(progress, "media"));
},
}
: {}),
});
return toTwitchMessageSendResult(result, "media");
},
},
});

View File

@@ -0,0 +1,105 @@
// Twitch tests cover plugin.lifecycle plugin behavior.
import {
createStartAccountContext,
expectLifecyclePatch,
expectStopPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TwitchAccountConfig } from "./types.js";
const hoisted = vi.hoisted(() => ({
monitorTwitchProvider: vi.fn(),
}));
vi.mock("./monitor.js", () => ({
monitorTwitchProvider: hoisted.monitorTwitchProvider,
}));
const { twitchPlugin } = await import("./plugin.js");
type TwitchStartAccount = NonNullable<NonNullable<typeof twitchPlugin.gateway>["startAccount"]>;
function requireStartAccount(): TwitchStartAccount {
const startAccount = twitchPlugin.gateway?.startAccount;
if (!startAccount) {
throw new Error("Expected Twitch gateway startAccount");
}
return startAccount;
}
function buildAccount(): TwitchAccountConfig & { accountId: string } {
return {
accountId: "default",
username: "testbot",
accessToken: "oauth:test-token",
clientId: "test-client-id",
channel: "#testchannel",
enabled: true,
};
}
function mockStartedMonitor() {
const stop = vi.fn();
hoisted.monitorTwitchProvider.mockResolvedValue({ stop });
return stop;
}
function startTwitchAccount(abortSignal?: AbortSignal) {
return requireStartAccount()(
createStartAccountContext({
account: buildAccount(),
abortSignal,
}),
);
}
describe("twitch startAccount lifecycle", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("keeps startAccount pending until abort, then stops the monitor", async () => {
const stop = mockStartedMonitor();
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: requireStartAccount(),
account: buildAccount(),
});
await expectStopPendingUntilAbort({
waitForStarted: waitForStartedMocks(hoisted.monitorTwitchProvider),
isSettled,
abort,
task,
stop,
});
});
it("stops immediately when startAccount receives an already-aborted signal", async () => {
const stop = mockStartedMonitor();
const abort = new AbortController();
abort.abort();
await startTwitchAccount(abort.signal);
expect(hoisted.monitorTwitchProvider).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledOnce();
});
it("clears running status when monitor startup fails", async () => {
hoisted.monitorTwitchProvider.mockRejectedValue(new Error("irc join failed"));
const patches: ChannelAccountSnapshot[] = [];
const task = requireStartAccount()(
createStartAccountContext({
account: buildAccount(),
statusPatchSink: (next) => patches.push({ ...next }),
}),
);
await expect(task).rejects.toThrow("irc join failed");
expectLifecyclePatch(patches, { running: true });
expectLifecyclePatch(patches, { running: false });
});
});

View File

@@ -0,0 +1,122 @@
/**
* Live Twitch IRC verification for the runStoppablePassiveMonitor lifecycle
* pattern used by the Twitch gateway.
*
* This test connects to irc.chat.twitch.tv using the same twurple stack the
* Twitch plugin uses, then drives that connection through the helper this PR
* wires into twitchPlugin.gateway.startAccount. It asserts the post-fix
* invariant — startAccount-shaped task stays pending after a successful
* connection and only resolves when the abort signal fires — using real
* network rather than mocks.
*
* Skipped by default. Enable with:
* TWITCH_LIVE_TEST=1
* TWITCH_USERNAME=<bot username>
* TWITCH_ACCESS_TOKEN=<oauth:token without the "oauth:" prefix>
* TWITCH_CLIENT_ID=<client id>
* TWITCH_CHANNEL=<channel name to join>
*/
import { StaticAuthProvider } from "@twurple/auth";
import { ChatClient } from "@twurple/chat";
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
import { describe, expect, it } from "vitest";
const LIVE = process.env.TWITCH_LIVE_TEST === "1";
const HAS_CREDS = Boolean(
process.env.TWITCH_USERNAME &&
process.env.TWITCH_ACCESS_TOKEN &&
process.env.TWITCH_CLIENT_ID &&
process.env.TWITCH_CHANNEL,
);
const maybeDescribe = LIVE && HAS_CREDS ? describe : describe.skip;
maybeDescribe("twitch live IRC lifecycle (skipped unless TWITCH_LIVE_TEST=1)", () => {
it("real twurple connection + runStoppablePassiveMonitor stays pending until abort, then stops cleanly", async () => {
const accessTokenRaw = process.env.TWITCH_ACCESS_TOKEN!.replace(/^oauth:/, "");
const clientId = process.env.TWITCH_CLIENT_ID!;
const channel = process.env.TWITCH_CHANNEL!;
const username = process.env.TWITCH_USERNAME!;
const start = Date.now();
const log = (msg: string) => {
console.log(`[T+${Date.now() - start}ms] ${msg}`);
};
log(`username=${username} channel=#${channel}`);
const authProvider = new StaticAuthProvider(clientId, accessTokenRaw, [
"chat:read",
"chat:edit",
]);
const abort = new AbortController();
let connectedAt: number | null = null;
let settled = false;
let stopCalled = false;
const task = runStoppablePassiveMonitor({
abortSignal: abort.signal,
start: async () => {
const chat = new ChatClient({
authProvider,
channels: [channel],
authIntents: ["chat"],
});
chat.onConnect(() => {
connectedAt = Date.now() - start;
log(`Connected to Twitch as ${username}`);
});
chat.onJoin((joinedChannel: string, joinedUser: string) => {
log(`Joined #${joinedChannel} as ${joinedUser}`);
});
chat.onDisconnect((manually: boolean, reason?: Error) => {
log(`Disconnected (manual=${manually}, reason=${reason?.message ?? "n/a"})`);
});
chat.connect();
return {
stop: () => {
stopCalled = true;
log(`stop() invoked`);
chat.quit();
},
};
},
})
.then(() => {
settled = true;
log(`task RESOLVED`);
})
.catch((err: unknown) => {
settled = true;
log(`task REJECTED: ${err instanceof Error ? err.message : String(err)}`);
throw err;
});
// Wait long enough that the original bug would have manifested.
// The reported time-to-restart in #60071 is ~2ms after connect.
const WATCH_MS = 15_000;
await new Promise((resolve) => {
setTimeout(resolve, WATCH_MS);
});
expect(connectedAt, "expected onConnect within the watch window").not.toBeNull();
expect(settled, "task must not have settled before abort").toBe(false);
log(
`--- t+${WATCH_MS}ms checkpoint: connected=${connectedAt}ms, settled=${settled}, stopCalled=${stopCalled}`,
);
abort.abort();
log(`abort() called`);
await task;
expect(settled).toBe(true);
expect(stopCalled, "stop hook must run on abort").toBe(true);
log(`PASS — promise pending for ${WATCH_MS}ms after connect, then stopped on abort`);
}, 60_000);
});

View File

@@ -0,0 +1,78 @@
// Twitch tests cover plugin plugin behavior.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { twitchPlugin } from "./plugin.js";
describe("twitchPlugin pairing", () => {
it("normalizes trimmed twitch user prefixes in allow entries", () => {
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" twitch:user:123456 ")).toBe("123456");
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" user789012 ")).toBe("789012");
});
});
describe("twitchPlugin.status.buildAccountSnapshot", () => {
it("uses the resolved account ID for multi-account configs", async () => {
const secondary = {
channel: "secondary-channel",
username: "secondary",
accessToken: "oauth:secondary-token",
clientId: "secondary-client",
enabled: true,
};
const cfg = {
channels: {
twitch: {
accounts: {
default: {
channel: "default-channel",
username: "default",
accessToken: "oauth:default-token",
clientId: "default-client",
enabled: true,
},
secondary,
},
},
},
} as OpenClawConfig;
const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
account: secondary,
cfg,
});
expect(snapshot?.accountId).toBe("secondary");
});
});
describe("twitchPlugin.config", () => {
it("uses configured defaultAccount for omitted-account plugin resolution", () => {
const cfg = {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
channel: "default-channel",
username: "default",
accessToken: "oauth:default-token",
clientId: "default-client",
enabled: true,
},
secondary: {
channel: "secondary-channel",
username: "secondary",
accessToken: "oauth:secondary-token",
clientId: "secondary-client",
enabled: true,
},
},
},
},
} as OpenClawConfig;
expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary");
});
});

View File

@@ -0,0 +1,229 @@
/**
* Twitch channel plugin for OpenClaw.
*
* Main plugin export combining all adapters (outbound, actions, status, gateway).
* This is the primary entry point for the Twitch channel integration.
*/
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
createLoggedPairingApprovalNotifier,
createPairingPrefixStripper,
} from "openclaw/plugin-sdk/channel-pairing";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
buildPassiveProbedChannelStatusSummary,
runStoppablePassiveMonitor,
} from "openclaw/plugin-sdk/extension-shared";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { twitchMessageActions } from "./actions.js";
import { removeClientManager } from "./client-manager-registry.js";
import { TwitchConfigSchema } from "./config-schema.js";
import {
DEFAULT_ACCOUNT_ID,
getAccountConfig,
listAccountIds,
resolveDefaultTwitchAccountId,
resolveTwitchAccountContext,
resolveTwitchSnapshotAccountId,
} from "./config.js";
import { twitchMessageAdapter, 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 type {
ChannelLogSink,
ChannelPlugin,
ChannelResolveKind,
ChannelResolveResult,
TwitchAccountConfig,
} from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
/**
* Twitch channel plugin.
*
* Implements the ChannelPlugin interface to provide Twitch chat integration
* for OpenClaw. Supports message sending, receiving, access control, and
* status monitoring.
*/
export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
createChatChannelPlugin<ResolvedTwitchAccount>({
pairing: {
idLabel: "twitchUserId",
normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
notifyApproval: createLoggedPairingApprovalNotifier(
({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`,
console.warn,
),
},
outbound: twitchOutbound,
base: {
id: "twitch",
meta: {
id: "twitch",
label: "Twitch",
selectionLabel: "Twitch (Chat)",
docsPath: "/channels/twitch",
blurb: "Twitch chat integration",
aliases: ["twitch-chat"],
},
setup: twitchSetupAdapter,
setupWizard: twitchSetupWizard,
capabilities: {
chatTypes: ["group"],
},
message: twitchMessageAdapter,
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
config: {
listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => {
const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return {
accountId: resolvedAccountId,
channel: "",
username: "",
accessToken: "",
clientId: "",
enabled: false,
};
}
return {
accountId: resolvedAccountId,
...account,
};
},
defaultAccountId: (cfg: OpenClawConfig): string => resolveDefaultTwitchAccountId(cfg),
isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean =>
resolveTwitchAccountContext(cfg).configured,
isEnabled: (account: ResolvedTwitchAccount | undefined): boolean =>
account?.enabled !== false,
describeAccount: (account: TwitchAccountConfig | undefined) =>
account
? describeAccountSnapshot({
account,
configured: isAccountConfigured(account, account.accessToken),
})
: {
accountId: DEFAULT_ACCOUNT_ID,
enabled: false,
configured: false,
},
},
actions: twitchMessageActions,
resolver: {
resolveTargets: async ({
cfg,
accountId,
inputs,
kind,
runtime,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
inputs: string[];
kind: ChannelResolveKind;
runtime: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
}): Promise<ChannelResolveResult[]> => {
const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
if (!account) {
return inputs.map((input) => ({
input,
resolved: false,
note: "account not configured",
}));
}
const log: ChannelLogSink = {
info: (msg) => runtime.log(msg),
warn: (msg) => runtime.log(msg),
error: (msg) => runtime.error(msg),
debug: (msg) => runtime.log(msg),
};
return await resolveTwitchTargets(inputs, account, kind, log);
},
},
status: createComputedAccountStatusAdapter<ResolvedTwitchAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
collectStatusIssues: collectTwitchStatusIssues,
resolveAccountSnapshot: ({ account, cfg }) => {
const resolvedAccountId =
account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
return {
accountId: resolvedAccountId,
enabled: account.enabled !== false,
configured,
};
},
}),
gateway: {
startAccount: async (ctx): Promise<void> => {
const account = ctx.account;
const accountId = ctx.accountId;
ctx.setStatus?.({
accountId,
running: true,
lastStartAt: Date.now(),
lastError: null,
});
ctx.log?.info(`Starting Twitch connection for ${account.username}`);
// Keep startAccount pending until abort fires; otherwise the channel
// supervisor reads the settled task as `channel exited without an
// error` and triggers a restart loop. See #60071.
try {
await runStoppablePassiveMonitor({
abortSignal: ctx.abortSignal,
start: async () => {
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorTwitchProvider } = await import("./monitor.js");
return monitorTwitchProvider({
account,
accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
});
} catch (error) {
ctx.setStatus?.({
accountId,
running: false,
lastStopAt: Date.now(),
});
throw error;
}
},
stopAccount: async (ctx): Promise<void> => {
const account = ctx.account;
const accountId = ctx.accountId;
await removeClientManager(accountId);
ctx.setStatus?.({
accountId,
running: false,
lastStopAt: Date.now(),
});
ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
},
},
},
});

View File

@@ -0,0 +1,197 @@
// Twitch tests cover probe plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { probeTwitch } from "./probe.js";
import type { TwitchAccountConfig } from "./types.js";
// Mock Twurple modules - Vitest v4 compatible mocking
const mockUnbind = vi.fn();
// Event handler storage
let connectHandler: (() => void) | null = null;
let disconnectHandler: ((manually: boolean, reason?: Error) => void) | null = null;
// Event listener mocks that store handlers and return unbind function
const mockOnConnect = vi.fn((handler: () => void) => {
connectHandler = handler;
return { unbind: mockUnbind };
});
const mockOnDisconnect = vi.fn((handler: (manually: boolean, reason?: Error) => void) => {
disconnectHandler = handler;
return { unbind: mockUnbind };
});
const mockOnAuthenticationFailure = vi.fn((_handler: () => void) => {
return { unbind: mockUnbind };
});
// Connect mock that triggers the registered handler
const defaultConnectImpl = async () => {
// Simulate successful connection by calling the handler immediately.
if (connectHandler) {
connectHandler();
}
};
const mockConnect = vi.fn().mockImplementation(defaultConnectImpl);
const mockQuit = vi.fn().mockResolvedValue(undefined);
vi.mock("@twurple/chat", () => ({
ChatClient: class {
connect = mockConnect;
quit = mockQuit;
onConnect = mockOnConnect;
onDisconnect = mockOnDisconnect;
onAuthenticationFailure = mockOnAuthenticationFailure;
},
}));
vi.mock("@twurple/auth", () => ({
StaticAuthProvider: function StaticAuthProvider() {},
}));
describe("probeTwitch", () => {
const mockAccount: TwitchAccountConfig = {
username: "testbot",
accessToken: "oauth:test123456789",
clientId: "test-client-id",
channel: "testchannel",
};
beforeEach(() => {
vi.clearAllMocks();
// Reset handlers
connectHandler = null;
disconnectHandler = null;
});
it("returns error when username is missing", async () => {
const account = { ...mockAccount, username: "" };
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("missing credentials");
});
it("returns error when token is missing", async () => {
const account = { ...mockAccount, accessToken: "" };
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("missing credentials");
});
it("attempts connection regardless of token prefix", async () => {
// Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided
// The actual connection would fail in production with an invalid token
const account = { ...mockAccount, accessToken: "raw_token_no_prefix" };
const result = await probeTwitch(account, 5000);
// With mock, connection succeeds even without oauth: prefix
expect(result.ok).toBe(true);
});
it("successfully connects with valid credentials", async () => {
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(true);
expect(result.connected).toBe(true);
expect(result.username).toBe("testbot");
expect(result.channel).toBe("testchannel"); // uses account's configured channel
});
it("uses custom channel when specified", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
channel: "customchannel",
};
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(true);
expect(result.channel).toBe("customchannel");
});
it("times out when connection takes too long", async () => {
vi.useFakeTimers();
try {
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
const resultPromise = probeTwitch(mockAccount, 100);
await vi.advanceTimersByTimeAsync(100);
const result = await resultPromise;
expect(result.ok).toBe(false);
expect(result.error).toContain("timeout");
} finally {
vi.useRealTimers();
mockConnect.mockImplementation(defaultConnectImpl);
}
});
it("cleans up client even on failure", async () => {
mockConnect.mockImplementationOnce(async () => {
// Simulate connection failure by calling disconnect handler
// onDisconnect signature: (manually: boolean, reason?: Error) => void
if (disconnectHandler) {
disconnectHandler(false, new Error("Connection failed"));
}
});
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("Connection failed");
expect(mockQuit).toHaveBeenCalled();
// Reset mocks
mockConnect.mockImplementation(defaultConnectImpl);
});
it("handles connection errors gracefully", async () => {
mockConnect.mockImplementationOnce(async () => {
// Simulate connection failure by calling disconnect handler
// onDisconnect signature: (manually: boolean, reason?: Error) => void
if (disconnectHandler) {
disconnectHandler(false, new Error("Network error"));
}
});
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("Network error");
// Reset mock
mockConnect.mockImplementation(defaultConnectImpl);
});
it("trims token before validation", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
accessToken: " oauth:test123456789 ",
};
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(true);
});
it("handles non-Error objects in catch block", async () => {
mockConnect.mockImplementationOnce(async () => {
// Simulate connection failure by calling disconnect handler
// onDisconnect signature: (manually: boolean, reason?: Error) => void
if (disconnectHandler) {
disconnectHandler(false, "String error" as unknown as Error);
}
});
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(false);
expect(result.error).toBe("String error");
// Reset mock
mockConnect.mockImplementation(defaultConnectImpl);
});
});

View File

@@ -0,0 +1,132 @@
// Twitch plugin module implements probe behavior.
import { StaticAuthProvider } from "@twurple/auth";
import { ChatClient } from "@twurple/chat";
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { TwitchAccountConfig } from "./types.js";
import { normalizeToken } from "./utils/twitch.js";
/**
* Result of probing a Twitch account
*/
type ProbeTwitchResult = BaseProbeResult<string> & {
username?: string;
elapsedMs: number;
connected?: boolean;
channel?: string;
};
/**
* Probe a Twitch account to verify the connection is working
*
* This tests the Twitch OAuth token by attempting to connect
* to the chat server and verify the bot's username.
*/
export async function probeTwitch(
account: TwitchAccountConfig,
timeoutMs: number,
): Promise<ProbeTwitchResult> {
const started = Date.now();
if (!account.accessToken || !account.username) {
return {
ok: false,
error: "missing credentials (accessToken, username)",
username: account.username,
elapsedMs: Date.now() - started,
};
}
const rawToken = normalizeToken(account.accessToken.trim());
let client: ChatClient | undefined;
try {
const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
client = new ChatClient({
authProvider,
});
// Create a promise that resolves when connected
const connectionPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const cleanup = () => {
if (settled) {
return;
}
settled = true;
connectListener?.unbind();
disconnectListener?.unbind();
authFailListener?.unbind();
};
// Success: connection established
const connectListener: ReturnType<ChatClient["onConnect"]> | undefined = client?.onConnect(
() => {
cleanup();
resolve();
},
);
// Failure: disconnected (e.g., auth failed)
const disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined =
client?.onDisconnect((_manually, reason) => {
cleanup();
reject(reason || new Error("Disconnected"));
});
// Failure: authentication failed
const authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined =
client?.onAuthenticationFailure(() => {
cleanup();
reject(new Error("Authentication failed"));
});
});
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error(`timeout after ${timeoutMs}ms`)),
timeoutMs,
);
});
client.connect();
try {
await Promise.race([connectionPromise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
client.quit();
client = undefined;
return {
ok: true,
connected: true,
username: account.username,
channel: account.channel,
elapsedMs: Date.now() - started,
};
} catch (error) {
return {
ok: false,
error: formatErrorMessage(error),
username: account.username,
channel: account.channel,
elapsedMs: Date.now() - started,
};
} finally {
if (client) {
try {
client.quit();
} catch {
// Ignore cleanup errors
}
}
}
}

View File

@@ -0,0 +1,139 @@
/**
* Twitch resolver adapter for channel/user name resolution.
*
* This module implements the ChannelResolverAdapter interface to resolve
* Twitch usernames to user IDs via the Twitch Helix API.
*/
import { ApiClient } from "@twurple/api";
import { StaticAuthProvider } from "@twurple/auth";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ChannelResolveKind, ChannelResolveResult } from "./types.js";
import type { ChannelLogSink, TwitchAccountConfig } from "./types.js";
import { normalizeToken } from "./utils/twitch.js";
/**
* Normalize a Twitch username - strip @ prefix and convert to lowercase
*/
function normalizeUsername(input: string): string {
const trimmed = input.trim();
if (trimmed.startsWith("@")) {
return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
}
return normalizeLowercaseStringOrEmpty(trimmed);
}
/**
* Create a logger that includes the Twitch prefix
*/
function createLogger(logger?: ChannelLogSink): ChannelLogSink {
return {
info: (msg: string) => logger?.info(msg),
warn: (msg: string) => logger?.warn(msg),
error: (msg: string) => logger?.error(msg),
debug: (msg: string) => logger?.debug?.(msg) ?? (() => {}),
};
}
/**
* Resolve Twitch usernames to user IDs via the Helix API
*
* @param inputs - Array of usernames or user IDs to resolve
* @param account - Twitch account configuration with auth credentials
* @param kind - Type of target to resolve ("user" or "group")
* @param logger - Optional logger
* @returns Promise resolving to array of ChannelResolveResult
*/
export async function resolveTwitchTargets(
inputs: string[],
account: TwitchAccountConfig,
_kind: ChannelResolveKind,
logger?: ChannelLogSink,
): Promise<ChannelResolveResult[]> {
const log = createLogger(logger);
if (!account.clientId || !account.accessToken) {
log.error("Missing Twitch client ID or accessToken");
return inputs.map((input) => ({
input,
resolved: false,
note: "missing Twitch credentials",
}));
}
const normalizedToken = normalizeToken(account.accessToken);
const authProvider = new StaticAuthProvider(account.clientId, normalizedToken);
const apiClient = new ApiClient({ authProvider });
const results: ChannelResolveResult[] = [];
for (const input of inputs) {
const normalized = normalizeUsername(input);
if (!normalized) {
results.push({
input,
resolved: false,
note: "empty input",
});
continue;
}
const looksLikeUserId = /^\d+$/.test(normalized);
try {
if (looksLikeUserId) {
const user = await apiClient.users.getUserById(normalized);
if (user) {
results.push({
input,
resolved: true,
id: user.id,
name: user.name,
});
log.debug?.(`Resolved user ID ${normalized} -> ${user.name}`);
} else {
results.push({
input,
resolved: false,
note: "user ID not found",
});
log.warn(`User ID ${normalized} not found`);
}
} else {
const user = await apiClient.users.getUserByName(normalized);
if (user) {
results.push({
input,
resolved: true,
id: user.id,
name: user.name,
note: user.displayName !== user.name ? `display: ${user.displayName}` : undefined,
});
log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
} else {
results.push({
input,
resolved: false,
note: "username not found",
});
log.warn(`Username ${normalized} not found`);
}
}
} catch (error) {
const errorMessage = formatErrorMessage(error);
results.push({
input,
resolved: false,
note: `API error: ${errorMessage}`,
});
log.error(`Failed to resolve ${input}: ${errorMessage}`);
}
}
return results;
}

View File

@@ -0,0 +1,10 @@
// Twitch plugin module implements runtime behavior.
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "twitch",
errorMessage: "Twitch runtime not initialized",
});
export { getTwitchRuntime, setTwitchRuntime };

View File

@@ -0,0 +1,342 @@
/**
* Tests for send.ts module
*
* Tests cover:
* - Message sending with valid configuration
* - Account resolution and validation
* - Channel normalization
* - Markdown stripping
* - Error handling for missing/invalid accounts
* - Registry integration
*/
import { describe, expect, it, vi } from "vitest";
import { getClientManager } from "./client-manager-registry.js";
import { resolveTwitchAccountContext } from "./config.js";
import { sendMessageTwitchInternal } from "./send.js";
import {
BASE_TWITCH_TEST_ACCOUNT,
installTwitchTestHooks,
makeTwitchTestConfig,
} from "./test-fixtures.js";
import { stripMarkdownForTwitch } from "./utils/markdown.js";
// Mock dependencies
vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default",
resolveTwitchAccountContext: vi.fn(),
}));
vi.mock("./utils/twitch.js", () => ({
generateMessageId: vi.fn(() => "test-msg-id"),
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
}));
vi.mock("./utils/markdown.js", () => ({
stripMarkdownForTwitch: vi.fn((text: string) => text.replace(/\*\*/g, "")),
}));
vi.mock("./client-manager-registry.js", () => ({
getClientManager: vi.fn(),
}));
describe("send", () => {
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const mockAccount = {
...BASE_TWITCH_TEST_ACCOUNT,
accessToken: "test123",
};
const mockConfig = makeTwitchTestConfig(mockAccount);
installTwitchTestHooks();
describe("sendMessageTwitchInternal", () => {
function setupAccountContext(params?: {
account?: typeof mockAccount | null;
configured?: boolean;
availableAccountIds?: string[];
}) {
const account = params?.account === undefined ? mockAccount : params.account;
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
accountId: accountId?.trim() || "default",
account,
tokenResolution: { source: "config", token: account?.accessToken ?? "" },
configured: account ? (params?.configured ?? true) : false,
availableAccountIds: params?.availableAccountIds ?? ["default"],
}));
}
async function mockSuccessfulSend(params: {
messageId: string;
stripMarkdown?: (text: string) => string;
}) {
setupAccountContext();
vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockResolvedValue({
ok: true,
messageId: params.messageId,
}),
} as unknown as ReturnType<typeof getClientManager>);
vi.mocked(stripMarkdownForTwitch).mockImplementation(
params.stripMarkdown ?? ((text) => text),
);
return { stripMarkdownForTwitch };
}
it("should send a message successfully", async () => {
await mockSuccessfulSend({ messageId: "twitch-msg-123" });
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello Twitch!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(true);
expect(result.messageId).toBe("twitch-msg-123");
expect(typeof result.receipt.sentAt).toBe("number");
expect({ ...result.receipt, sentAt: 0 }).toEqual({
primaryPlatformMessageId: "twitch-msg-123",
platformMessageIds: ["twitch-msg-123"],
parts: [
{
platformMessageId: "twitch-msg-123",
kind: "text",
index: 0,
raw: {
channel: "twitch",
conversationId: "testchannel",
messageId: "twitch-msg-123",
},
},
],
raw: [
{
channel: "twitch",
conversationId: "testchannel",
messageId: "twitch-msg-123",
},
],
sentAt: 0,
});
});
it("should strip markdown when enabled", async () => {
const { stripMarkdownForTwitch: stripMarkdownForTwitchLocal } = await mockSuccessfulSend({
messageId: "twitch-msg-456",
stripMarkdown: (text) => text.replace(/\*\*/g, ""),
});
await sendMessageTwitchInternal(
"#testchannel",
"**Bold** text",
mockConfig,
"default",
true,
mockLogger as unknown as Console,
);
expect(stripMarkdownForTwitchLocal).toHaveBeenCalledWith("**Bold** text");
});
it("should return error when account not found", async () => {
setupAccountContext({ account: null });
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"nonexistent",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("Account not found: nonexistent");
});
it("should return error when account not configured", async () => {
setupAccountContext({ configured: false });
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("not properly configured");
});
it("should return error when no channel specified", async () => {
// Set channel to undefined to trigger the error (bypassing type check)
const accountWithoutChannel = {
...mockAccount,
channel: undefined as unknown as string,
};
setupAccountContext({ account: accountWithoutChannel });
const result = await sendMessageTwitchInternal(
"",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("No channel specified");
});
it("should skip sending empty message after markdown stripping", async () => {
setupAccountContext();
vi.mocked(stripMarkdownForTwitch).mockReturnValue("");
const result = await sendMessageTwitchInternal(
"#testchannel",
"**Only markdown**",
mockConfig,
"default",
true,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(true);
expect(result.messageId).toBe("skipped");
expect(result.receipt.platformMessageIds).toStrictEqual([]);
expect(result.receipt.parts).toStrictEqual([]);
});
it("should return error when client manager not found", async () => {
setupAccountContext();
vi.mocked(getClientManager).mockReturnValue(undefined);
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("Client manager not found");
});
it("should handle send errors gracefully", async () => {
setupAccountContext();
vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
} as unknown as ReturnType<typeof getClientManager>);
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toBe("Connection lost");
expect(mockLogger.error).toHaveBeenCalled();
});
it("should use account channel when channel parameter is empty", async () => {
setupAccountContext();
const mockSend = vi.fn().mockResolvedValue({
ok: true,
messageId: "twitch-msg-789",
});
vi.mocked(getClientManager).mockReturnValue({
sendMessage: mockSend,
} as unknown as ReturnType<typeof getClientManager>);
await sendMessageTwitchInternal(
"",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(mockSend).toHaveBeenCalledWith(
mockAccount,
"testchannel", // normalized account channel
"Hello!",
mockConfig,
"default",
);
});
it("uses the configured default account when accountId is omitted", async () => {
const secondaryAccount = {
...mockAccount,
username: "secondary-user",
channel: "secondary-channel",
};
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
accountId: accountId?.trim() || "secondary",
account: secondaryAccount,
tokenResolution: { source: "config", token: secondaryAccount.accessToken ?? "" },
configured: true,
availableAccountIds: ["default", "secondary"],
}));
const mockSend = vi.fn().mockResolvedValue({
ok: true,
messageId: "twitch-msg-secondary",
});
vi.mocked(getClientManager).mockReturnValue({
sendMessage: mockSend,
} as unknown as ReturnType<typeof getClientManager>);
const result = await sendMessageTwitchInternal(
"",
"Hello!",
{
channels: {
twitch: {
defaultAccount: "secondary",
},
},
} as never,
undefined,
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(true);
expect(getClientManager).toHaveBeenCalledWith("secondary");
expect(mockSend).toHaveBeenCalledWith(
secondaryAccount,
"secondary-channel",
"Hello!",
{
channels: {
twitch: {
defaultAccount: "secondary",
},
},
},
"secondary",
);
});
});
});

View File

@@ -0,0 +1,191 @@
/**
* Twitch message sending functions with dependency injection support.
*
* These functions are the primary interface for sending messages to Twitch.
* They support dependency injection via the `deps` parameter for testability.
*/
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
import { resolveTwitchAccountContext } from "./config.js";
import { stripMarkdownForTwitch } from "./utils/markdown.js";
import { generateMessageId, normalizeTwitchChannel } from "./utils/twitch.js";
/**
* Result from sending a message to Twitch.
*/
export interface SendMessageResult {
/** Whether the send was successful */
ok: boolean;
/** The message ID (generated for tracking) */
messageId: string;
/** Receipt for visible sends; empty when no Twitch message was sent */
receipt: MessageReceipt;
/** Error message if the send failed */
error?: string;
}
function createTwitchSendReceipt(params: {
messageId: string;
channel?: string | null;
visible?: boolean;
}): MessageReceipt {
const messageId = params.messageId.trim();
const conversationId = params.channel?.trim();
const hasVisibleMessage = params.visible === true && messageId && messageId !== "skipped";
return createMessageReceiptFromOutboundResults({
results: hasVisibleMessage
? [
{
channel: "twitch",
messageId,
...(conversationId ? { conversationId } : {}),
},
]
: [],
kind: "text",
});
}
/**
* Internal send function used by the outbound adapter.
*
* This function has access to the full OpenClaw config and handles
* account resolution, markdown stripping, and actual message sending.
*
* @param channel - The channel name
* @param text - The message text
* @param cfg - Full OpenClaw configuration
* @param accountId - Account ID to use
* @param stripMarkdown - Whether to strip markdown (default: true)
* @param logger - Logger instance
* @returns Result with message ID and status
*
* @example
* const result = await sendMessageTwitchInternal(
* "#mychannel",
* "Hello Twitch!",
* openclawConfig,
* "default",
* true,
* console,
* );
*/
export async function sendMessageTwitchInternal(
channel: string,
text: string,
cfg: OpenClawConfig,
accountId?: string,
stripMarkdown = true,
logger: Console = console,
): Promise<SendMessageResult> {
const {
account,
configured,
availableAccountIds,
accountId: resolvedAccountId,
} = resolveTwitchAccountContext(cfg, accountId);
if (!account) {
return {
ok: false,
messageId: generateMessageId(),
receipt: createTwitchSendReceipt({ messageId: "", channel, visible: false }),
error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
};
}
if (!configured) {
return {
ok: false,
messageId: generateMessageId(),
receipt: createTwitchSendReceipt({ messageId: "", channel, visible: false }),
error:
`Account ${resolvedAccountId} is not properly configured. ` +
"Required: username, clientId, and token (config or env for default account).",
};
}
const normalizedChannel = channel || account.channel;
if (!normalizedChannel) {
return {
ok: false,
messageId: generateMessageId(),
receipt: createTwitchSendReceipt({
messageId: "",
channel: normalizedChannel,
visible: false,
}),
error: "No channel specified and no default channel in account config",
};
}
const deliveryChannel = normalizeTwitchChannel(normalizedChannel);
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
if (!cleanedText) {
return {
ok: true,
messageId: "skipped",
receipt: createTwitchSendReceipt({
messageId: "skipped",
channel: deliveryChannel,
visible: false,
}),
};
}
const clientManager = getRegistryClientManager(resolvedAccountId);
if (!clientManager) {
return {
ok: false,
messageId: generateMessageId(),
receipt: createTwitchSendReceipt({
messageId: "",
channel: deliveryChannel,
visible: false,
}),
error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`,
};
}
try {
const result = await clientManager.sendMessage(
account,
deliveryChannel,
cleanedText,
cfg,
resolvedAccountId,
);
if (!result.ok) {
const messageId = result.messageId ?? generateMessageId();
return {
ok: false,
messageId,
receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: false }),
error: result.error ?? "Send failed",
};
}
const messageId = result.messageId ?? generateMessageId();
return {
ok: true,
messageId,
receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: true }),
};
} catch (error) {
const errorMsg = formatErrorMessage(error);
const messageId = generateMessageId();
logger.error(`Failed to send message: ${errorMsg}`);
return {
ok: false,
messageId,
receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: false }),
error: errorMsg,
};
}
}

View File

@@ -0,0 +1,529 @@
/**
* Tests for setup-surface.ts helpers.
*
* Tests cover:
* - promptToken helper
* - promptUsername helper
* - promptClientId helper
* - promptChannelName helper
* - promptRefreshTokenSetup helper
* - configureWithEnvToken helper
* - setTwitchAccount config updates
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "../api.js";
import {
configureWithEnvToken,
promptChannelName,
promptClientId,
promptRefreshTokenSetup,
promptToken,
promptUsername,
setTwitchAccount,
twitchSetupPlugin,
twitchSetupWizard,
} from "./setup-surface.js";
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",
accessToken: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
function requireFirstTextPromptArgs(): {
message?: string;
initialValue?: string;
validate?: (value: string) => string | undefined;
} {
const [call] = mockPromptText.mock.calls;
if (!call || typeof call[0] !== "object" || call[0] === null || Array.isArray(call[0])) {
throw new Error("expected Twitch text prompt args");
}
return call[0] as {
message?: string;
initialValue?: string;
validate?: (value: string) => string | undefined;
};
}
describe("setup surface helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
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
});
describe("promptToken", () => {
it("should return existing token when user confirms to keep it", async () => {
mockPromptConfirm.mockResolvedValue(true);
const result = await promptToken(mockPrompter, mockAccount, undefined);
expect(result).toBe("oauth:test123");
expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Access token already configured. Keep it?",
initialValue: true,
});
expect(mockPromptText).not.toHaveBeenCalled();
});
it("should validate token format", async () => {
// Set up mocks - user doesn't want to keep existing token
mockPromptConfirm.mockResolvedValueOnce(false);
// Track how many times promptText is called
let promptTextCallCount = 0;
let capturedValidate: ((value: string) => string | undefined) | undefined;
mockPromptText.mockImplementationOnce((_args) => {
promptTextCallCount++;
// Capture the validate function from the first argument
if (_args?.validate) {
capturedValidate = _args.validate;
}
return Promise.resolve("oauth:test123");
});
// Call promptToken
const result = await promptToken(mockPrompter, mockAccount, undefined);
// Verify promptText was called
expect(promptTextCallCount).toBe(1);
expect(result).toBe("oauth:test123");
// Test the validate function
if (!capturedValidate) {
throw new Error("promptToken validate callback was not captured");
}
expect(capturedValidate("")).toBe("Required");
expect(capturedValidate("notoauth")).toBe("Token should start with 'oauth:'");
expect(capturedValidate("oauth:goodtoken")).toBeUndefined();
});
});
describe("promptUsername", () => {
it("should prompt for username with validation", async () => {
mockPromptText.mockResolvedValue("mybot");
const result = await promptUsername(mockPrompter, null);
expect(result).toBe("mybot");
const promptArgs = requireFirstTextPromptArgs();
expect(promptArgs.message).toBe("Twitch bot username");
expect(promptArgs.initialValue).toBe("");
expect(promptArgs.validate?.("")).toBe("Required");
expect(promptArgs.validate?.("mybot")).toBeUndefined();
});
});
describe("promptClientId", () => {
it("should prompt for client ID with validation", async () => {
mockPromptText.mockResolvedValue("abc123xyz");
const result = await promptClientId(mockPrompter, null);
expect(result).toBe("abc123xyz");
const promptArgs = requireFirstTextPromptArgs();
expect(promptArgs.message).toBe("Twitch Client ID");
expect(promptArgs.initialValue).toBe("");
expect(promptArgs.validate?.("")).toBe("Required");
expect(promptArgs.validate?.("abc123xyz")).toBeUndefined();
});
});
describe("promptChannelName", () => {
it("should require a non-empty channel name", async () => {
mockPromptText.mockResolvedValue("");
await promptChannelName(mockPrompter, null);
const { validate } = requireFirstTextPromptArgs();
expect(validate?.("")).toBe("Required");
expect(validate?.(" ")).toBe("Required");
expect(validate?.("#chan")).toBeUndefined();
});
});
describe("promptRefreshTokenSetup", () => {
it("should return empty object when user declines", async () => {
mockPromptConfirm.mockResolvedValue(false);
const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
expect(result).toStrictEqual({});
expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Enable automatic token refresh (requires client secret and refresh token)?",
initialValue: false,
});
});
it("should prompt for credentials when user accepts", async () => {
mockPromptConfirm
.mockResolvedValueOnce(true) // First call: useRefresh
.mockResolvedValueOnce("secret123") // clientSecret
.mockResolvedValueOnce("refresh123"); // refreshToken
mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
const result = await promptRefreshTokenSetup(mockPrompter, null);
expect(result).toEqual({
clientSecret: "secret123",
refreshToken: "refresh123",
});
});
});
describe("configureWithEnvToken", () => {
it("should prompt for username and clientId when using env token", async () => {
// Reset and set up mocks - user accepts env token
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
// Set up mocks for username and clientId prompts
mockPromptText
.mockReset()
.mockResolvedValueOnce("testbot" as never)
.mockResolvedValueOnce("test-client-id" as never);
const result = await configureWithEnvToken(
{} as Parameters<typeof configureWithEnvToken>[0],
mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[5],
);
// Should return config with username and clientId
if (!result) {
throw new Error("expected Twitch env-token setup result");
}
const defaultAccount = result.cfg.channels?.twitch?.accounts?.default as
| { username?: string; clientId?: string }
| undefined;
expect(defaultAccount?.username).toBe("testbot");
expect(defaultAccount?.clientId).toBe("test-client-id");
});
it("skips env-token shortcut for non-default accounts", async () => {
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
mockPromptText
.mockReset()
.mockResolvedValueOnce("secondary-bot" as never)
.mockResolvedValueOnce("secondary-client" as never);
const result = await configureWithEnvToken(
{
channels: {
twitch: {
defaultAccount: "secondary",
},
},
} as Parameters<typeof configureWithEnvToken>[0],
mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[5],
);
expect(result).toBeNull();
expect(mockPromptConfirm).not.toHaveBeenCalled();
expect(mockPromptText).not.toHaveBeenCalled();
});
});
describe("defaultAccount setup resolution", () => {
it("reports status for the configured default account", () => {
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
},
} as never);
expect(lines).toEqual(["Twitch (secondary): configured"]);
});
it("reports status for the requested account override", () => {
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", () => {
type FinalizeArgs = Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0];
async function finalizeTwitchSetupForAccount(cfg: FinalizeArgs["cfg"]) {
return await twitchSetupWizard.finalize?.({
cfg,
accountId: "secondary",
credentialValues: {},
runtime: {} as FinalizeArgs["runtime"],
prompter: mockPrompter,
options: {},
forceAllowFrom: false,
});
}
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 finalizeTwitchSetupForAccount({
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
},
},
},
} as FinalizeArgs["cfg"]);
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 finalizeTwitchSetupForAccount({
channels: {
twitch: {
accounts: {},
},
},
} as FinalizeArgs["cfg"]);
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

@@ -0,0 +1,524 @@
/**
* 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,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type OpenClawConfig,
type WizardPrompter,
normalizeAccountId,
createSetupTranslator,
} from "openclaw/plugin-sdk/setup";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
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 t = createSetupTranslator();
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);
}
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg);
}
export function setTwitchAccount(
cfg: OpenClawConfig,
account: Partial<TwitchAccountConfig>,
accountId: string = resolveSetupAccountId(cfg),
): OpenClawConfig {
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 ?? "",
clientId: account.clientId ?? existing?.clientId ?? "",
channel: account.channel ?? existing?.channel ?? "",
enabled: account.enabled ?? existing?.enabled ?? true,
allowFrom: account.allowFrom ?? existing?.allowFrom,
allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
requireMention: account.requireMention ?? existing?.requireMention,
clientSecret: account.clientSecret ?? existing?.clientSecret,
refreshToken: account.refreshToken ?? existing?.refreshToken,
expiresIn: account.expiresIn ?? existing?.expiresIn,
obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp,
};
return {
...cfg,
channels: {
...cfg.channels,
twitch: {
...((cfg.channels as Record<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined),
enabled: true,
accounts: {
...((
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
)?.accounts as Record<string, unknown> | undefined),
[resolvedAccountId]: merged,
},
},
},
};
}
async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
t("wizard.twitch.helpRequiresBot"),
t("wizard.twitch.helpCreateApp"),
t("wizard.twitch.helpGenerateToken"),
t("wizard.twitch.helpTokenTools"),
t("wizard.twitch.helpCopyToken"),
t("wizard.twitch.helpEnvVars"),
`Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`,
].join("\n"),
t("wizard.twitch.setupTitle"),
);
}
export async function promptToken(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
envToken: string | undefined,
): Promise<string> {
const existingToken = account?.accessToken ?? "";
if (existingToken && !envToken) {
const keepToken = await prompter.confirm({
message: t("wizard.twitch.accessTokenKeep"),
initialValue: true,
});
if (keepToken) {
return existingToken;
}
}
return (
await prompter.text({
message: t("wizard.twitch.oauthTokenPrompt"),
initialValue: envToken ?? "",
validate: (value) => {
const raw = value?.trim() ?? "";
if (!raw) {
return "Required";
}
if (!raw.startsWith("oauth:")) {
return "Token should start with 'oauth:'";
}
return undefined;
},
})
).trim();
}
export async function promptUsername(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
return (
await prompter.text({
message: t("wizard.twitch.botUsernamePrompt"),
initialValue: account?.username ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
})
).trim();
}
export async function promptClientId(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
return (
await prompter.text({
message: t("wizard.twitch.clientIdPrompt"),
initialValue: account?.clientId ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
})
).trim();
}
export async function promptChannelName(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
return (
await prompter.text({
message: t("wizard.twitch.channelJoinPrompt"),
initialValue: account?.channel ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
})
).trim();
}
export async function promptRefreshTokenSetup(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<{ clientSecret?: string; refreshToken?: string }> {
const useRefresh = await prompter.confirm({
message: t("wizard.twitch.refreshTokenPrompt"),
initialValue: Boolean(account?.clientSecret && account?.refreshToken),
});
if (!useRefresh) {
return {};
}
const clientSecret =
(
await prompter.text({
message: t("wizard.twitch.clientSecretPrompt"),
initialValue: account?.clientSecret ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
})
).trim() || undefined;
const refreshToken =
(
await prompter.text({
message: t("wizard.twitch.refreshTokenInputPrompt"),
initialValue: account?.refreshToken ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
})
).trim() || undefined;
return { clientSecret, refreshToken };
}
export async function configureWithEnvToken(
cfg: OpenClawConfig,
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
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: t("wizard.twitch.envPrompt"),
initialValue: true,
});
if (!useEnv) {
return null;
}
const username = await promptUsername(prompter, account);
const clientId = await promptClientId(prompter, account);
const cfgWithAccount = setTwitchAccount(
cfg,
{
username,
clientId,
accessToken: envToken,
enabled: true,
},
resolvedAccountId,
);
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
return {
cfg: await dmPolicy.promptAllowFrom({
cfg: cfgWithAccount,
prompter,
accountId: resolvedAccountId,
}),
};
}
return { cfg: cfgWithAccount };
}
function setTwitchAccessControl(
cfg: OpenClawConfig,
allowedRoles: TwitchRole[],
requireMention: boolean,
accountId?: string,
): OpenClawConfig {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return cfg;
}
return setTwitchAccount(
cfg,
{
...account,
allowedRoles,
requireMention,
},
resolvedAccountId,
);
}
function resolveTwitchGroupPolicy(
cfg: OpenClawConfig,
accountId?: string,
): "open" | "allowlist" | "disabled" {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
if (account?.allowedRoles?.includes("all")) {
return "open";
}
if (account?.allowedRoles?.includes("moderator")) {
return "allowlist";
}
return "disabled";
}
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, accountId);
}
const twitchDmPolicy: ChannelSetupDmPolicy = {
label: "Twitch",
channel,
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";
}
if (account?.allowFrom && account.allowFrom.length > 0) {
return "allowlist";
}
return "disabled";
},
setPolicy: (cfg, policy, accountId) => {
const allowedRoles: TwitchRole[] =
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
return setTwitchAccessControl(cfg, allowedRoles, true, 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({
message: t("wizard.twitch.allowFromPrompt"),
placeholder: "123456789",
initialValue: existingAllowFrom[0] || undefined,
});
const allowFrom = normalizeStringEntries((entry ?? "").split(/[\n,;]+/g));
return setTwitchAccount(
cfg,
{
...(account ?? undefined),
allowFrom,
},
resolvedAccountId,
);
},
};
const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
label: "Twitch chat",
placeholder: "",
skipAllowlistEntries: true,
currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
currentEntries: ({ cfg, accountId }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
return account?.allowFrom ?? [];
},
updatePrompt: ({ cfg, accountId }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
},
setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
resolveAllowlist: async () => [],
applyAllowlist: ({ cfg }) => cfg,
};
export const twitchSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
applyAccountConfig: ({ cfg, accountId }) =>
setTwitchAccount(
cfg,
{
enabled: true,
},
accountId,
),
};
export const twitchSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: ({ cfg, accountOverride }) =>
resolveSetupAccountId(cfg, accountOverride),
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsUsernameTokenClientId"),
configuredHint: t("wizard.channels.statusConfigured"),
unconfiguredHint: t("wizard.channels.statusNeedsSetup"),
resolveConfigured: ({ cfg, accountId }) => {
return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
},
resolveStatusLines: ({ cfg, accountId }) => {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
return [
`Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${
configured
? t("wizard.channels.statusConfigured")
: t("wizard.channels.statusNeedsUsernameTokenClientId")
}`,
];
},
},
credentials: [],
finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
const accountId = resolveSetupAccountId(cfg, requestedAccountId);
const account = getAccountConfig(cfg, accountId);
if (!account || !isAccountConfigured(account)) {
await noteTwitchSetupHelp(prompter);
}
const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) {
const envResult = await configureWithEnvToken(
cfg,
prompter,
account,
envToken,
forceAllowFrom,
twitchDmPolicy,
accountId,
);
if (envResult) {
return envResult;
}
}
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,
{
username,
accessToken: token,
clientId,
channel: channelName,
clientSecret,
refreshToken,
enabled: true,
},
accountId,
);
const cfgWithAllowFrom =
forceAllowFrom && twitchDmPolicy.promptAllowFrom
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId })
: cfgWithAccount;
return { cfg: cfgWithAllowFrom };
},
dmPolicy: twitchDmPolicy,
groupAccess: twitchGroupAccess,
disable: (cfg) => {
const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined;
return {
...cfg,
channels: {
...cfg.channels,
twitch: { ...twitch, enabled: false },
},
};
},
};
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

@@ -0,0 +1,298 @@
/**
* Tests for status.ts module
*
* Tests cover:
* - Detection of unconfigured accounts
* - Detection of disabled accounts
* - Detection of missing clientId
* - Token format warnings
* - Access control warnings
* - Runtime error detection
*/
import { describe, expect, it } from "vitest";
import { collectTwitchStatusIssues } from "./status.js";
import type { ChannelAccountSnapshot } from "./types.js";
function createSnapshot(overrides: Partial<ChannelAccountSnapshot> = {}): ChannelAccountSnapshot {
return {
accountId: "default",
configured: true,
enabled: true,
running: false,
...overrides,
};
}
function createSimpleTwitchConfig(overrides: Record<string, unknown>) {
return {
channels: {
twitch: overrides,
},
};
}
function expectSingleIssue(
issues: ReturnType<typeof collectTwitchStatusIssues>,
expected: ReturnType<typeof collectTwitchStatusIssues>[number],
): void {
expect(issues).toEqual([expected]);
}
function expectIssues(
issues: ReturnType<typeof collectTwitchStatusIssues>,
expected: ReturnType<typeof collectTwitchStatusIssues>,
): void {
expect(issues).toEqual(expected);
}
function neverConnectedIssue(): ReturnType<typeof collectTwitchStatusIssues>[number] {
return {
channel: "twitch",
accountId: "default",
kind: "runtime",
message: "Account has never connected successfully",
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
};
}
describe("status", () => {
describe("collectTwitchStatusIssues", () => {
it("should detect unconfigured accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ configured: false })];
const issues = collectTwitchStatusIssues(snapshots);
expectSingleIssue(issues, {
channel: "twitch",
accountId: "default",
kind: "config",
message: "Twitch account is not properly configured",
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
});
});
it("should detect disabled accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ enabled: false })];
const issues = collectTwitchStatusIssues(snapshots);
expectSingleIssue(issues, {
channel: "twitch",
accountId: "default",
kind: "config",
message: "Twitch account is disabled",
fix: "Set enabled: true in your account configuration to enable this account",
});
});
it("should detect missing clientId when account configured (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "oauth:test123",
// clientId missing
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
expectIssues(issues, [
{
channel: "twitch",
accountId: "default",
kind: "config",
message: "Twitch client ID is required",
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
},
neverConnectedIssue(),
]);
});
it("should warn about oauth: prefix in token (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "oauth:test123", // has prefix
clientId: "test-id",
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
expectIssues(issues, [
{
channel: "twitch",
accountId: "default",
kind: "config",
message: "Token contains 'oauth:' prefix (will be stripped)",
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
},
neverConnectedIssue(),
]);
});
it("should detect clientSecret without refreshToken (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "oauth:test123",
clientId: "test-id",
clientSecret: "secret123",
// refreshToken missing
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
expectIssues(issues, [
{
channel: "twitch",
accountId: "default",
kind: "config",
message: "Token contains 'oauth:' prefix (will be stripped)",
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
},
{
channel: "twitch",
accountId: "default",
kind: "config",
message: "clientSecret provided without refreshToken",
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
},
neverConnectedIssue(),
]);
});
it("should detect empty allowFrom array (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowFrom: [], // empty array
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
expectIssues(issues, [
{
channel: "twitch",
accountId: "default",
kind: "config",
message: "allowFrom is configured but empty",
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
},
neverConnectedIssue(),
]);
});
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowedRoles: ["all"],
allowFrom: ["123456"], // conflict!
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
expectIssues(issues, [
{
channel: "twitch",
accountId: "default",
kind: "intent",
message: "allowedRoles is set to 'all' but allowFrom is also configured",
fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
},
neverConnectedIssue(),
]);
});
it("should detect runtime errors", () => {
const snapshots: ChannelAccountSnapshot[] = [
createSnapshot({ lastError: "Connection timeout" }),
];
const issues = collectTwitchStatusIssues(snapshots);
expectIssues(issues, [
{
channel: "twitch",
accountId: "default",
kind: "runtime",
message: "Last error: Connection timeout",
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
},
neverConnectedIssue(),
]);
});
it("should detect accounts that never connected", () => {
const snapshots: ChannelAccountSnapshot[] = [
createSnapshot({
lastStartAt: undefined,
lastInboundAt: undefined,
lastOutboundAt: undefined,
}),
];
const issues = collectTwitchStatusIssues(snapshots);
expectSingleIssue(issues, {
channel: "twitch",
accountId: "default",
kind: "runtime",
message: "Account has never connected successfully",
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
});
});
it("should detect long-running connections", () => {
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
const snapshots: ChannelAccountSnapshot[] = [
createSnapshot({
running: true,
lastStartAt: oldDate,
}),
];
const issues = collectTwitchStatusIssues(snapshots);
expectSingleIssue(issues, {
channel: "twitch",
accountId: "default",
kind: "runtime",
message: "Connection has been running for 8 days",
fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
});
});
it("should handle empty snapshots array", () => {
const issues = collectTwitchStatusIssues([]);
expect(issues).toStrictEqual([]);
});
it("should skip non-Twitch accounts gracefully", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "unknown",
configured: false,
enabled: true,
running: false,
},
];
const issues = collectTwitchStatusIssues(snapshots);
expectSingleIssue(issues, {
channel: "twitch",
accountId: "unknown",
kind: "config",
message: "Twitch account is not properly configured",
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
});
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* Twitch status issues collector.
*
* Detects and reports configuration issues for Twitch accounts.
*/
import type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
import { getAccountConfig } from "./config.js";
import { resolveTwitchToken } from "./token.js";
import type { ChannelAccountSnapshot } from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
/**
* Collect status issues for Twitch accounts.
*
* Analyzes account snapshots and detects configuration problems,
* authentication issues, and other potential problems.
*
* @param accounts - Array of account snapshots to analyze
* @param getCfg - Optional function to get full config for additional checks
* @returns Array of detected status issues
*
* @example
* const issues = collectTwitchStatusIssues(accountSnapshots);
* if (issues.length > 0) {
* console.warn("Twitch configuration issues detected:");
* issues.forEach(issue => console.warn(`- ${issue.message}`));
* }
*/
export function collectTwitchStatusIssues(
accounts: ChannelAccountSnapshot[],
getCfg?: () => unknown,
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const accountId = entry.accountId;
if (!accountId) {
continue;
}
let account: ReturnType<typeof getAccountConfig> | null = null;
let cfg: Parameters<typeof resolveTwitchToken>[0] | undefined;
if (getCfg) {
try {
cfg = getCfg() as {
channels?: { twitch?: { accounts?: Record<string, unknown> } };
};
account = getAccountConfig(cfg, accountId);
} catch {
// Ignore config access errors
}
}
if (!entry.configured) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "Twitch account is not properly configured",
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
});
continue;
}
if (entry.enabled === false) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "Twitch account is disabled",
fix: "Set enabled: true in your account configuration to enable this account",
});
continue;
}
if (account && account.username && account.accessToken && !account.clientId) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "Twitch client ID is required",
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
});
}
const tokenResolution = cfg
? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
: { token: "", source: "none" };
if (account && isAccountConfigured(account, tokenResolution.token)) {
if (account.accessToken?.startsWith("oauth:")) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "Token contains 'oauth:' prefix (will be stripped)",
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
});
}
if (account.clientSecret && !account.refreshToken) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "clientSecret provided without refreshToken",
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
});
}
if (account.allowFrom && account.allowFrom.length === 0) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "allowFrom is configured but empty",
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
});
}
if (
account.allowedRoles?.includes("all") &&
account.allowFrom &&
account.allowFrom.length > 0
) {
issues.push({
channel: "twitch",
accountId,
kind: "intent",
message: "allowedRoles is set to 'all' but allowFrom is also configured",
fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
});
}
}
if (entry.lastError) {
issues.push({
channel: "twitch",
accountId,
kind: "runtime",
message: `Last error: ${entry.lastError}`,
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
});
}
if (
entry.configured &&
!entry.running &&
!entry.lastStartAt &&
!entry.lastInboundAt &&
!entry.lastOutboundAt
) {
issues.push({
channel: "twitch",
accountId,
kind: "runtime",
message: "Account has never connected successfully",
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
});
}
if (entry.running && entry.lastStartAt) {
const uptime = Date.now() - entry.lastStartAt;
const daysSinceStart = uptime / (1000 * 60 * 60 * 24);
if (daysSinceStart > 7) {
issues.push({
channel: "twitch",
accountId,
kind: "runtime",
message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
});
}
}
}
return issues;
}

View File

@@ -0,0 +1,31 @@
// Twitch plugin module implements test fixtures behavior.
import { afterEach, beforeEach, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
export const BASE_TWITCH_TEST_ACCOUNT = {
username: "testbot",
clientId: "test-client-id",
channel: "#testchannel",
};
export function makeTwitchTestConfig(account: Record<string, unknown>): OpenClawConfig {
return {
channels: {
twitch: {
accounts: {
default: account,
},
},
},
} as unknown as OpenClawConfig;
}
export function installTwitchTestHooks() {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
}

View File

@@ -0,0 +1,198 @@
/**
* Tests for token.ts module
*
* Tests cover:
* - Token resolution from config
* - Token resolution from environment variable
* - Fallback behavior when token not found
* - Account ID normalization
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
describe("token", () => {
const originalAccessToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
// Multi-account config for testing non-default accounts
const mockMultiAccountConfig = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
accessToken: "oauth:config-token",
},
other: {
username: "otherbot",
accessToken: "oauth:other-token",
},
},
},
},
} as unknown as OpenClawConfig;
// Simplified single-account config
const mockSimplifiedConfig = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:config-token",
},
},
} as unknown as OpenClawConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
if (originalAccessToken === undefined) {
delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
} else {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalAccessToken;
}
});
describe("resolveTwitchToken", () => {
it("should resolve token from simplified config for default account", () => {
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
expect(result.token).toBe("oauth:config-token");
expect(result.source).toBe("config");
});
it("should resolve token from config for non-default account (multi-account)", () => {
const result = resolveTwitchToken(mockMultiAccountConfig, { accountId: "other" });
expect(result.token).toBe("oauth:other-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";
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
// Config token should be used even if env var exists
expect(result.token).toBe("oauth:config-token");
expect(result.source).toBe("config");
});
it("should use env var when config token is empty (simplified config)", () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
const configWithEmptyToken = {
channels: {
twitch: {
username: "testbot",
accessToken: "",
},
},
} as unknown as OpenClawConfig;
const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" });
expect(result.token).toBe("oauth:env-token");
expect(result.source).toBe("env");
});
it("should return empty token when neither config nor env has token (simplified config)", () => {
const configWithoutToken = {
channels: {
twitch: {
username: "testbot",
accessToken: "",
},
},
} as unknown as OpenClawConfig;
const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
it("should not use env var for non-default accounts (multi-account)", () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
const configWithoutToken = {
channels: {
twitch: {
accounts: {
secondary: {
username: "secondary",
accessToken: "",
},
},
},
},
} as unknown as OpenClawConfig;
const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
// Non-default accounts shouldn't use env var
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
it("should handle missing account gracefully", () => {
const configWithoutAccount = {
channels: {
twitch: {
accounts: {},
},
},
} as unknown as OpenClawConfig;
const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
it("should handle missing Twitch config section", () => {
const configWithoutSection = {
channels: {},
} as unknown as OpenClawConfig;
const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
});
describe("TwitchTokenSource type", () => {
it("should have correct values", () => {
const sources: TwitchTokenSource[] = ["env", "config", "none"];
expect(sources).toContain("env");
expect(sources).toContain("config");
expect(sources).toContain("none");
});
});
});

View File

@@ -0,0 +1,93 @@
/**
* Twitch access token resolution with environment variable support.
*
* Supports reading Twitch OAuth access tokens from config or environment variable.
* The OPENCLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
*
* Token resolution priority:
* 1. Account access token from merged config (accounts.{id} or base-level for default)
* 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-contracts";
export type TwitchTokenSource = "env" | "config" | "none";
export type TwitchTokenResolution = {
token: string;
source: TwitchTokenSource;
};
/**
* Normalize a Twitch OAuth token - ensure it has the oauth: prefix
*/
function normalizeTwitchToken(raw?: string | null): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
// Twitch tokens should have oauth: prefix
return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
}
/**
* Resolve Twitch access token from config or environment variable.
*
* Priority:
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
*
* The getAccountConfig function handles merging base-level config with accounts.default,
* so this logic works for both simplified and multi-account patterns.
*
* @param cfg - OpenClaw config
* @param opts - Options including accountId and optional envToken override
* @returns Token resolution with source
*/
export function resolveTwitchToken(
cfg?: OpenClawConfig,
opts: { accountId?: string | null; envToken?: string | null } = {},
): TwitchTokenResolution {
const accountId = normalizeAccountId(opts.accountId);
// Get merged account config (handles both simplified and multi-account patterns)
const twitchCfg = cfg?.channels?.twitch;
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;
if (accountId === DEFAULT_ACCOUNT_ID) {
// Base-level config takes precedence
token = normalizeTwitchToken(
(typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : undefined) ||
(accountCfg?.accessToken as string | undefined),
);
} else {
// Non-default accounts only use accounts object
token = normalizeTwitchToken(accountCfg?.accessToken as string | undefined);
}
if (token) {
return { token, source: "config" };
}
// Environment variable (default account only)
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? normalizeTwitchToken(opts.envToken ?? process.env.OPENCLAW_TWITCH_ACCESS_TOKEN)
: undefined;
if (envToken) {
return { token: envToken, source: "env" };
}
return { token: "", source: "none" };
}

View File

@@ -0,0 +1,796 @@
/**
* Tests for TwitchClientManager class
*
* Tests cover:
* - Client connection and reconnection
* - Message handling (chat)
* - Message sending with rate limiting
* - Disconnection scenarios
* - Error handling and edge cases
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTwitchToken } from "./token.js";
import { TwitchClientManager } from "./twitch-client.js";
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
// Mock @twurple dependencies
const mockConnect = vi.fn(() => {
for (const handler of authSuccessHandlers) {
handler();
}
});
const mockJoin = vi.fn().mockResolvedValue(undefined);
const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" });
const mockQuit = vi.fn();
const mockUnbind = vi.fn();
// Event handler storage for testing
const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> =
[];
const authSuccessHandlers: Array<() => void> = [];
const authFailureHandlers: Array<(text: string, retryCount: number) => void> = [];
const disconnectHandlers: Array<(manual: boolean, reason?: Error) => void> = [];
// Mock functions that track handlers and return unbind objects
const mockOnMessage = vi.fn((handler: any) => {
messageHandlers.push(handler);
return { unbind: mockUnbind };
});
const mockOnAuthenticationSuccess = vi.fn((handler: () => void) => {
authSuccessHandlers.push(handler);
return { unbind: mockUnbind };
});
const mockOnAuthenticationFailure = vi.fn((handler: (text: string, retryCount: number) => void) => {
authFailureHandlers.push(handler);
return { unbind: mockUnbind };
});
const mockOnDisconnect = vi.fn((handler: (manual: boolean, reason?: Error) => void) => {
disconnectHandlers.push(handler);
return { unbind: mockUnbind };
});
const mockAddUserForToken = vi.fn().mockResolvedValue("123456");
const mockOnRefresh = vi.fn();
const mockOnRefreshFailure = vi.fn();
vi.mock("@twurple/chat", () => ({
ChatClient: class {
onMessage = mockOnMessage;
onAuthenticationSuccess = mockOnAuthenticationSuccess;
onAuthenticationFailure = mockOnAuthenticationFailure;
onDisconnect = mockOnDisconnect;
connect = mockConnect;
join = mockJoin;
say = mockSay;
quit = mockQuit;
},
LogLevel: {
CRITICAL: "CRITICAL",
ERROR: "ERROR",
WARNING: "WARNING",
INFO: "INFO",
DEBUG: "DEBUG",
TRACE: "TRACE",
},
}));
const mockAuthProvider = {
constructor: vi.fn(),
};
vi.mock("@twurple/auth", () => ({
StaticAuthProvider: function StaticAuthProvider(...args: unknown[]) {
mockAuthProvider.constructor(...args);
},
RefreshingAuthProvider: class {
addUserForToken = mockAddUserForToken;
onRefresh = mockOnRefresh;
onRefreshFailure = mockOnRefreshFailure;
},
}));
// Mock token resolution - must be after @twurple/auth mock
vi.mock("./token.js", () => ({
resolveTwitchToken: vi.fn(() => ({
token: "oauth:mock-token-from-tests",
source: "config" as const,
})),
DEFAULT_ACCOUNT_ID: "default",
}));
describe("TwitchClientManager", () => {
let manager: TwitchClientManager;
let mockLogger: ChannelLogSink;
let resolveTwitchTokenMock: ReturnType<typeof vi.mocked<typeof resolveTwitchToken>>;
const testAccount: TwitchAccountConfig = {
username: "testbot",
accessToken: "test123456",
clientId: "test-client-id",
channel: "testchannel",
enabled: true,
};
const testAccount2: TwitchAccountConfig = {
username: "testbot2",
accessToken: "test789",
clientId: "test-client-id-2",
channel: "testchannel2",
enabled: true,
};
beforeAll(() => {
resolveTwitchTokenMock = vi.mocked(resolveTwitchToken);
});
beforeEach(() => {
// Clear all mocks first
vi.clearAllMocks();
// Clear handler arrays
messageHandlers.length = 0;
authSuccessHandlers.length = 0;
authFailureHandlers.length = 0;
disconnectHandlers.length = 0;
// Re-set up the default token mock implementation after clearing
resolveTwitchTokenMock.mockReturnValue({
token: "oauth:mock-token-from-tests",
source: "config" as const,
});
// Create mock logger
mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
// Create manager instance
manager = new TwitchClientManager(mockLogger);
});
afterEach(() => {
// Clean up manager to avoid side effects
manager.clearForTest();
});
describe("getClient", () => {
it("should create a new client connection", async () => {
const ignoredClientForTest = await manager.getClient(testAccount);
void ignoredClientForTest;
// New implementation: connect is called, channels are passed to constructor
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith("Connected to Twitch as testbot");
});
it("should use account username as default channel when channel not specified", async () => {
const accountWithoutChannel: TwitchAccountConfig = {
...testAccount,
channel: "",
} as unknown as TwitchAccountConfig;
await manager.getClient(accountWithoutChannel);
// New implementation: channel (testbot) is passed to constructor, not via join()
expect(mockConnect).toHaveBeenCalledTimes(1);
});
it("should reuse existing client for same account", async () => {
const client1 = await manager.getClient(testAccount);
const client2 = await manager.getClient(testAccount);
expect(client1).toBe(client2);
expect(mockConnect).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent client creation for the same account", async () => {
mockConnect.mockImplementationOnce(() => {});
const first = manager.getClient(testAccount);
const second = manager.getClient(testAccount);
await Promise.resolve();
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(authSuccessHandlers).toHaveLength(1);
authSuccessHandlers[0]?.();
const [client1, client2] = await Promise.all([first, second]);
expect(client1).toBe(client2);
});
it("waits through authentication failure retry disconnects", async () => {
mockConnect.mockImplementationOnce(() => {});
const connection = manager.getClient(testAccount);
await Promise.resolve();
authFailureHandlers[0]?.("bad token", 1);
let settled = false;
void connection.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await Promise.resolve();
expect(settled).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
"Twitch authentication failed for testbot; waiting for retry, disconnect, or timeout: bad token",
);
disconnectHandlers[0]?.(false, new Error("disconnected"));
await Promise.resolve();
expect(settled).toBe(false);
authSuccessHandlers[0]?.();
await expect(connection).resolves.toBeTruthy();
});
it("rejects pending auth retry connections on manual disconnect", async () => {
mockConnect.mockImplementationOnce(() => {});
const connection = manager.getClient(testAccount);
await Promise.resolve();
authFailureHandlers[0]?.("bad token", 1);
disconnectHandlers[0]?.(true);
await expect(connection).rejects.toThrow("Twitch connection cancelled");
});
it("does not cache pending connections after disconnectAll", async () => {
mockConnect.mockImplementationOnce(() => {});
const connection = manager.getClient(testAccount);
await Promise.resolve();
await manager.disconnectAll();
authSuccessHandlers[0]?.();
await expect(connection).rejects.toThrow("Twitch connection cancelled");
expect(mockQuit).toHaveBeenCalledTimes(2);
});
it("should create separate clients for different accounts", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
expect(mockConnect).toHaveBeenCalledTimes(2);
});
it("should normalize token by removing oauth: prefix", async () => {
const accountWithPrefix: TwitchAccountConfig = {
...testAccount,
accessToken: "oauth:actualtoken123",
};
// Override the mock to return a specific token for this test
resolveTwitchTokenMock.mockReturnValue({
token: "oauth:actualtoken123",
source: "config" as const,
});
await manager.getClient(accountWithPrefix);
expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
});
it("should use token directly when no oauth: prefix", async () => {
// Override the mock to return a token without oauth: prefix
resolveTwitchTokenMock.mockReturnValue({
token: "oauth:mock-token-from-tests",
source: "config" as const,
});
await manager.getClient(testAccount);
// Implementation strips oauth: prefix from all tokens
expect(mockAuthProvider.constructor).toHaveBeenCalledWith(
"test-client-id",
"mock-token-from-tests",
);
});
it("should register refreshing tokens for Twurple chat intent", async () => {
const refreshingAccount: TwitchAccountConfig = {
...testAccount,
clientSecret: "test-client-secret",
refreshToken: "test-refresh-token",
expiresIn: 3600,
obtainmentTimestamp: 1_700_000_000_000,
};
await manager.getClient(refreshingAccount);
expect(mockAddUserForToken).toHaveBeenCalledTimes(1);
expect(mockAddUserForToken).toHaveBeenCalledWith(
{
accessToken: "mock-token-from-tests",
refreshToken: "test-refresh-token",
expiresIn: 3600,
obtainmentTimestamp: 1_700_000_000_000,
},
["chat"],
);
expect(mockAuthProvider.constructor).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(
"Using RefreshingAuthProvider for testbot (automatic token refresh enabled)",
);
});
it("rejects and does not cache a client when addUserForToken fails (83853)", async () => {
const refreshingAccount: TwitchAccountConfig = {
...testAccount,
clientSecret: "test-client-secret",
refreshToken: "test-refresh-token",
expiresIn: 3600,
obtainmentTimestamp: 1_700_000_000_000,
};
mockAddUserForToken.mockRejectedValueOnce(new Error("token bind failed"));
await expect(manager.getClient(refreshingAccount)).rejects.toThrow("token bind failed");
// The broken auth provider must not be cached as a usable client;
// otherwise later sends fail with an opaque error instead of failing fast.
const key = manager.getAccountKey(refreshingAccount);
expect((manager as any).clients.has(key)).toBe(false);
});
it("retries client creation after an earlier addUserForToken failure (83853)", async () => {
const refreshingAccount: TwitchAccountConfig = {
...testAccount,
clientSecret: "test-client-secret",
refreshToken: "test-refresh-token",
expiresIn: 3600,
obtainmentTimestamp: 1_700_000_000_000,
};
mockAddUserForToken.mockRejectedValueOnce(new Error("token bind failed"));
await expect(manager.getClient(refreshingAccount)).rejects.toThrow("token bind failed");
// No broken client was cached, so a second call re-attempts the bind.
await manager.getClient(refreshingAccount);
expect(mockAddUserForToken).toHaveBeenCalledTimes(2);
});
it("should throw error when clientId is missing", async () => {
const accountWithoutClientId: TwitchAccountConfig = {
...testAccount,
clientId: "" as unknown as string,
} as unknown as TwitchAccountConfig;
await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow(
"Missing Twitch client ID",
);
expect(mockLogger.error).toHaveBeenCalledWith("Missing Twitch client ID for account testbot");
});
it("should throw error when token is missing", async () => {
// Override the mock to return empty token
resolveTwitchTokenMock.mockReturnValue({
token: "",
source: "none" as const,
});
await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
});
it("should set up message handlers on client connection", async () => {
await manager.getClient(testAccount);
expect(mockOnMessage).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith("Set up handlers for testbot:testchannel");
});
it("should create separate clients for same account with different channels", async () => {
const account1: TwitchAccountConfig = {
...testAccount,
channel: "channel1",
};
const account2: TwitchAccountConfig = {
...testAccount,
channel: "channel2",
};
await manager.getClient(account1);
await manager.getClient(account2);
expect(mockConnect).toHaveBeenCalledTimes(2);
});
});
describe("onMessage", () => {
it("should register message handler for account", () => {
const handler = vi.fn();
manager.onMessage(testAccount, handler);
expect(handler).not.toHaveBeenCalled();
});
it("should replace existing handler for same account", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
manager.onMessage(testAccount, handler1);
manager.onMessage(testAccount, handler2);
// Check the stored handler is handler2
const key = manager.getAccountKey(testAccount);
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
});
it("cleanup of an earlier handler does not remove a newer registered handler (#83888)", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
const key = manager.getAccountKey(testAccount);
const cleanup1 = manager.onMessage(testAccount, handler1);
manager.onMessage(testAccount, handler2);
// Running the first handler's cleanup must not drop handler2.
cleanup1();
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
});
it("cleanup of an earlier registration does not remove a newer registration using the same handler", () => {
const handler = vi.fn();
const key = manager.getAccountKey(testAccount);
const cleanup1 = manager.onMessage(testAccount, handler);
manager.onMessage(testAccount, handler);
cleanup1();
expect((manager as any).messageHandlers.get(key)).toBe(handler);
});
it("cleanup of the current handler removes it", () => {
const handler = vi.fn();
const key = manager.getAccountKey(testAccount);
const cleanup = manager.onMessage(testAccount, handler);
cleanup();
expect((manager as any).messageHandlers.has(key)).toBe(false);
});
});
describe("disconnect", () => {
it("should disconnect a connected client", async () => {
await manager.getClient(testAccount);
await manager.disconnect(testAccount);
expect(mockQuit).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith("Disconnected testbot:testchannel");
});
it("should clear client and message handler", async () => {
const handler = vi.fn();
await manager.getClient(testAccount);
manager.onMessage(testAccount, handler);
await manager.disconnect(testAccount);
const key = manager.getAccountKey(testAccount);
expect((manager as any).clients.has(key)).toBe(false);
expect((manager as any).messageHandlers.has(key)).toBe(false);
});
it("clears pending client message handlers when disconnect cancels connection", async () => {
mockConnect.mockImplementationOnce(() => {});
const handler = vi.fn();
manager.onMessage(testAccount, handler);
const connection = manager.getClient(testAccount);
await Promise.resolve();
await manager.disconnect(testAccount);
const key = manager.getAccountKey(testAccount);
expect((manager as any).messageHandlers.has(key)).toBe(false);
authSuccessHandlers[0]?.();
await expect(connection).rejects.toThrow("Twitch connection cancelled");
messageHandlers[0]?.("#testchannel", "testuser", "stale", {
userInfo: {
userName: "testuser",
displayName: "TestUser",
userId: "123",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "msg-stale",
});
expect(handler).not.toHaveBeenCalled();
});
it("should handle disconnecting non-existent client gracefully", async () => {
// Missing clients are ignored.
await manager.disconnect(testAccount);
expect(mockQuit).not.toHaveBeenCalled();
});
it("should only disconnect specified account when multiple accounts exist", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
await manager.disconnect(testAccount);
expect(mockQuit).toHaveBeenCalledTimes(1);
const key2 = manager.getAccountKey(testAccount2);
expect((manager as any).clients.has(key2)).toBe(true);
});
});
describe("disconnectAll", () => {
it("should disconnect all connected clients", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
await manager.disconnectAll();
expect(mockQuit).toHaveBeenCalledTimes(2);
expect((manager as any).clients.size).toBe(0);
expect((manager as any).messageHandlers.size).toBe(0);
});
it("should handle empty client list gracefully", async () => {
// Empty client sets are ignored.
await manager.disconnectAll();
expect(mockQuit).not.toHaveBeenCalled();
});
});
describe("sendMessage", () => {
beforeEach(async () => {
await manager.getClient(testAccount);
});
it("should send message successfully", async () => {
const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
const { messageId, ...resultRest } = result;
expect(resultRest).toEqual({ ok: true });
expect(messageId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
});
it("should generate unique message ID for each message", async () => {
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
expect(result1.messageId).not.toBe(result2.messageId);
});
it("should handle sending to account's default channel", async () => {
const result = await manager.sendMessage(
testAccount,
testAccount.channel || testAccount.username,
"Test message",
);
// Should use the account's channel or username
expect(result.ok).toBe(true);
expect(mockSay).toHaveBeenCalled();
});
it("should return error on send failure", async () => {
mockSay.mockRejectedValueOnce(new Error("Rate limited"));
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
expect(result.ok).toBe(false);
expect(result.error).toBe("Rate limited");
expect(mockLogger.error).toHaveBeenCalledWith("Failed to send message: Rate limited");
});
it("should handle unknown error types", async () => {
mockSay.mockRejectedValueOnce("String error");
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
expect(result.ok).toBe(false);
expect(result.error).toBe("String error");
});
it("should create client if not already connected", async () => {
// Clear the existing client
(manager as any).clients.clear();
// Reset connect call count for this specific test
const connectCallCountBefore = mockConnect.mock.calls.length;
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
expect(result.ok).toBe(true);
expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
});
});
describe("message handling integration", () => {
let capturedMessage: TwitchChatMessage | null = null;
beforeEach(() => {
capturedMessage = null;
// Set up message handler before connecting
manager.onMessage(testAccount, (message) => {
capturedMessage = message;
});
});
it("should handle incoming chat messages", async () => {
await manager.getClient(testAccount);
// Get the onMessage callback
const onMessageCallback = messageHandlers[0];
if (!onMessageCallback) {
throw new Error("onMessageCallback not found");
}
// Simulate Twitch message
onMessageCallback("#testchannel", "testuser", "Hello bot!", {
userInfo: {
userName: "testuser",
displayName: "TestUser",
userId: "12345",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "msg123",
});
expect(capturedMessage?.username).toBe("testuser");
expect(capturedMessage?.displayName).toBe("TestUser");
expect(capturedMessage?.userId).toBe("12345");
expect(capturedMessage?.message).toBe("Hello bot!");
expect(capturedMessage?.channel).toBe("testchannel");
expect(capturedMessage?.chatType).toBe("group");
});
it("should normalize channel names without # prefix", async () => {
await manager.getClient(testAccount);
const onMessageCallback = messageHandlers[0];
onMessageCallback("testchannel", "testuser", "Test", {
userInfo: {
userName: "testuser",
displayName: "TestUser",
userId: "123",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "msg1",
});
expect(capturedMessage?.channel).toBe("testchannel");
});
it("should include user role flags in message", async () => {
await manager.getClient(testAccount);
const onMessageCallback = messageHandlers[0];
onMessageCallback("#testchannel", "moduser", "Test", {
userInfo: {
userName: "moduser",
displayName: "ModUser",
userId: "456",
isMod: true,
isBroadcaster: false,
isVip: true,
isSubscriber: true,
},
id: "msg2",
});
expect(capturedMessage?.isMod).toBe(true);
expect(capturedMessage?.isVip).toBe(true);
expect(capturedMessage?.isSub).toBe(true);
expect(capturedMessage?.isOwner).toBe(false);
});
it("should handle broadcaster messages", async () => {
await manager.getClient(testAccount);
const onMessageCallback = messageHandlers[0];
onMessageCallback("#testchannel", "broadcaster", "Test", {
userInfo: {
userName: "broadcaster",
displayName: "Broadcaster",
userId: "789",
isMod: false,
isBroadcaster: true,
isVip: false,
isSubscriber: false,
},
id: "msg3",
});
expect(capturedMessage?.isOwner).toBe(true);
});
});
describe("edge cases", () => {
it("should handle multiple message handlers for different accounts", async () => {
const messages1: TwitchChatMessage[] = [];
const messages2: TwitchChatMessage[] = [];
manager.onMessage(testAccount, (msg) => messages1.push(msg));
manager.onMessage(testAccount2, (msg) => messages2.push(msg));
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
// Simulate message for first account
const onMessage1 = messageHandlers[0];
if (!onMessage1) {
throw new Error("onMessage1 not found");
}
onMessage1("#testchannel", "user1", "msg1", {
userInfo: {
userName: "user1",
displayName: "User1",
userId: "1",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "1",
});
// Simulate message for second account
const onMessage2 = messageHandlers[1];
if (!onMessage2) {
throw new Error("onMessage2 not found");
}
onMessage2("#testchannel2", "user2", "msg2", {
userInfo: {
userName: "user2",
displayName: "User2",
userId: "2",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "2",
});
expect(messages1).toHaveLength(1);
expect(messages2).toHaveLength(1);
expect(messages1[0]?.message).toBe("msg1");
expect(messages2[0]?.message).toBe("msg2");
});
it("should handle rapid client creation requests", async () => {
const promises = [
manager.getClient(testAccount),
manager.getClient(testAccount),
manager.getClient(testAccount),
];
await Promise.all(promises);
// Note: The implementation doesn't handle concurrent getClient calls,
// so multiple connections may be created. This is expected behavior.
expect(mockConnect).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,411 @@
// Twitch plugin module implements twitch client behavior.
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
import { ChatClient, LogLevel } from "@twurple/chat";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveTwitchToken } from "./token.js";
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
import { normalizeToken } from "./utils/twitch.js";
const TWITCH_CHAT_AUTH_INTENTS = ["chat"];
/**
* Manages Twitch chat client connections
*/
export class TwitchClientManager {
private clients = new Map<string, ChatClient>();
private pendingClients = new Map<string, ChatClient>();
private connectionPromises = new Map<string, Promise<ChatClient>>();
private messageHandlers = new Map<string, (message: TwitchChatMessage) => void>();
private messageHandlerTokens = new Map<string, symbol>();
constructor(private logger: ChannelLogSink) {}
/**
* Create an auth provider for the account.
*/
private async createAuthProvider(
account: TwitchAccountConfig,
normalizedToken: string,
): Promise<StaticAuthProvider | RefreshingAuthProvider> {
if (!account.clientId) {
throw new Error("Missing Twitch client ID");
}
if (account.clientSecret) {
const authProvider = new RefreshingAuthProvider({
clientId: account.clientId,
clientSecret: account.clientSecret,
});
try {
const userId = await authProvider.addUserForToken(
{
accessToken: normalizedToken,
refreshToken: account.refreshToken ?? null,
expiresIn: account.expiresIn ?? null,
obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(),
},
TWITCH_CHAT_AUTH_INTENTS,
);
this.logger.info(`Added user ${userId} to RefreshingAuthProvider for ${account.username}`);
} catch (err) {
throw new Error(
`Failed to add user to RefreshingAuthProvider: ${formatErrorMessage(err)}`,
{
cause: err,
},
);
}
authProvider.onRefresh((userId, token) => {
this.logger.info(
`Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`,
);
});
authProvider.onRefreshFailure((userId, error) => {
this.logger.error(`Failed to refresh access token for user ${userId}: ${error.message}`);
});
const refreshStatus = account.refreshToken
? "automatic token refresh enabled"
: "token refresh disabled (no refresh token)";
this.logger.info(`Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`);
return authProvider;
}
this.logger.info(`Using StaticAuthProvider for ${account.username} (no clientSecret provided)`);
return new StaticAuthProvider(account.clientId, normalizedToken);
}
/**
* Get or create a chat client for an account
*/
async getClient(
account: TwitchAccountConfig,
cfg?: OpenClawConfig,
accountId?: string,
): Promise<ChatClient> {
const key = this.getAccountKey(account);
const existing = this.clients.get(key);
if (existing) {
return existing;
}
const pending = this.connectionPromises.get(key);
if (pending) {
return pending;
}
const connection = this.createConnectedClient(key, account, cfg, accountId);
this.connectionPromises.set(key, connection);
try {
return await connection;
} finally {
if (this.connectionPromises.get(key) === connection) {
this.connectionPromises.delete(key);
}
}
}
private async createConnectedClient(
key: string,
account: TwitchAccountConfig,
cfg?: OpenClawConfig,
accountId?: string,
): Promise<ChatClient> {
const tokenResolution = resolveTwitchToken(cfg, {
accountId,
});
if (!tokenResolution.token) {
this.logger.error(
`Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or OPENCLAW_TWITCH_ACCESS_TOKEN for default)`,
);
throw new Error("Missing Twitch token");
}
this.logger.debug?.(`Using ${tokenResolution.source} token source for ${account.username}`);
if (!account.clientId) {
this.logger.error(`Missing Twitch client ID for account ${account.username}`);
throw new Error("Missing Twitch client ID");
}
const normalizedToken = normalizeToken(tokenResolution.token);
const authProvider = await this.createAuthProvider(account, normalizedToken);
const client = new ChatClient({
authProvider,
channels: [account.channel],
rejoinChannelsOnReconnect: true,
requestMembershipEvents: true,
logger: {
minLevel: LogLevel.WARNING,
custom: {
log: (level, message) => {
switch (level) {
case LogLevel.CRITICAL:
this.logger.error(message);
break;
case LogLevel.ERROR:
this.logger.error(message);
break;
case LogLevel.WARNING:
this.logger.warn(message);
break;
case LogLevel.INFO:
this.logger.info(message);
break;
case LogLevel.DEBUG:
this.logger.debug?.(message);
break;
case LogLevel.TRACE:
this.logger.debug?.(message);
break;
}
},
},
},
});
this.setupClientHandlers(client, account);
this.pendingClients.set(key, client);
try {
await this.connectClient(client, account);
if (this.pendingClients.get(key) !== client) {
client.quit();
throw new Error(`Twitch connection cancelled for ${account.username}`);
}
this.pendingClients.delete(key);
} catch (error) {
if (this.pendingClients.get(key) === client) {
this.pendingClients.delete(key);
}
throw error;
}
this.clients.set(key, client);
this.logger.info(`Connected to Twitch as ${account.username}`);
return client;
}
private async connectClient(client: ChatClient, account: TwitchAccountConfig): Promise<void> {
const connectTimeoutMs = 15000;
await new Promise<void>((resolve, reject) => {
let settled = false;
let authRetryPending = false;
const listeners: Array<{ unbind: () => void }> = [];
const finish = (error?: Error) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
for (const listener of listeners) {
listener.unbind();
}
if (error) {
try {
client.quit();
} catch {
// Best effort: connection setup already failed.
}
reject(error);
return;
}
resolve();
};
listeners.push(
client.onAuthenticationSuccess(() => finish()),
client.onAuthenticationFailure((text) => {
authRetryPending = true;
this.logger.warn(
`Twitch authentication failed for ${account.username}; waiting for retry, disconnect, or timeout: ${text}`,
);
}),
client.onDisconnect((manual, reason) => {
if (authRetryPending && !manual) {
this.logger.debug?.(
`Twitch disconnected during auth retry for ${account.username}: ${formatErrorMessage(reason)}`,
);
return;
}
finish(
reason ??
new Error(
manual
? `Twitch connection cancelled for ${account.username}`
: `Twitch disconnected before ready for ${account.username}`,
),
);
}),
);
const timeout: NodeJS.Timeout | undefined = setTimeout(
() => finish(new Error(`Timed out connecting to Twitch as ${account.username}`)),
connectTimeoutMs,
);
timeout.unref?.();
try {
client.connect();
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
});
}
/**
* Set up message and event handlers for a client
*/
private setupClientHandlers(client: ChatClient, account: TwitchAccountConfig): void {
const key = this.getAccountKey(account);
// Handle incoming messages
client.onMessage((channelName, _user, messageText, msg) => {
const handler = this.messageHandlers.get(key);
if (handler) {
const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
const from = `twitch:${msg.userInfo.userName}`;
const preview = sliceUtf16Safe(messageText, 0, 100).replace(/\n/g, "\\n");
this.logger.debug?.(
`twitch inbound: channel=${normalizedChannel} from=${from} len=${messageText.length} preview="${preview}"`,
);
handler({
username: msg.userInfo.userName,
displayName: msg.userInfo.displayName,
userId: msg.userInfo.userId,
message: messageText,
channel: normalizedChannel,
id: msg.id,
timestamp: new Date(),
isMod: msg.userInfo.isMod,
isOwner: msg.userInfo.isBroadcaster,
isVip: msg.userInfo.isVip,
isSub: msg.userInfo.isSubscriber,
chatType: "group",
});
}
});
this.logger.info(`Set up handlers for ${key}`);
}
/**
* Set a message handler for an account
* @returns A function that removes the handler when called
*/
onMessage(
account: TwitchAccountConfig,
handler: (message: TwitchChatMessage) => void,
): () => void {
const key = this.getAccountKey(account);
const token = Symbol(key);
this.messageHandlers.set(key, handler);
this.messageHandlerTokens.set(key, token);
return () => {
// Only remove the exact registration this cleanup closure owns. A later
// onMessage() may reuse the same callback function for the same account.
if (this.messageHandlerTokens.get(key) === token) {
this.messageHandlers.delete(key);
this.messageHandlerTokens.delete(key);
}
};
}
private clearMessageHandler(key: string): void {
this.messageHandlers.delete(key);
this.messageHandlerTokens.delete(key);
}
/**
* Disconnect a client
*/
async disconnect(account: TwitchAccountConfig): Promise<void> {
const key = this.getAccountKey(account);
const client = this.clients.get(key);
const pendingClient = this.pendingClients.get(key);
if (pendingClient) {
pendingClient.quit();
this.pendingClients.delete(key);
this.connectionPromises.delete(key);
this.clearMessageHandler(key);
}
if (client) {
client.quit();
this.clients.delete(key);
this.clearMessageHandler(key);
this.logger.info(`Disconnected ${key}`);
}
}
/**
* Disconnect all clients
*/
async disconnectAll(): Promise<void> {
this.pendingClients.forEach((client) => client.quit());
this.clients.forEach((client) => client.quit());
this.pendingClients.clear();
this.connectionPromises.clear();
this.clients.clear();
this.messageHandlers.clear();
this.messageHandlerTokens.clear();
this.logger.info(" Disconnected all clients");
}
/**
* Send a message to a channel
*/
async sendMessage(
account: TwitchAccountConfig,
channel: string,
message: string,
cfg?: OpenClawConfig,
accountId?: string,
): Promise<{ ok: boolean; error?: string; messageId?: string }> {
try {
const client = await this.getClient(account, cfg, accountId);
// Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one)
const messageId = crypto.randomUUID();
// Send message (Twurple handles rate limiting)
await client.say(channel, message);
return { ok: true, messageId };
} catch (error) {
this.logger.error(`Failed to send message: ${formatErrorMessage(error)}`);
return {
ok: false,
error: formatErrorMessage(error),
};
}
}
/**
* Generate a unique key for an account
*/
public getAccountKey(account: TwitchAccountConfig): string {
return `${account.username}:${account.channel}`;
}
/**
* Clear all clients and handlers (for testing)
*/
clearForTest(): void {
this.clients.clear();
this.pendingClients.clear();
this.connectionPromises.clear();
this.messageHandlers.clear();
}
}

View File

@@ -0,0 +1,104 @@
/**
* Twitch channel plugin types.
*
* This file defines Twitch-specific types. Generic channel types are imported
* from OpenClaw core.
*/
import type {
ChannelAccountSnapshot,
ChannelLogSink,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelPlugin,
ChannelResolveKind,
ChannelResolveResult,
OutboundDeliveryResult,
} from "../runtime-api.js";
// ============================================================================
// Twitch-Specific Types
// ============================================================================
/**
* Twitch user roles that can be allowed to interact with the bot
*/
export type TwitchRole = "moderator" | "owner" | "vip" | "subscriber" | "all";
/**
* Account configuration for a Twitch channel
*/
export interface TwitchAccountConfig {
/** Twitch username */
username: string;
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
accessToken: string;
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
clientId: string;
/** Channel name to join (required) */
channel: string;
/** Enable this account */
enabled?: boolean;
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
allowFrom?: Array<string>;
/** Roles allowed to interact with the bot (e.g., ["mod", "vip", "sub"]) */
allowedRoles?: TwitchRole[];
/** Require @mention to trigger bot responses */
requireMention?: boolean;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
clientSecret?: string;
/** Refresh token (required for automatic token refresh) */
refreshToken?: string;
/** Token expiry time in seconds (optional, for token refresh tracking) */
expiresIn?: number | null;
/** Timestamp when token was obtained (optional, for token refresh tracking) */
obtainmentTimestamp?: number;
}
/**
* Twitch message from chat
*/
export interface TwitchChatMessage {
/** Username of sender */
username: string;
/** Twitch user ID of sender (unique, persistent identifier) */
userId?: string;
/** Message text */
message: string;
/** Channel name */
channel: string;
/** Display name (may include special characters) */
displayName?: string;
/** Message ID */
id?: string;
/** Timestamp */
timestamp?: Date;
/** Whether the sender is a moderator */
isMod?: boolean;
/** Whether the sender is the channel owner/broadcaster */
isOwner?: boolean;
/** Whether the sender is a VIP */
isVip?: boolean;
/** Whether the sender is a subscriber */
isSub?: boolean;
/** Chat type */
chatType?: "group";
}
// Re-export core types for convenience
export type {
ChannelAccountSnapshot,
ChannelLogSink,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelOutboundAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelPlugin,
ChannelOutboundContext,
OutboundDeliveryResult,
};

View File

@@ -0,0 +1,98 @@
/**
* Markdown utilities for Twitch chat
*
* Twitch chat doesn't support markdown formatting, so we strip it before sending.
* Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
*/
/**
* Strip markdown formatting from text for Twitch compatibility.
*
* Removes images, links, bold, italic, strikethrough, code blocks, inline code,
* headers, and list formatting. Replaces newlines with spaces since Twitch
* is a single-line chat medium.
*
* @param markdown - The markdown text to strip
* @returns Plain text with markdown removed
*/
export function stripMarkdownForTwitch(markdown: string): string {
return (
markdown
// Images
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
// Links
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
// Bold (**text**)
.replace(/\*\*([^*]+)\*\*/g, "$1")
// Bold (__text__)
.replace(/__([^_]+)__/g, "$1")
// Italic (*text*)
.replace(/\*([^*]+)\*/g, "$1")
// Italic (_text_)
.replace(/_([^_]+)_/g, "$1")
// Strikethrough (~~text~~)
.replace(/~~([^~]+)~~/g, "$1")
// Code blocks
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
// Inline code
.replace(/`([^`]+)`/g, "$1")
// Headers
.replace(/^#{1,6}\s+/gm, "")
// Lists
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
// Normalize whitespace
.replace(/\r/g, "") // Remove carriage returns
.replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines
.replace(/\n/g, " ") // Replace newlines with spaces (for Twitch)
.replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single
.trim()
);
}
/**
* Simple word-boundary chunker for Twitch (500 char limit).
* Strips markdown before chunking to avoid breaking markdown patterns.
*
* @param text - The text to chunk
* @param limit - Maximum characters per chunk (Twitch limit is 500)
* @returns Array of text chunks
*/
export function chunkTextForTwitch(text: string, limit: number): string[] {
// First, strip markdown
const cleaned = stripMarkdownForTwitch(text);
if (!cleaned) {
return [];
}
if (limit <= 0) {
return [cleaned];
}
if (cleaned.length <= limit) {
return [cleaned];
}
const chunks: string[] = [];
let remaining = cleaned;
while (remaining.length > limit) {
// Find the last space before the limit
const window = remaining.slice(0, limit);
const lastSpaceIndex = window.lastIndexOf(" ");
if (lastSpaceIndex === -1) {
// No space found, hard split at limit
chunks.push(window);
remaining = remaining.slice(limit);
} else {
// Split at the last space
chunks.push(window.slice(0, lastSpaceIndex));
remaining = remaining.slice(lastSpaceIndex + 1);
}
}
if (remaining) {
chunks.push(remaining);
}
return chunks;
}

View File

@@ -0,0 +1,82 @@
// Twitch plugin module implements twitch behavior.
import { randomUUID } from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
/**
* Twitch-specific utility functions
*/
/**
* Normalize Twitch channel names.
*
* Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
* Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
*
* @param channel - The channel name to normalize
* @returns Normalized channel name
*
* @example
* normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
* normalizeTwitchChannel("MyChannel") // "mychannel"
*/
export function normalizeTwitchChannel(channel: string): string {
const trimmed = normalizeLowercaseStringOrEmpty(channel);
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
}
/**
* Create a standardized error message for missing target.
*
* @param provider - The provider name (e.g., "Twitch")
* @param hint - Optional hint for how to fix the issue
* @returns Error object with descriptive message
*/
export function missingTargetError(provider: string, hint?: string): Error {
return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
}
/**
* Generate a unique message ID for Twitch messages.
*
* Twurple's say() doesn't return the message ID, so we generate one
* for tracking purposes.
*
* @returns A unique message ID
*/
export function generateMessageId(): string {
return `${Date.now()}-${randomUUID()}`;
}
/**
* Normalize OAuth token by removing the "oauth:" prefix if present.
*
* Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
*
* @param token - The OAuth token to normalize
* @returns Normalized token without "oauth:" prefix
*
* @example
* normalizeToken("oauth:abc123") // "abc123"
* normalizeToken("abc123") // "abc123"
*/
export function normalizeToken(token: string): string {
return token.startsWith("oauth:") ? token.slice(6) : token;
}
/**
* Check if an account is properly configured with required credentials.
*
* @param account - The Twitch account config to check
* @returns true if the account has required credentials
*/
export function isAccountConfigured(
account: {
username?: string;
accessToken?: string;
clientId?: string;
},
resolvedToken?: string | null,
): boolean {
const token = resolvedToken ?? account?.accessToken;
return Boolean(account?.username && token && account?.clientId);
}