mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-23 07:51:33 +00:00
refactor: adopt chat plugin builder in twitch
This commit is contained in:
@@ -5,11 +5,17 @@
|
||||
* This is the primary entry point for the Twitch channel integration.
|
||||
*/
|
||||
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import {
|
||||
createLoggedPairingApprovalNotifier,
|
||||
createPairingPrefixStripper,
|
||||
} from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import { buildChannelConfigSchema } from "../api.js";
|
||||
import { twitchMessageActions } from "./actions.js";
|
||||
@@ -28,10 +34,7 @@ import { resolveTwitchTargets } from "./resolver.js";
|
||||
import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js";
|
||||
import { collectTwitchStatusIssues } from "./status.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelCapabilities,
|
||||
ChannelLogSink,
|
||||
ChannelMeta,
|
||||
ChannelPlugin,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
@@ -39,6 +42,8 @@ import type {
|
||||
} from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
|
||||
|
||||
/**
|
||||
* Twitch channel plugin.
|
||||
*
|
||||
@@ -46,223 +51,157 @@ import { isAccountConfigured } from "./utils/twitch.js";
|
||||
* 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,
|
||||
|
||||
/** Setup wizard surface */
|
||||
setup: twitchSetupAdapter,
|
||||
setupWizard: twitchSetupWizard,
|
||||
|
||||
/** Pairing configuration */
|
||||
pairing: {
|
||||
idLabel: "twitchUserId",
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
|
||||
notifyApproval: createLoggedPairingApprovalNotifier(
|
||||
({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`,
|
||||
console.warn,
|
||||
),
|
||||
},
|
||||
|
||||
/** 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;
|
||||
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"],
|
||||
},
|
||||
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => {
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
channel: "",
|
||||
username: "",
|
||||
accessToken: "",
|
||||
clientId: "",
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
...account,
|
||||
};
|
||||
},
|
||||
defaultAccountId: (): string => DEFAULT_ACCOUNT_ID,
|
||||
isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean =>
|
||||
resolveTwitchAccountContext(cfg, DEFAULT_ACCOUNT_ID).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 ?? DEFAULT_ACCOUNT_ID);
|
||||
if (!account) {
|
||||
return inputs.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "account not configured",
|
||||
}));
|
||||
}
|
||||
|
||||
/** Get the default account ID */
|
||||
defaultAccountId: (): string => DEFAULT_ACCOUNT_ID,
|
||||
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;
|
||||
|
||||
/** Check if an account is configured */
|
||||
isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean => {
|
||||
return resolveTwitchAccountContext(cfg, DEFAULT_ACCOUNT_ID).configured;
|
||||
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,
|
||||
});
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/** 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("openclaw/plugin-sdk/runtime-env").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 }) =>
|
||||
buildPassiveProbedChannelStatusSummary(snapshot),
|
||||
|
||||
/** 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 resolvedAccountId = resolveTwitchSnapshotAccountId(cfg, account);
|
||||
const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
enabled: account?.enabled !== false,
|
||||
configured,
|
||||
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}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user