@openclaw/twitch 2026.5.12 → 2026.5.14-beta.2

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/dist/api.js CHANGED
@@ -1,3 +1,3 @@
1
- import { t as twitchPlugin } from "./plugin-CxTwdtw4.js";
2
- import { n as setTwitchRuntime } from "./runtime-QZ5I3GlI.js";
1
+ import { t as twitchPlugin } from "./plugin-6H4HbNEm.js";
2
+ import { n as setTwitchRuntime } from "./runtime-C5O5cqdz.js";
3
3
  export { setTwitchRuntime, twitchPlugin };
@@ -1,2 +1,2 @@
1
- import { t as twitchPlugin } from "./plugin-CxTwdtw4.js";
1
+ import { t as twitchPlugin } from "./plugin-6H4HbNEm.js";
2
2
  export { twitchPlugin };
@@ -1,5 +1,5 @@
1
- import { i as getOrCreateClientManager, n as stripMarkdownForTwitch } from "./markdown-BbzAc3z0.js";
2
- import { t as getTwitchRuntime } from "./runtime-QZ5I3GlI.js";
1
+ import { n as stripMarkdownForTwitch, r as getOrCreateClientManager } from "./plugin-6H4HbNEm.js";
2
+ import { t as getTwitchRuntime } from "./runtime-C5O5cqdz.js";
3
3
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4
4
  import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
5
5
  import { createChannelIngressResolver, defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
@@ -1,6 +1,4 @@
1
- import { a as normalizeTwitchChannel, i as normalizeToken, n as isAccountConfigured, o as resolveTwitchToken, r as missingTargetError, t as generateMessageId } from "./twitch-BsSKTyqW.js";
2
- import { a as getAccountConfig, c as resolveTwitchAccountContext, i as DEFAULT_ACCOUNT_ID, l as resolveTwitchSnapshotAccountId, o as listAccountIds, r as twitchSetupWizard, s as resolveDefaultTwitchAccountId, t as twitchSetupAdapter } from "./setup-surface-C1-HFfeJ.js";
3
- import { a as removeClientManager, n as stripMarkdownForTwitch, r as getClientManager, t as chunkTextForTwitch } from "./markdown-BbzAc3z0.js";
1
+ import { a as getAccountConfig, c as resolveTwitchAccountContext, d as isAccountConfigured, f as missingTargetError, h as resolveTwitchToken, i as DEFAULT_ACCOUNT_ID, l as resolveTwitchSnapshotAccountId, m as normalizeTwitchChannel, o as listAccountIds, p as normalizeToken, r as twitchSetupWizard, s as resolveDefaultTwitchAccountId, t as twitchSetupAdapter, u as generateMessageId } from "./setup-surface-B7FlQUTK.js";
4
2
  import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
5
3
  import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
6
4
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
@@ -10,11 +8,312 @@ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState }
10
8
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
11
9
  import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
12
10
  import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message";
13
- import { StaticAuthProvider } from "@twurple/auth";
14
- import { ChatClient } from "@twurple/chat";
11
+ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
12
+ import { ChatClient, LogLevel } from "@twurple/chat";
15
13
  import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
16
14
  import { z } from "zod";
17
15
  import { ApiClient } from "@twurple/api";
16
+ //#region extensions/twitch/src/twitch-client.ts
17
+ /**
18
+ * Manages Twitch chat client connections
19
+ */
20
+ var TwitchClientManager = class {
21
+ constructor(logger) {
22
+ this.logger = logger;
23
+ this.clients = /* @__PURE__ */ new Map();
24
+ this.messageHandlers = /* @__PURE__ */ new Map();
25
+ }
26
+ /**
27
+ * Create an auth provider for the account.
28
+ */
29
+ async createAuthProvider(account, normalizedToken) {
30
+ if (!account.clientId) throw new Error("Missing Twitch client ID");
31
+ if (account.clientSecret) {
32
+ const authProvider = new RefreshingAuthProvider({
33
+ clientId: account.clientId,
34
+ clientSecret: account.clientSecret
35
+ });
36
+ await authProvider.addUserForToken({
37
+ accessToken: normalizedToken,
38
+ refreshToken: account.refreshToken ?? null,
39
+ expiresIn: account.expiresIn ?? null,
40
+ obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now()
41
+ }).then((userId) => {
42
+ this.logger.info(`Added user ${userId} to RefreshingAuthProvider for ${account.username}`);
43
+ }).catch((err) => {
44
+ this.logger.error(`Failed to add user to RefreshingAuthProvider: ${formatErrorMessage(err)}`);
45
+ });
46
+ authProvider.onRefresh((userId, token) => {
47
+ this.logger.info(`Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`);
48
+ });
49
+ authProvider.onRefreshFailure((userId, error) => {
50
+ this.logger.error(`Failed to refresh access token for user ${userId}: ${error.message}`);
51
+ });
52
+ const refreshStatus = account.refreshToken ? "automatic token refresh enabled" : "token refresh disabled (no refresh token)";
53
+ this.logger.info(`Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`);
54
+ return authProvider;
55
+ }
56
+ this.logger.info(`Using StaticAuthProvider for ${account.username} (no clientSecret provided)`);
57
+ return new StaticAuthProvider(account.clientId, normalizedToken);
58
+ }
59
+ /**
60
+ * Get or create a chat client for an account
61
+ */
62
+ async getClient(account, cfg, accountId) {
63
+ const key = this.getAccountKey(account);
64
+ const existing = this.clients.get(key);
65
+ if (existing) return existing;
66
+ const tokenResolution = resolveTwitchToken(cfg, { accountId });
67
+ if (!tokenResolution.token) {
68
+ this.logger.error(`Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or OPENCLAW_TWITCH_ACCESS_TOKEN for default)`);
69
+ throw new Error("Missing Twitch token");
70
+ }
71
+ this.logger.debug?.(`Using ${tokenResolution.source} token source for ${account.username}`);
72
+ if (!account.clientId) {
73
+ this.logger.error(`Missing Twitch client ID for account ${account.username}`);
74
+ throw new Error("Missing Twitch client ID");
75
+ }
76
+ const normalizedToken = normalizeToken(tokenResolution.token);
77
+ const client = new ChatClient({
78
+ authProvider: await this.createAuthProvider(account, normalizedToken),
79
+ channels: [account.channel],
80
+ rejoinChannelsOnReconnect: true,
81
+ requestMembershipEvents: true,
82
+ logger: {
83
+ minLevel: LogLevel.WARNING,
84
+ custom: { log: (level, message) => {
85
+ switch (level) {
86
+ case LogLevel.CRITICAL:
87
+ this.logger.error(message);
88
+ break;
89
+ case LogLevel.ERROR:
90
+ this.logger.error(message);
91
+ break;
92
+ case LogLevel.WARNING:
93
+ this.logger.warn(message);
94
+ break;
95
+ case LogLevel.INFO:
96
+ this.logger.info(message);
97
+ break;
98
+ case LogLevel.DEBUG:
99
+ this.logger.debug?.(message);
100
+ break;
101
+ case LogLevel.TRACE:
102
+ this.logger.debug?.(message);
103
+ break;
104
+ }
105
+ } }
106
+ }
107
+ });
108
+ this.setupClientHandlers(client, account);
109
+ client.connect();
110
+ this.clients.set(key, client);
111
+ this.logger.info(`Connected to Twitch as ${account.username}`);
112
+ return client;
113
+ }
114
+ /**
115
+ * Set up message and event handlers for a client
116
+ */
117
+ setupClientHandlers(client, account) {
118
+ const key = this.getAccountKey(account);
119
+ client.onMessage((channelName, _user, messageText, msg) => {
120
+ const handler = this.messageHandlers.get(key);
121
+ if (handler) {
122
+ const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
123
+ const from = `twitch:${msg.userInfo.userName}`;
124
+ const preview = messageText.slice(0, 100).replace(/\n/g, "\\n");
125
+ this.logger.debug?.(`twitch inbound: channel=${normalizedChannel} from=${from} len=${messageText.length} preview="${preview}"`);
126
+ handler({
127
+ username: msg.userInfo.userName,
128
+ displayName: msg.userInfo.displayName,
129
+ userId: msg.userInfo.userId,
130
+ message: messageText,
131
+ channel: normalizedChannel,
132
+ id: msg.id,
133
+ timestamp: /* @__PURE__ */ new Date(),
134
+ isMod: msg.userInfo.isMod,
135
+ isOwner: msg.userInfo.isBroadcaster,
136
+ isVip: msg.userInfo.isVip,
137
+ isSub: msg.userInfo.isSubscriber,
138
+ chatType: "group"
139
+ });
140
+ }
141
+ });
142
+ this.logger.info(`Set up handlers for ${key}`);
143
+ }
144
+ /**
145
+ * Set a message handler for an account
146
+ * @returns A function that removes the handler when called
147
+ */
148
+ onMessage(account, handler) {
149
+ const key = this.getAccountKey(account);
150
+ this.messageHandlers.set(key, handler);
151
+ return () => {
152
+ this.messageHandlers.delete(key);
153
+ };
154
+ }
155
+ /**
156
+ * Disconnect a client
157
+ */
158
+ async disconnect(account) {
159
+ const key = this.getAccountKey(account);
160
+ const client = this.clients.get(key);
161
+ if (client) {
162
+ client.quit();
163
+ this.clients.delete(key);
164
+ this.messageHandlers.delete(key);
165
+ this.logger.info(`Disconnected ${key}`);
166
+ }
167
+ }
168
+ /**
169
+ * Disconnect all clients
170
+ */
171
+ async disconnectAll() {
172
+ this.clients.forEach((client) => client.quit());
173
+ this.clients.clear();
174
+ this.messageHandlers.clear();
175
+ this.logger.info(" Disconnected all clients");
176
+ }
177
+ /**
178
+ * Send a message to a channel
179
+ */
180
+ async sendMessage(account, channel, message, cfg, accountId) {
181
+ try {
182
+ const client = await this.getClient(account, cfg, accountId);
183
+ const messageId = crypto.randomUUID();
184
+ await client.say(channel, message);
185
+ return {
186
+ ok: true,
187
+ messageId
188
+ };
189
+ } catch (error) {
190
+ this.logger.error(`Failed to send message: ${formatErrorMessage(error)}`);
191
+ return {
192
+ ok: false,
193
+ error: formatErrorMessage(error)
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * Generate a unique key for an account
199
+ */
200
+ getAccountKey(account) {
201
+ return `${account.username}:${account.channel}`;
202
+ }
203
+ /**
204
+ * Clear all clients and handlers (for testing)
205
+ */
206
+ _clearForTest() {
207
+ this.clients.clear();
208
+ this.messageHandlers.clear();
209
+ }
210
+ };
211
+ //#endregion
212
+ //#region extensions/twitch/src/client-manager-registry.ts
213
+ /**
214
+ * Client manager registry for Twitch plugin.
215
+ *
216
+ * Manages the lifecycle of TwitchClientManager instances across the plugin,
217
+ * ensuring proper cleanup when accounts are stopped or reconfigured.
218
+ */
219
+ /**
220
+ * Global registry of client managers.
221
+ * Keyed by account ID.
222
+ */
223
+ const registry = /* @__PURE__ */ new Map();
224
+ /**
225
+ * Get or create a client manager for an account.
226
+ *
227
+ * @param accountId - The account ID
228
+ * @param logger - Logger instance
229
+ * @returns The client manager
230
+ */
231
+ function getOrCreateClientManager(accountId, logger) {
232
+ const existing = registry.get(accountId);
233
+ if (existing) return existing.manager;
234
+ const manager = new TwitchClientManager(logger);
235
+ registry.set(accountId, {
236
+ manager,
237
+ accountId,
238
+ logger,
239
+ createdAt: Date.now()
240
+ });
241
+ logger.info(`Registered client manager for account: ${accountId}`);
242
+ return manager;
243
+ }
244
+ /**
245
+ * Get an existing client manager for an account.
246
+ *
247
+ * @param accountId - The account ID
248
+ * @returns The client manager, or undefined if not registered
249
+ */
250
+ function getClientManager(accountId) {
251
+ return registry.get(accountId)?.manager;
252
+ }
253
+ /**
254
+ * Disconnect and remove a client manager from the registry.
255
+ *
256
+ * @param accountId - The account ID
257
+ * @returns Promise that resolves when cleanup is complete
258
+ */
259
+ async function removeClientManager(accountId) {
260
+ const entry = registry.get(accountId);
261
+ if (!entry) return;
262
+ await entry.manager.disconnectAll();
263
+ registry.delete(accountId);
264
+ entry.logger.info(`Unregistered client manager for account: ${accountId}`);
265
+ }
266
+ //#endregion
267
+ //#region extensions/twitch/src/utils/markdown.ts
268
+ /**
269
+ * Markdown utilities for Twitch chat
270
+ *
271
+ * Twitch chat doesn't support markdown formatting, so we strip it before sending.
272
+ * Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
273
+ */
274
+ /**
275
+ * Strip markdown formatting from text for Twitch compatibility.
276
+ *
277
+ * Removes images, links, bold, italic, strikethrough, code blocks, inline code,
278
+ * headers, and list formatting. Replaces newlines with spaces since Twitch
279
+ * is a single-line chat medium.
280
+ *
281
+ * @param markdown - The markdown text to strip
282
+ * @returns Plain text with markdown removed
283
+ */
284
+ function stripMarkdownForTwitch(markdown) {
285
+ return markdown.replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/\[([^\]]+)]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/_([^_]+)_/g, "$1").replace(/~~([^~]+)~~/g, "$1").replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, "")).replace(/`([^`]+)`/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/^\s*[-*+]\s+/gm, "").replace(/^\s*\d+\.\s+/gm, "").replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n/g, " ").replace(/[ \t]{2,}/g, " ").trim();
286
+ }
287
+ /**
288
+ * Simple word-boundary chunker for Twitch (500 char limit).
289
+ * Strips markdown before chunking to avoid breaking markdown patterns.
290
+ *
291
+ * @param text - The text to chunk
292
+ * @param limit - Maximum characters per chunk (Twitch limit is 500)
293
+ * @returns Array of text chunks
294
+ */
295
+ function chunkTextForTwitch(text, limit) {
296
+ const cleaned = stripMarkdownForTwitch(text);
297
+ if (!cleaned) return [];
298
+ if (limit <= 0) return [cleaned];
299
+ if (cleaned.length <= limit) return [cleaned];
300
+ const chunks = [];
301
+ let remaining = cleaned;
302
+ while (remaining.length > limit) {
303
+ const window = remaining.slice(0, limit);
304
+ const lastSpaceIndex = window.lastIndexOf(" ");
305
+ if (lastSpaceIndex === -1) {
306
+ chunks.push(window);
307
+ remaining = remaining.slice(limit);
308
+ } else {
309
+ chunks.push(window.slice(0, lastSpaceIndex));
310
+ remaining = remaining.slice(lastSpaceIndex + 1);
311
+ }
312
+ }
313
+ if (remaining) chunks.push(remaining);
314
+ return chunks;
315
+ }
316
+ //#endregion
18
317
  //#region extensions/twitch/src/send.ts
19
318
  /**
20
319
  * Twitch message sending functions with dependency injection support.
@@ -954,7 +1253,7 @@ const twitchPlugin = createChatChannelPlugin({
954
1253
  lastError: null
955
1254
  });
956
1255
  ctx.log?.info(`Starting Twitch connection for ${account.username}`);
957
- const { monitorTwitchProvider } = await import("./monitor-BQ5mdzvw.js");
1256
+ const { monitorTwitchProvider } = await import("./monitor-DAM8hrYU.js");
958
1257
  await monitorTwitchProvider({
959
1258
  account,
960
1259
  accountId,
@@ -978,4 +1277,4 @@ const twitchPlugin = createChatChannelPlugin({
978
1277
  }
979
1278
  });
980
1279
  //#endregion
981
- export { twitchPlugin as t };
1280
+ export { stripMarkdownForTwitch as n, getOrCreateClientManager as r, twitchPlugin as t };
@@ -1,2 +1,2 @@
1
- import { n as twitchSetupPlugin } from "./setup-surface-C1-HFfeJ.js";
1
+ import { n as twitchSetupPlugin } from "./setup-surface-B7FlQUTK.js";
2
2
  export { twitchSetupPlugin };
@@ -1,8 +1,134 @@
1
- import { n as isAccountConfigured, o as resolveTwitchToken } from "./twitch-BsSKTyqW.js";
2
- import { listCombinedAccountIds, normalizeAccountId, resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
1
+ import { DEFAULT_ACCOUNT_ID, listCombinedAccountIds, normalizeAccountId, resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
2
+ import { randomUUID } from "node:crypto";
3
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
3
4
  import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
4
5
  import { getChatChannelMeta } from "openclaw/plugin-sdk/core";
5
6
  import { formatDocsLink, normalizeAccountId as normalizeAccountId$1 } from "openclaw/plugin-sdk/setup";
7
+ //#region extensions/twitch/src/token.ts
8
+ /**
9
+ * Twitch access token resolution with environment variable support.
10
+ *
11
+ * Supports reading Twitch OAuth access tokens from config or environment variable.
12
+ * The OPENCLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
13
+ *
14
+ * Token resolution priority:
15
+ * 1. Account access token from merged config (accounts.{id} or base-level for default)
16
+ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
17
+ */
18
+ /**
19
+ * Normalize a Twitch OAuth token - ensure it has the oauth: prefix
20
+ */
21
+ function normalizeTwitchToken(raw) {
22
+ if (!raw) return;
23
+ const trimmed = raw.trim();
24
+ if (!trimmed) return;
25
+ return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
26
+ }
27
+ /**
28
+ * Resolve Twitch access token from config or environment variable.
29
+ *
30
+ * Priority:
31
+ * 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
32
+ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
33
+ *
34
+ * The getAccountConfig function handles merging base-level config with accounts.default,
35
+ * so this logic works for both simplified and multi-account patterns.
36
+ *
37
+ * @param cfg - OpenClaw config
38
+ * @param opts - Options including accountId and optional envToken override
39
+ * @returns Token resolution with source
40
+ */
41
+ function resolveTwitchToken(cfg, opts = {}) {
42
+ const accountId = normalizeAccountId(opts.accountId);
43
+ const twitchCfg = cfg?.channels?.twitch;
44
+ const accounts = twitchCfg?.accounts;
45
+ const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
46
+ let token;
47
+ if (accountId === DEFAULT_ACCOUNT_ID) token = normalizeTwitchToken((typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : void 0) || accountCfg?.accessToken);
48
+ else token = normalizeTwitchToken(accountCfg?.accessToken);
49
+ if (token) return {
50
+ token,
51
+ source: "config"
52
+ };
53
+ const envToken = accountId === DEFAULT_ACCOUNT_ID ? normalizeTwitchToken(opts.envToken ?? process.env.OPENCLAW_TWITCH_ACCESS_TOKEN) : void 0;
54
+ if (envToken) return {
55
+ token: envToken,
56
+ source: "env"
57
+ };
58
+ return {
59
+ token: "",
60
+ source: "none"
61
+ };
62
+ }
63
+ //#endregion
64
+ //#region extensions/twitch/src/utils/twitch.ts
65
+ /**
66
+ * Twitch-specific utility functions
67
+ */
68
+ /**
69
+ * Normalize Twitch channel names.
70
+ *
71
+ * Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
72
+ * Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
73
+ *
74
+ * @param channel - The channel name to normalize
75
+ * @returns Normalized channel name
76
+ *
77
+ * @example
78
+ * normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
79
+ * normalizeTwitchChannel("MyChannel") // "mychannel"
80
+ */
81
+ function normalizeTwitchChannel(channel) {
82
+ const trimmed = normalizeLowercaseStringOrEmpty(channel);
83
+ return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
84
+ }
85
+ /**
86
+ * Create a standardized error message for missing target.
87
+ *
88
+ * @param provider - The provider name (e.g., "Twitch")
89
+ * @param hint - Optional hint for how to fix the issue
90
+ * @returns Error object with descriptive message
91
+ */
92
+ function missingTargetError(provider, hint) {
93
+ return /* @__PURE__ */ new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
94
+ }
95
+ /**
96
+ * Generate a unique message ID for Twitch messages.
97
+ *
98
+ * Twurple's say() doesn't return the message ID, so we generate one
99
+ * for tracking purposes.
100
+ *
101
+ * @returns A unique message ID
102
+ */
103
+ function generateMessageId() {
104
+ return `${Date.now()}-${randomUUID()}`;
105
+ }
106
+ /**
107
+ * Normalize OAuth token by removing the "oauth:" prefix if present.
108
+ *
109
+ * Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
110
+ *
111
+ * @param token - The OAuth token to normalize
112
+ * @returns Normalized token without "oauth:" prefix
113
+ *
114
+ * @example
115
+ * normalizeToken("oauth:abc123") // "abc123"
116
+ * normalizeToken("abc123") // "abc123"
117
+ */
118
+ function normalizeToken(token) {
119
+ return token.startsWith("oauth:") ? token.slice(6) : token;
120
+ }
121
+ /**
122
+ * Check if an account is properly configured with required credentials.
123
+ *
124
+ * @param account - The Twitch account config to check
125
+ * @returns true if the account has required credentials
126
+ */
127
+ function isAccountConfigured(account, resolvedToken) {
128
+ const token = resolvedToken ?? account?.accessToken;
129
+ return Boolean(account?.username && token && account?.clientId);
130
+ }
131
+ //#endregion
6
132
  //#region extensions/twitch/src/config.ts
7
133
  /**
8
134
  * Default account ID for Twitch
@@ -397,4 +523,4 @@ const twitchSetupPlugin = {
397
523
  setupWizard: twitchSetupWizard
398
524
  };
399
525
  //#endregion
400
- export { getAccountConfig as a, resolveTwitchAccountContext as c, DEFAULT_ACCOUNT_ID$1 as i, resolveTwitchSnapshotAccountId as l, twitchSetupPlugin as n, listAccountIds as o, twitchSetupWizard as r, resolveDefaultTwitchAccountId as s, twitchSetupAdapter as t };
526
+ export { getAccountConfig as a, resolveTwitchAccountContext as c, isAccountConfigured as d, missingTargetError as f, resolveTwitchToken as h, DEFAULT_ACCOUNT_ID$1 as i, resolveTwitchSnapshotAccountId as l, normalizeTwitchChannel as m, twitchSetupPlugin as n, listAccountIds as o, normalizeToken as p, twitchSetupWizard as r, resolveDefaultTwitchAccountId as s, twitchSetupAdapter as t, generateMessageId as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/twitch",
3
- "version": "2026.5.12",
3
+ "version": "2026.5.14-beta.2",
4
4
  "description": "OpenClaw Twitch channel plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,10 +27,10 @@
27
27
  "minHostVersion": ">=2026.4.10"
28
28
  },
29
29
  "compat": {
30
- "pluginApi": ">=2026.5.12"
30
+ "pluginApi": ">=2026.5.14-beta.2"
31
31
  },
32
32
  "build": {
33
- "openclawVersion": "2026.5.12"
33
+ "openclawVersion": "2026.5.14-beta.2"
34
34
  },
35
35
  "channel": {
36
36
  "id": "twitch",
@@ -57,7 +57,7 @@
57
57
  "README.md"
58
58
  ],
59
59
  "peerDependencies": {
60
- "openclaw": ">=2026.5.12"
60
+ "openclaw": ">=2026.5.14-beta.2"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "openclaw": {
@@ -1,306 +0,0 @@
1
- import { i as normalizeToken, o as resolveTwitchToken } from "./twitch-BsSKTyqW.js";
2
- import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
- import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
4
- import { ChatClient, LogLevel } from "@twurple/chat";
5
- //#region extensions/twitch/src/twitch-client.ts
6
- /**
7
- * Manages Twitch chat client connections
8
- */
9
- var TwitchClientManager = class {
10
- constructor(logger) {
11
- this.logger = logger;
12
- this.clients = /* @__PURE__ */ new Map();
13
- this.messageHandlers = /* @__PURE__ */ new Map();
14
- }
15
- /**
16
- * Create an auth provider for the account.
17
- */
18
- async createAuthProvider(account, normalizedToken) {
19
- if (!account.clientId) throw new Error("Missing Twitch client ID");
20
- if (account.clientSecret) {
21
- const authProvider = new RefreshingAuthProvider({
22
- clientId: account.clientId,
23
- clientSecret: account.clientSecret
24
- });
25
- await authProvider.addUserForToken({
26
- accessToken: normalizedToken,
27
- refreshToken: account.refreshToken ?? null,
28
- expiresIn: account.expiresIn ?? null,
29
- obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now()
30
- }).then((userId) => {
31
- this.logger.info(`Added user ${userId} to RefreshingAuthProvider for ${account.username}`);
32
- }).catch((err) => {
33
- this.logger.error(`Failed to add user to RefreshingAuthProvider: ${formatErrorMessage(err)}`);
34
- });
35
- authProvider.onRefresh((userId, token) => {
36
- this.logger.info(`Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`);
37
- });
38
- authProvider.onRefreshFailure((userId, error) => {
39
- this.logger.error(`Failed to refresh access token for user ${userId}: ${error.message}`);
40
- });
41
- const refreshStatus = account.refreshToken ? "automatic token refresh enabled" : "token refresh disabled (no refresh token)";
42
- this.logger.info(`Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`);
43
- return authProvider;
44
- }
45
- this.logger.info(`Using StaticAuthProvider for ${account.username} (no clientSecret provided)`);
46
- return new StaticAuthProvider(account.clientId, normalizedToken);
47
- }
48
- /**
49
- * Get or create a chat client for an account
50
- */
51
- async getClient(account, cfg, accountId) {
52
- const key = this.getAccountKey(account);
53
- const existing = this.clients.get(key);
54
- if (existing) return existing;
55
- const tokenResolution = resolveTwitchToken(cfg, { accountId });
56
- if (!tokenResolution.token) {
57
- this.logger.error(`Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or OPENCLAW_TWITCH_ACCESS_TOKEN for default)`);
58
- throw new Error("Missing Twitch token");
59
- }
60
- this.logger.debug?.(`Using ${tokenResolution.source} token source for ${account.username}`);
61
- if (!account.clientId) {
62
- this.logger.error(`Missing Twitch client ID for account ${account.username}`);
63
- throw new Error("Missing Twitch client ID");
64
- }
65
- const normalizedToken = normalizeToken(tokenResolution.token);
66
- const client = new ChatClient({
67
- authProvider: await this.createAuthProvider(account, normalizedToken),
68
- channels: [account.channel],
69
- rejoinChannelsOnReconnect: true,
70
- requestMembershipEvents: true,
71
- logger: {
72
- minLevel: LogLevel.WARNING,
73
- custom: { log: (level, message) => {
74
- switch (level) {
75
- case LogLevel.CRITICAL:
76
- this.logger.error(message);
77
- break;
78
- case LogLevel.ERROR:
79
- this.logger.error(message);
80
- break;
81
- case LogLevel.WARNING:
82
- this.logger.warn(message);
83
- break;
84
- case LogLevel.INFO:
85
- this.logger.info(message);
86
- break;
87
- case LogLevel.DEBUG:
88
- this.logger.debug?.(message);
89
- break;
90
- case LogLevel.TRACE:
91
- this.logger.debug?.(message);
92
- break;
93
- }
94
- } }
95
- }
96
- });
97
- this.setupClientHandlers(client, account);
98
- client.connect();
99
- this.clients.set(key, client);
100
- this.logger.info(`Connected to Twitch as ${account.username}`);
101
- return client;
102
- }
103
- /**
104
- * Set up message and event handlers for a client
105
- */
106
- setupClientHandlers(client, account) {
107
- const key = this.getAccountKey(account);
108
- client.onMessage((channelName, _user, messageText, msg) => {
109
- const handler = this.messageHandlers.get(key);
110
- if (handler) {
111
- const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
112
- const from = `twitch:${msg.userInfo.userName}`;
113
- const preview = messageText.slice(0, 100).replace(/\n/g, "\\n");
114
- this.logger.debug?.(`twitch inbound: channel=${normalizedChannel} from=${from} len=${messageText.length} preview="${preview}"`);
115
- handler({
116
- username: msg.userInfo.userName,
117
- displayName: msg.userInfo.displayName,
118
- userId: msg.userInfo.userId,
119
- message: messageText,
120
- channel: normalizedChannel,
121
- id: msg.id,
122
- timestamp: /* @__PURE__ */ new Date(),
123
- isMod: msg.userInfo.isMod,
124
- isOwner: msg.userInfo.isBroadcaster,
125
- isVip: msg.userInfo.isVip,
126
- isSub: msg.userInfo.isSubscriber,
127
- chatType: "group"
128
- });
129
- }
130
- });
131
- this.logger.info(`Set up handlers for ${key}`);
132
- }
133
- /**
134
- * Set a message handler for an account
135
- * @returns A function that removes the handler when called
136
- */
137
- onMessage(account, handler) {
138
- const key = this.getAccountKey(account);
139
- this.messageHandlers.set(key, handler);
140
- return () => {
141
- this.messageHandlers.delete(key);
142
- };
143
- }
144
- /**
145
- * Disconnect a client
146
- */
147
- async disconnect(account) {
148
- const key = this.getAccountKey(account);
149
- const client = this.clients.get(key);
150
- if (client) {
151
- client.quit();
152
- this.clients.delete(key);
153
- this.messageHandlers.delete(key);
154
- this.logger.info(`Disconnected ${key}`);
155
- }
156
- }
157
- /**
158
- * Disconnect all clients
159
- */
160
- async disconnectAll() {
161
- this.clients.forEach((client) => client.quit());
162
- this.clients.clear();
163
- this.messageHandlers.clear();
164
- this.logger.info(" Disconnected all clients");
165
- }
166
- /**
167
- * Send a message to a channel
168
- */
169
- async sendMessage(account, channel, message, cfg, accountId) {
170
- try {
171
- const client = await this.getClient(account, cfg, accountId);
172
- const messageId = crypto.randomUUID();
173
- await client.say(channel, message);
174
- return {
175
- ok: true,
176
- messageId
177
- };
178
- } catch (error) {
179
- this.logger.error(`Failed to send message: ${formatErrorMessage(error)}`);
180
- return {
181
- ok: false,
182
- error: formatErrorMessage(error)
183
- };
184
- }
185
- }
186
- /**
187
- * Generate a unique key for an account
188
- */
189
- getAccountKey(account) {
190
- return `${account.username}:${account.channel}`;
191
- }
192
- /**
193
- * Clear all clients and handlers (for testing)
194
- */
195
- _clearForTest() {
196
- this.clients.clear();
197
- this.messageHandlers.clear();
198
- }
199
- };
200
- //#endregion
201
- //#region extensions/twitch/src/client-manager-registry.ts
202
- /**
203
- * Client manager registry for Twitch plugin.
204
- *
205
- * Manages the lifecycle of TwitchClientManager instances across the plugin,
206
- * ensuring proper cleanup when accounts are stopped or reconfigured.
207
- */
208
- /**
209
- * Global registry of client managers.
210
- * Keyed by account ID.
211
- */
212
- const registry = /* @__PURE__ */ new Map();
213
- /**
214
- * Get or create a client manager for an account.
215
- *
216
- * @param accountId - The account ID
217
- * @param logger - Logger instance
218
- * @returns The client manager
219
- */
220
- function getOrCreateClientManager(accountId, logger) {
221
- const existing = registry.get(accountId);
222
- if (existing) return existing.manager;
223
- const manager = new TwitchClientManager(logger);
224
- registry.set(accountId, {
225
- manager,
226
- accountId,
227
- logger,
228
- createdAt: Date.now()
229
- });
230
- logger.info(`Registered client manager for account: ${accountId}`);
231
- return manager;
232
- }
233
- /**
234
- * Get an existing client manager for an account.
235
- *
236
- * @param accountId - The account ID
237
- * @returns The client manager, or undefined if not registered
238
- */
239
- function getClientManager(accountId) {
240
- return registry.get(accountId)?.manager;
241
- }
242
- /**
243
- * Disconnect and remove a client manager from the registry.
244
- *
245
- * @param accountId - The account ID
246
- * @returns Promise that resolves when cleanup is complete
247
- */
248
- async function removeClientManager(accountId) {
249
- const entry = registry.get(accountId);
250
- if (!entry) return;
251
- await entry.manager.disconnectAll();
252
- registry.delete(accountId);
253
- entry.logger.info(`Unregistered client manager for account: ${accountId}`);
254
- }
255
- //#endregion
256
- //#region extensions/twitch/src/utils/markdown.ts
257
- /**
258
- * Markdown utilities for Twitch chat
259
- *
260
- * Twitch chat doesn't support markdown formatting, so we strip it before sending.
261
- * Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
262
- */
263
- /**
264
- * Strip markdown formatting from text for Twitch compatibility.
265
- *
266
- * Removes images, links, bold, italic, strikethrough, code blocks, inline code,
267
- * headers, and list formatting. Replaces newlines with spaces since Twitch
268
- * is a single-line chat medium.
269
- *
270
- * @param markdown - The markdown text to strip
271
- * @returns Plain text with markdown removed
272
- */
273
- function stripMarkdownForTwitch(markdown) {
274
- return markdown.replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/\[([^\]]+)]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/_([^_]+)_/g, "$1").replace(/~~([^~]+)~~/g, "$1").replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, "")).replace(/`([^`]+)`/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/^\s*[-*+]\s+/gm, "").replace(/^\s*\d+\.\s+/gm, "").replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n/g, " ").replace(/[ \t]{2,}/g, " ").trim();
275
- }
276
- /**
277
- * Simple word-boundary chunker for Twitch (500 char limit).
278
- * Strips markdown before chunking to avoid breaking markdown patterns.
279
- *
280
- * @param text - The text to chunk
281
- * @param limit - Maximum characters per chunk (Twitch limit is 500)
282
- * @returns Array of text chunks
283
- */
284
- function chunkTextForTwitch(text, limit) {
285
- const cleaned = stripMarkdownForTwitch(text);
286
- if (!cleaned) return [];
287
- if (limit <= 0) return [cleaned];
288
- if (cleaned.length <= limit) return [cleaned];
289
- const chunks = [];
290
- let remaining = cleaned;
291
- while (remaining.length > limit) {
292
- const window = remaining.slice(0, limit);
293
- const lastSpaceIndex = window.lastIndexOf(" ");
294
- if (lastSpaceIndex === -1) {
295
- chunks.push(window);
296
- remaining = remaining.slice(limit);
297
- } else {
298
- chunks.push(window.slice(0, lastSpaceIndex));
299
- remaining = remaining.slice(lastSpaceIndex + 1);
300
- }
301
- }
302
- if (remaining) chunks.push(remaining);
303
- return chunks;
304
- }
305
- //#endregion
306
- export { removeClientManager as a, getOrCreateClientManager as i, stripMarkdownForTwitch as n, getClientManager as r, chunkTextForTwitch as t };
@@ -1,129 +0,0 @@
1
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
2
- import { randomUUID } from "node:crypto";
3
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
4
- //#region extensions/twitch/src/token.ts
5
- /**
6
- * Twitch access token resolution with environment variable support.
7
- *
8
- * Supports reading Twitch OAuth access tokens from config or environment variable.
9
- * The OPENCLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
10
- *
11
- * Token resolution priority:
12
- * 1. Account access token from merged config (accounts.{id} or base-level for default)
13
- * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
14
- */
15
- /**
16
- * Normalize a Twitch OAuth token - ensure it has the oauth: prefix
17
- */
18
- function normalizeTwitchToken(raw) {
19
- if (!raw) return;
20
- const trimmed = raw.trim();
21
- if (!trimmed) return;
22
- return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
23
- }
24
- /**
25
- * Resolve Twitch access token from config or environment variable.
26
- *
27
- * Priority:
28
- * 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
29
- * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
30
- *
31
- * The getAccountConfig function handles merging base-level config with accounts.default,
32
- * so this logic works for both simplified and multi-account patterns.
33
- *
34
- * @param cfg - OpenClaw config
35
- * @param opts - Options including accountId and optional envToken override
36
- * @returns Token resolution with source
37
- */
38
- function resolveTwitchToken(cfg, opts = {}) {
39
- const accountId = normalizeAccountId(opts.accountId);
40
- const twitchCfg = cfg?.channels?.twitch;
41
- const accounts = twitchCfg?.accounts;
42
- const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
43
- let token;
44
- if (accountId === DEFAULT_ACCOUNT_ID) token = normalizeTwitchToken((typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : void 0) || accountCfg?.accessToken);
45
- else token = normalizeTwitchToken(accountCfg?.accessToken);
46
- if (token) return {
47
- token,
48
- source: "config"
49
- };
50
- const envToken = accountId === DEFAULT_ACCOUNT_ID ? normalizeTwitchToken(opts.envToken ?? process.env.OPENCLAW_TWITCH_ACCESS_TOKEN) : void 0;
51
- if (envToken) return {
52
- token: envToken,
53
- source: "env"
54
- };
55
- return {
56
- token: "",
57
- source: "none"
58
- };
59
- }
60
- //#endregion
61
- //#region extensions/twitch/src/utils/twitch.ts
62
- /**
63
- * Twitch-specific utility functions
64
- */
65
- /**
66
- * Normalize Twitch channel names.
67
- *
68
- * Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
69
- * Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
70
- *
71
- * @param channel - The channel name to normalize
72
- * @returns Normalized channel name
73
- *
74
- * @example
75
- * normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
76
- * normalizeTwitchChannel("MyChannel") // "mychannel"
77
- */
78
- function normalizeTwitchChannel(channel) {
79
- const trimmed = normalizeLowercaseStringOrEmpty(channel);
80
- return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
81
- }
82
- /**
83
- * Create a standardized error message for missing target.
84
- *
85
- * @param provider - The provider name (e.g., "Twitch")
86
- * @param hint - Optional hint for how to fix the issue
87
- * @returns Error object with descriptive message
88
- */
89
- function missingTargetError(provider, hint) {
90
- return /* @__PURE__ */ new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
91
- }
92
- /**
93
- * Generate a unique message ID for Twitch messages.
94
- *
95
- * Twurple's say() doesn't return the message ID, so we generate one
96
- * for tracking purposes.
97
- *
98
- * @returns A unique message ID
99
- */
100
- function generateMessageId() {
101
- return `${Date.now()}-${randomUUID()}`;
102
- }
103
- /**
104
- * Normalize OAuth token by removing the "oauth:" prefix if present.
105
- *
106
- * Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
107
- *
108
- * @param token - The OAuth token to normalize
109
- * @returns Normalized token without "oauth:" prefix
110
- *
111
- * @example
112
- * normalizeToken("oauth:abc123") // "abc123"
113
- * normalizeToken("abc123") // "abc123"
114
- */
115
- function normalizeToken(token) {
116
- return token.startsWith("oauth:") ? token.slice(6) : token;
117
- }
118
- /**
119
- * Check if an account is properly configured with required credentials.
120
- *
121
- * @param account - The Twitch account config to check
122
- * @returns true if the account has required credentials
123
- */
124
- function isAccountConfigured(account, resolvedToken) {
125
- const token = resolvedToken ?? account?.accessToken;
126
- return Boolean(account?.username && token && account?.clientId);
127
- }
128
- //#endregion
129
- export { normalizeTwitchChannel as a, normalizeToken as i, isAccountConfigured as n, resolveTwitchToken as o, missingTargetError as r, generateMessageId as t };