@openclaw/twitch 2026.2.21 → 2026.5.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts CHANGED
@@ -5,23 +5,37 @@
5
5
  * This is the primary entry point for the Twitch channel integration.
6
6
  */
7
7
 
8
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
9
- import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
8
+ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
9
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
10
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
11
+ import {
12
+ createLoggedPairingApprovalNotifier,
13
+ createPairingPrefixStripper,
14
+ } from "openclaw/plugin-sdk/channel-pairing";
15
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
16
+ import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
17
+ import {
18
+ createComputedAccountStatusAdapter,
19
+ createDefaultChannelRuntimeState,
20
+ } from "openclaw/plugin-sdk/status-helpers";
10
21
  import { twitchMessageActions } from "./actions.js";
11
22
  import { removeClientManager } from "./client-manager-registry.js";
12
23
  import { TwitchConfigSchema } from "./config-schema.js";
13
- import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js";
14
- import { twitchOnboardingAdapter } from "./onboarding.js";
24
+ import {
25
+ DEFAULT_ACCOUNT_ID,
26
+ getAccountConfig,
27
+ listAccountIds,
28
+ resolveDefaultTwitchAccountId,
29
+ resolveTwitchAccountContext,
30
+ resolveTwitchSnapshotAccountId,
31
+ } from "./config.js";
15
32
  import { twitchOutbound } from "./outbound.js";
16
33
  import { probeTwitch } from "./probe.js";
17
34
  import { resolveTwitchTargets } from "./resolver.js";
35
+ import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js";
18
36
  import { collectTwitchStatusIssues } from "./status.js";
19
- import { resolveTwitchToken } from "./token.js";
20
37
  import type {
21
- ChannelAccountSnapshot,
22
- ChannelCapabilities,
23
38
  ChannelLogSink,
24
- ChannelMeta,
25
39
  ChannelPlugin,
26
40
  ChannelResolveKind,
27
41
  ChannelResolveResult,
@@ -29,6 +43,8 @@ import type {
29
43
  } from "./types.js";
30
44
  import { isAccountConfigured } from "./utils/twitch.js";
31
45
 
46
+ type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
47
+
32
48
  /**
33
49
  * Twitch channel plugin.
34
50
  *
@@ -36,239 +52,157 @@ import { isAccountConfigured } from "./utils/twitch.js";
36
52
  * for OpenClaw. Supports message sending, receiving, access control, and
37
53
  * status monitoring.
38
54
  */
39
- export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
40
- /** Plugin identifier */
41
- id: "twitch",
42
-
43
- /** Plugin metadata */
44
- meta: {
45
- id: "twitch",
46
- label: "Twitch",
47
- selectionLabel: "Twitch (Chat)",
48
- docsPath: "/channels/twitch",
49
- blurb: "Twitch chat integration",
50
- aliases: ["twitch-chat"],
51
- } satisfies ChannelMeta,
52
-
53
- /** Onboarding adapter */
54
- onboarding: twitchOnboardingAdapter,
55
-
56
- /** Pairing configuration */
57
- pairing: {
58
- idLabel: "twitchUserId",
59
- normalizeAllowEntry: (entry) => entry.replace(/^(twitch:)?user:?/i, ""),
60
- notifyApproval: async ({ id }) => {
61
- // Note: Twitch doesn't support DMs from bots, so pairing approval is limited
62
- // We'll log the approval instead
63
- console.warn(`Pairing approved for user ${id} (notification sent via chat if possible)`);
64
- },
65
- },
66
-
67
- /** Supported chat capabilities */
68
- capabilities: {
69
- chatTypes: ["group"],
70
- } satisfies ChannelCapabilities,
71
-
72
- /** Configuration schema for Twitch channel */
73
- configSchema: buildChannelConfigSchema(TwitchConfigSchema),
74
-
75
- /** Account configuration management */
76
- config: {
77
- /** List all configured account IDs */
78
- listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
79
-
80
- /** Resolve an account config by ID */
81
- resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): TwitchAccountConfig => {
82
- const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
83
- if (!account) {
84
- // Return a default/empty account if not configured
85
- return {
86
- username: "",
87
- accessToken: "",
88
- clientId: "",
89
- enabled: false,
90
- } as TwitchAccountConfig;
91
- }
92
- return account;
93
- },
94
-
95
- /** Get the default account ID */
96
- defaultAccountId: (): string => DEFAULT_ACCOUNT_ID,
97
-
98
- /** Check if an account is configured */
99
- isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean => {
100
- const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
101
- const tokenResolution = resolveTwitchToken(cfg, { accountId: DEFAULT_ACCOUNT_ID });
102
- return account ? isAccountConfigured(account, tokenResolution.token) : false;
103
- },
104
-
105
- /** Check if an account is enabled */
106
- isEnabled: (account: TwitchAccountConfig | undefined): boolean => account?.enabled !== false,
107
-
108
- /** Describe account status */
109
- describeAccount: (account: TwitchAccountConfig | undefined) => {
110
- return {
111
- accountId: DEFAULT_ACCOUNT_ID,
112
- enabled: account?.enabled !== false,
113
- configured: account ? isAccountConfigured(account, account?.accessToken) : false,
114
- };
115
- },
116
- },
117
-
118
- /** Outbound message adapter */
119
- outbound: twitchOutbound,
120
-
121
- /** Message actions adapter */
122
- actions: twitchMessageActions,
123
-
124
- /** Resolver adapter for username -> user ID resolution */
125
- resolver: {
126
- resolveTargets: async ({
127
- cfg,
128
- accountId,
129
- inputs,
130
- kind,
131
- runtime,
132
- }: {
133
- cfg: OpenClawConfig;
134
- accountId?: string | null;
135
- inputs: string[];
136
- kind: ChannelResolveKind;
137
- runtime: import("../../../src/runtime.js").RuntimeEnv;
138
- }): Promise<ChannelResolveResult[]> => {
139
- const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
140
-
141
- if (!account) {
142
- return inputs.map((input) => ({
143
- input,
144
- resolved: false,
145
- note: "account not configured",
146
- }));
147
- }
148
-
149
- // Adapt RuntimeEnv.log to ChannelLogSink
150
- const log: ChannelLogSink = {
151
- info: (msg) => runtime.log(msg),
152
- warn: (msg) => runtime.log(msg),
153
- error: (msg) => runtime.error(msg),
154
- debug: (msg) => runtime.log(msg),
155
- };
156
- return await resolveTwitchTargets(inputs, account, kind, log);
157
- },
158
- },
159
-
160
- /** Status monitoring adapter */
161
- status: {
162
- /** Default runtime state */
163
- defaultRuntime: {
164
- accountId: DEFAULT_ACCOUNT_ID,
165
- running: false,
166
- lastStartAt: null,
167
- lastStopAt: null,
168
- lastError: null,
169
- },
170
-
171
- /** Build channel summary from snapshot */
172
- buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({
173
- configured: snapshot.configured ?? false,
174
- running: snapshot.running ?? false,
175
- lastStartAt: snapshot.lastStartAt ?? null,
176
- lastStopAt: snapshot.lastStopAt ?? null,
177
- lastError: snapshot.lastError ?? null,
178
- probe: snapshot.probe,
179
- lastProbeAt: snapshot.lastProbeAt ?? null,
180
- }),
181
-
182
- /** Probe account connection */
183
- probeAccount: async ({
184
- account,
185
- timeoutMs,
186
- }: {
187
- account: TwitchAccountConfig;
188
- timeoutMs: number;
189
- }): Promise<unknown> => {
190
- return await probeTwitch(account, timeoutMs);
55
+ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
56
+ createChatChannelPlugin<ResolvedTwitchAccount>({
57
+ pairing: {
58
+ idLabel: "twitchUserId",
59
+ normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
60
+ notifyApproval: createLoggedPairingApprovalNotifier(
61
+ ({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`,
62
+ console.warn,
63
+ ),
191
64
  },
192
-
193
- /** Build account snapshot with current status */
194
- buildAccountSnapshot: ({
195
- account,
196
- cfg,
197
- runtime,
198
- probe,
199
- }: {
200
- account: TwitchAccountConfig;
201
- cfg: OpenClawConfig;
202
- runtime?: ChannelAccountSnapshot;
203
- probe?: unknown;
204
- }): ChannelAccountSnapshot => {
205
- const twitch = (cfg as Record<string, unknown>).channels as
206
- | Record<string, unknown>
207
- | undefined;
208
- const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
209
- const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
210
- const resolvedAccountId =
211
- Object.entries(accountMap).find(([, value]) => value === account)?.[0] ??
212
- DEFAULT_ACCOUNT_ID;
213
- const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
214
- return {
215
- accountId: resolvedAccountId,
216
- enabled: account?.enabled !== false,
217
- configured: isAccountConfigured(account, tokenResolution.token),
218
- running: runtime?.running ?? false,
219
- lastStartAt: runtime?.lastStartAt ?? null,
220
- lastStopAt: runtime?.lastStopAt ?? null,
221
- lastError: runtime?.lastError ?? null,
222
- probe,
223
- };
224
- },
225
-
226
- /** Collect status issues for all accounts */
227
- collectStatusIssues: collectTwitchStatusIssues,
228
- },
229
-
230
- /** Gateway adapter for connection lifecycle */
231
- gateway: {
232
- /** Start an account connection */
233
- startAccount: async (ctx): Promise<void> => {
234
- const account = ctx.account;
235
- const accountId = ctx.accountId;
236
-
237
- ctx.setStatus?.({
238
- accountId,
239
- running: true,
240
- lastStartAt: Date.now(),
241
- lastError: null,
242
- });
243
-
244
- ctx.log?.info(`Starting Twitch connection for ${account.username}`);
245
-
246
- // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
247
- const { monitorTwitchProvider } = await import("./monitor.js");
248
- await monitorTwitchProvider({
249
- account,
250
- accountId,
251
- config: ctx.cfg,
252
- runtime: ctx.runtime,
253
- abortSignal: ctx.abortSignal,
254
- });
255
- },
256
-
257
- /** Stop an account connection */
258
- stopAccount: async (ctx): Promise<void> => {
259
- const account = ctx.account;
260
- const accountId = ctx.accountId;
261
-
262
- // Disconnect and remove client manager from registry
263
- await removeClientManager(accountId);
264
-
265
- ctx.setStatus?.({
266
- accountId,
267
- running: false,
268
- lastStopAt: Date.now(),
269
- });
270
-
271
- ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
65
+ outbound: twitchOutbound,
66
+ base: {
67
+ id: "twitch",
68
+ meta: {
69
+ id: "twitch",
70
+ label: "Twitch",
71
+ selectionLabel: "Twitch (Chat)",
72
+ docsPath: "/channels/twitch",
73
+ blurb: "Twitch chat integration",
74
+ aliases: ["twitch-chat"],
75
+ },
76
+ setup: twitchSetupAdapter,
77
+ setupWizard: twitchSetupWizard,
78
+ capabilities: {
79
+ chatTypes: ["group"],
80
+ },
81
+ configSchema: buildChannelConfigSchema(TwitchConfigSchema),
82
+ config: {
83
+ listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
84
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => {
85
+ const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
86
+ const account = getAccountConfig(cfg, resolvedAccountId);
87
+ if (!account) {
88
+ return {
89
+ accountId: resolvedAccountId,
90
+ channel: "",
91
+ username: "",
92
+ accessToken: "",
93
+ clientId: "",
94
+ enabled: false,
95
+ };
96
+ }
97
+ return {
98
+ accountId: resolvedAccountId,
99
+ ...account,
100
+ };
101
+ },
102
+ defaultAccountId: (cfg: OpenClawConfig): string => resolveDefaultTwitchAccountId(cfg),
103
+ isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean =>
104
+ resolveTwitchAccountContext(cfg).configured,
105
+ isEnabled: (account: ResolvedTwitchAccount | undefined): boolean =>
106
+ account?.enabled !== false,
107
+ describeAccount: (account: TwitchAccountConfig | undefined) =>
108
+ account
109
+ ? describeAccountSnapshot({
110
+ account,
111
+ configured: isAccountConfigured(account, account.accessToken),
112
+ })
113
+ : {
114
+ accountId: DEFAULT_ACCOUNT_ID,
115
+ enabled: false,
116
+ configured: false,
117
+ },
118
+ },
119
+ actions: twitchMessageActions,
120
+ resolver: {
121
+ resolveTargets: async ({
122
+ cfg,
123
+ accountId,
124
+ inputs,
125
+ kind,
126
+ runtime,
127
+ }: {
128
+ cfg: OpenClawConfig;
129
+ accountId?: string | null;
130
+ inputs: string[];
131
+ kind: ChannelResolveKind;
132
+ runtime: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
133
+ }): Promise<ChannelResolveResult[]> => {
134
+ const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
135
+ if (!account) {
136
+ return inputs.map((input) => ({
137
+ input,
138
+ resolved: false,
139
+ note: "account not configured",
140
+ }));
141
+ }
142
+
143
+ const log: ChannelLogSink = {
144
+ info: (msg) => runtime.log(msg),
145
+ warn: (msg) => runtime.log(msg),
146
+ error: (msg) => runtime.error(msg),
147
+ debug: (msg) => runtime.log(msg),
148
+ };
149
+ return await resolveTwitchTargets(inputs, account, kind, log);
150
+ },
151
+ },
152
+ status: createComputedAccountStatusAdapter<ResolvedTwitchAccount>({
153
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
154
+ buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
155
+ probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
156
+ collectStatusIssues: collectTwitchStatusIssues,
157
+ resolveAccountSnapshot: ({ account, cfg }) => {
158
+ const resolvedAccountId =
159
+ account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
160
+ const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
161
+ return {
162
+ accountId: resolvedAccountId,
163
+ enabled: account.enabled !== false,
164
+ configured,
165
+ };
166
+ },
167
+ }),
168
+ gateway: {
169
+ startAccount: async (ctx): Promise<void> => {
170
+ const account = ctx.account;
171
+ const accountId = ctx.accountId;
172
+
173
+ ctx.setStatus?.({
174
+ accountId,
175
+ running: true,
176
+ lastStartAt: Date.now(),
177
+ lastError: null,
178
+ });
179
+
180
+ ctx.log?.info(`Starting Twitch connection for ${account.username}`);
181
+
182
+ // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
183
+ const { monitorTwitchProvider } = await import("./monitor.js");
184
+ await monitorTwitchProvider({
185
+ account,
186
+ accountId,
187
+ config: ctx.cfg,
188
+ runtime: ctx.runtime,
189
+ abortSignal: ctx.abortSignal,
190
+ });
191
+ },
192
+ stopAccount: async (ctx): Promise<void> => {
193
+ const account = ctx.account;
194
+ const accountId = ctx.accountId;
195
+
196
+ await removeClientManager(accountId);
197
+
198
+ ctx.setStatus?.({
199
+ accountId,
200
+ running: false,
201
+ lastStopAt: Date.now(),
202
+ });
203
+
204
+ ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
205
+ },
206
+ },
272
207
  },
273
- },
274
- };
208
+ });
package/src/probe.test.ts CHANGED
@@ -47,7 +47,7 @@ vi.mock("@twurple/chat", () => ({
47
47
  }));
48
48
 
49
49
  vi.mock("@twurple/auth", () => ({
50
- StaticAuthProvider: class {},
50
+ StaticAuthProvider: function StaticAuthProvider() {},
51
51
  }));
52
52
 
53
53
  describe("probeTwitch", () => {
package/src/probe.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { StaticAuthProvider } from "@twurple/auth";
2
2
  import { ChatClient } from "@twurple/chat";
3
- import type { BaseProbeResult } from "openclaw/plugin-sdk";
3
+ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
4
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4
5
  import type { TwitchAccountConfig } from "./types.js";
5
6
  import { normalizeToken } from "./utils/twitch.js";
6
7
 
7
8
  /**
8
9
  * Result of probing a Twitch account
9
10
  */
10
- export type ProbeTwitchResult = BaseProbeResult<string> & {
11
+ type ProbeTwitchResult = BaseProbeResult<string> & {
11
12
  username?: string;
12
13
  elapsedMs: number;
13
14
  connected?: boolean;
@@ -82,12 +83,22 @@ export async function probeTwitch(
82
83
  });
83
84
  });
84
85
 
86
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
85
87
  const timeout = new Promise<never>((_, reject) => {
86
- setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
88
+ timeoutHandle = setTimeout(
89
+ () => reject(new Error(`timeout after ${timeoutMs}ms`)),
90
+ timeoutMs,
91
+ );
87
92
  });
88
93
 
89
94
  client.connect();
90
- await Promise.race([connectionPromise, timeout]);
95
+ try {
96
+ await Promise.race([connectionPromise, timeout]);
97
+ } finally {
98
+ if (timeoutHandle) {
99
+ clearTimeout(timeoutHandle);
100
+ }
101
+ }
91
102
 
92
103
  client.quit();
93
104
  client = undefined;
@@ -102,7 +113,7 @@ export async function probeTwitch(
102
113
  } catch (error) {
103
114
  return {
104
115
  ok: false,
105
- error: error instanceof Error ? error.message : String(error),
116
+ error: formatErrorMessage(error),
106
117
  username: account.username,
107
118
  channel: account.channel,
108
119
  elapsedMs: Date.now() - started,
package/src/resolver.ts CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { ApiClient } from "@twurple/api";
9
9
  import { StaticAuthProvider } from "@twurple/auth";
10
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
11
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
10
12
  import type { ChannelResolveKind, ChannelResolveResult } from "./types.js";
11
13
  import type { ChannelLogSink, TwitchAccountConfig } from "./types.js";
12
14
  import { normalizeToken } from "./utils/twitch.js";
@@ -17,9 +19,9 @@ import { normalizeToken } from "./utils/twitch.js";
17
19
  function normalizeUsername(input: string): string {
18
20
  const trimmed = input.trim();
19
21
  if (trimmed.startsWith("@")) {
20
- return trimmed.slice(1).toLowerCase();
22
+ return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
21
23
  }
22
- return trimmed.toLowerCase();
24
+ return normalizeLowercaseStringOrEmpty(trimmed);
23
25
  }
24
26
 
25
27
  /**
@@ -123,7 +125,7 @@ export async function resolveTwitchTargets(
123
125
  }
124
126
  }
125
127
  } catch (error) {
126
- const errorMessage = error instanceof Error ? error.message : String(error);
128
+ const errorMessage = formatErrorMessage(error);
127
129
  results.push({
128
130
  input,
129
131
  resolved: false,
package/src/runtime.ts CHANGED
@@ -1,14 +1,9 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
3
 
3
- let runtime: PluginRuntime | null = null;
4
-
5
- export function setTwitchRuntime(next: PluginRuntime) {
6
- runtime = next;
7
- }
8
-
9
- export function getTwitchRuntime(): PluginRuntime {
10
- if (!runtime) {
11
- throw new Error("Twitch runtime not initialized");
12
- }
13
- return runtime;
14
- }
4
+ const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>({
6
+ pluginId: "twitch",
7
+ errorMessage: "Twitch runtime not initialized",
8
+ });
9
+ export { getTwitchRuntime, setTwitchRuntime };