Files
moltbot/extensions/twitch/src/plugin.ts
2026-02-17 09:18:40 +09:00

275 lines
8.5 KiB
TypeScript

/**
* 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 type { OpenClawConfig } from "openclaw/plugin-sdk";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
import { twitchMessageActions } from "./actions.js";
import { removeClientManager } from "./client-manager-registry.js";
import { TwitchConfigSchema } from "./config-schema.js";
import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js";
import { twitchOnboardingAdapter } from "./onboarding.js";
import { twitchOutbound } from "./outbound.js";
import { probeTwitch } from "./probe.js";
import { resolveTwitchTargets } from "./resolver.js";
import { collectTwitchStatusIssues } from "./status.js";
import { resolveTwitchToken } from "./token.js";
import type {
ChannelAccountSnapshot,
ChannelCapabilities,
ChannelLogSink,
ChannelMeta,
ChannelPlugin,
ChannelResolveKind,
ChannelResolveResult,
TwitchAccountConfig,
} from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
/**
* 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<TwitchAccountConfig> = {
/** Plugin identifier */
id: "twitch",
/** Plugin metadata */
meta: {
id: "twitch",
label: "Twitch",
selectionLabel: "Twitch (Chat)",
docsPath: "/channels/twitch",
blurb: "Twitch chat integration",
aliases: ["twitch-chat"],
} satisfies ChannelMeta,
/** Onboarding adapter */
onboarding: twitchOnboardingAdapter,
/** Pairing configuration */
pairing: {
idLabel: "twitchUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(twitch:)?user:?/i, ""),
notifyApproval: async ({ id }) => {
// Note: Twitch doesn't support DMs from bots, so pairing approval is limited
// We'll log the approval instead
console.warn(`Pairing approved for user ${id} (notification sent via chat if possible)`);
},
},
/** Supported chat capabilities */
capabilities: {
chatTypes: ["group"],
} satisfies ChannelCapabilities,
/** Configuration schema for Twitch channel */
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
/** Account configuration management */
config: {
/** List all configured account IDs */
listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
/** Resolve an account config by ID */
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): TwitchAccountConfig => {
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
if (!account) {
// Return a default/empty account if not configured
return {
username: "",
accessToken: "",
clientId: "",
enabled: false,
} as TwitchAccountConfig;
}
return account;
},
/** Get the default account ID */
defaultAccountId: (): string => DEFAULT_ACCOUNT_ID,
/** Check if an account is configured */
isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const tokenResolution = resolveTwitchToken(cfg, { accountId: DEFAULT_ACCOUNT_ID });
return account ? isAccountConfigured(account, tokenResolution.token) : false;
},
/** Check if an account is enabled */
isEnabled: (account: TwitchAccountConfig | undefined): boolean => account?.enabled !== false,
/** Describe account status */
describeAccount: (account: TwitchAccountConfig | undefined) => {
return {
accountId: DEFAULT_ACCOUNT_ID,
enabled: account?.enabled !== false,
configured: account ? isAccountConfigured(account, account?.accessToken) : false,
};
},
},
/** Outbound message adapter */
outbound: twitchOutbound,
/** Message actions adapter */
actions: twitchMessageActions,
/** Resolver adapter for username -> user ID resolution */
resolver: {
resolveTargets: async ({
cfg,
accountId,
inputs,
kind,
runtime,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
inputs: string[];
kind: ChannelResolveKind;
runtime: import("../../../src/runtime.js").RuntimeEnv;
}): Promise<ChannelResolveResult[]> => {
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
if (!account) {
return inputs.map((input) => ({
input,
resolved: false,
note: "account not configured",
}));
}
// Adapt RuntimeEnv.log to ChannelLogSink
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 monitoring adapter */
status: {
/** Default runtime state */
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
/** Build channel summary from snapshot */
buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
/** Probe account connection */
probeAccount: async ({
account,
timeoutMs,
}: {
account: TwitchAccountConfig;
timeoutMs: number;
}): Promise<unknown> => {
return await probeTwitch(account, timeoutMs);
},
/** Build account snapshot with current status */
buildAccountSnapshot: ({
account,
cfg,
runtime,
probe,
}: {
account: TwitchAccountConfig;
cfg: OpenClawConfig;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
}): ChannelAccountSnapshot => {
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) ?? {};
const resolvedAccountId =
Object.entries(accountMap).find(([, value]) => value === account)?.[0] ??
DEFAULT_ACCOUNT_ID;
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
return {
accountId: resolvedAccountId,
enabled: account?.enabled !== false,
configured: isAccountConfigured(account, tokenResolution.token),
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
};
},
/** Collect status issues for all accounts */
collectStatusIssues: collectTwitchStatusIssues,
},
/** Gateway adapter for connection lifecycle */
gateway: {
/** Start an account connection */
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}`);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorTwitchProvider } = await import("./monitor.js");
await monitorTwitchProvider({
account,
accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
/** Stop an account connection */
stopAccount: async (ctx): Promise<void> => {
const account = ctx.account;
const accountId = ctx.accountId;
// Disconnect and remove client manager from registry
await removeClientManager(accountId);
ctx.setStatus?.({
accountId,
running: false,
lastStopAt: Date.now(),
});
ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
},
},
};