@openclaw/twitch 2026.5.14-beta.1 → 2026.5.16-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/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-CEhzoRMh.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-CEhzoRMh.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-CEhzoRMh.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,20 +1,319 @@
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-BiDFQV9J.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";
7
5
  import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
8
- import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
6
+ import { buildPassiveProbedChannelStatusSummary, runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
9
7
  import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
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,13 +1253,18 @@ 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");
958
- await monitorTwitchProvider({
959
- account,
960
- accountId,
961
- config: ctx.cfg,
962
- runtime: ctx.runtime,
963
- abortSignal: ctx.abortSignal
1256
+ await runStoppablePassiveMonitor({
1257
+ abortSignal: ctx.abortSignal,
1258
+ start: async () => {
1259
+ const { monitorTwitchProvider } = await import("./monitor-d1yhkO1M.js");
1260
+ return monitorTwitchProvider({
1261
+ account,
1262
+ accountId,
1263
+ config: ctx.cfg,
1264
+ runtime: ctx.runtime,
1265
+ abortSignal: ctx.abortSignal
1266
+ });
1267
+ }
964
1268
  });
965
1269
  },
966
1270
  stopAccount: async (ctx) => {
@@ -978,4 +1282,4 @@ const twitchPlugin = createChatChannelPlugin({
978
1282
  }
979
1283
  });
980
1284
  //#endregion
981
- export { twitchPlugin as t };
1285
+ 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-BiDFQV9J.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
- import { formatDocsLink, normalizeAccountId as normalizeAccountId$1 } from "openclaw/plugin-sdk/setup";
6
+ import { createSetupTranslator, 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
@@ -96,6 +222,7 @@ function resolveTwitchSnapshotAccountId(cfg, account) {
96
222
  * Twitch setup wizard surface for CLI setup.
97
223
  */
98
224
  const channel = "twitch";
225
+ const t = createSetupTranslator();
99
226
  const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
100
227
  function normalizeRequestedSetupAccountId(accountId) {
101
228
  const normalized = normalizeOptionalAccountId(accountId);
@@ -142,25 +269,25 @@ function setTwitchAccount(cfg, account, accountId = resolveSetupAccountId(cfg))
142
269
  }
143
270
  async function noteTwitchSetupHelp(prompter) {
144
271
  await prompter.note([
145
- "Twitch requires a bot account with OAuth token.",
146
- "1. Create a Twitch application at https://dev.twitch.tv/console",
147
- "2. Generate a token with scopes: chat:read and chat:write",
148
- " Use https://twitchtokengenerator.com/ or https://twitchapps.com/tmi/",
149
- "3. Copy the token (starts with 'oauth:') and Client ID",
150
- "Env vars supported: OPENCLAW_TWITCH_ACCESS_TOKEN",
272
+ t("wizard.twitch.helpRequiresBot"),
273
+ t("wizard.twitch.helpCreateApp"),
274
+ t("wizard.twitch.helpGenerateToken"),
275
+ t("wizard.twitch.helpTokenTools"),
276
+ t("wizard.twitch.helpCopyToken"),
277
+ t("wizard.twitch.helpEnvVars"),
151
278
  `Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`
152
- ].join("\n"), "Twitch setup");
279
+ ].join("\n"), t("wizard.twitch.setupTitle"));
153
280
  }
154
281
  async function promptToken(prompter, account, envToken) {
155
282
  const existingToken = account?.accessToken ?? "";
156
283
  if (existingToken && !envToken) {
157
284
  if (await prompter.confirm({
158
- message: "Access token already configured. Keep it?",
285
+ message: t("wizard.twitch.accessTokenKeep"),
159
286
  initialValue: true
160
287
  })) return existingToken;
161
288
  }
162
289
  return (await prompter.text({
163
- message: "Twitch OAuth token (oauth:...)",
290
+ message: t("wizard.twitch.oauthTokenPrompt"),
164
291
  initialValue: envToken ?? "",
165
292
  validate: (value) => {
166
293
  const raw = value?.trim() ?? "";
@@ -171,38 +298,38 @@ async function promptToken(prompter, account, envToken) {
171
298
  }
172
299
  async function promptUsername(prompter, account) {
173
300
  return (await prompter.text({
174
- message: "Twitch bot username",
301
+ message: t("wizard.twitch.botUsernamePrompt"),
175
302
  initialValue: account?.username ?? "",
176
303
  validate: (value) => value?.trim() ? void 0 : "Required"
177
304
  })).trim();
178
305
  }
179
306
  async function promptClientId(prompter, account) {
180
307
  return (await prompter.text({
181
- message: "Twitch Client ID",
308
+ message: t("wizard.twitch.clientIdPrompt"),
182
309
  initialValue: account?.clientId ?? "",
183
310
  validate: (value) => value?.trim() ? void 0 : "Required"
184
311
  })).trim();
185
312
  }
186
313
  async function promptChannelName(prompter, account) {
187
314
  return (await prompter.text({
188
- message: "Channel to join",
315
+ message: t("wizard.twitch.channelJoinPrompt"),
189
316
  initialValue: account?.channel ?? "",
190
317
  validate: (value) => value?.trim() ? void 0 : "Required"
191
318
  })).trim();
192
319
  }
193
320
  async function promptRefreshTokenSetup(prompter, account) {
194
321
  if (!await prompter.confirm({
195
- message: "Enable automatic token refresh (requires client secret and refresh token)?",
322
+ message: t("wizard.twitch.refreshTokenPrompt"),
196
323
  initialValue: Boolean(account?.clientSecret && account?.refreshToken)
197
324
  })) return {};
198
325
  return {
199
326
  clientSecret: (await prompter.text({
200
- message: "Twitch Client Secret (for token refresh)",
327
+ message: t("wizard.twitch.clientSecretPrompt"),
201
328
  initialValue: account?.clientSecret ?? "",
202
329
  validate: (value) => value?.trim() ? void 0 : "Required"
203
330
  })).trim() || void 0,
204
331
  refreshToken: (await prompter.text({
205
- message: "Twitch Refresh Token",
332
+ message: t("wizard.twitch.refreshTokenInputPrompt"),
206
333
  initialValue: account?.refreshToken ?? "",
207
334
  validate: (value) => value?.trim() ? void 0 : "Required"
208
335
  })).trim() || void 0
@@ -212,7 +339,7 @@ async function configureWithEnvToken(cfg, prompter, account, envToken, forceAllo
212
339
  const resolvedAccountId = accountId.trim() ? normalizeRequestedSetupAccountId(accountId) : resolveSetupAccountId(cfg);
213
340
  if (resolvedAccountId !== "default") return null;
214
341
  if (!await prompter.confirm({
215
- message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?",
342
+ message: t("wizard.twitch.envPrompt"),
216
343
  initialValue: true
217
344
  })) return null;
218
345
  const cfgWithAccount = setTwitchAccount(cfg, {
@@ -273,7 +400,7 @@ const twitchDmPolicy = {
273
400
  const account = getAccountConfig(cfg, resolvedAccountId);
274
401
  const existingAllowFrom = account?.allowFrom ?? [];
275
402
  const allowFrom = (await prompter.text({
276
- message: "Twitch allowFrom (user IDs, one per line, recommended for security)",
403
+ message: t("wizard.twitch.allowFromPrompt"),
277
404
  placeholder: "123456789",
278
405
  initialValue: existingAllowFrom[0] || void 0
279
406
  }) ?? "").split(/[\n,;]+/g).map((s) => s.trim()).filter(Boolean);
@@ -308,17 +435,17 @@ const twitchSetupWizard = {
308
435
  resolveAccountIdForConfigure: ({ cfg, accountOverride }) => resolveSetupAccountId(cfg, accountOverride),
309
436
  resolveShouldPromptAccountIds: () => false,
310
437
  status: {
311
- configuredLabel: "configured",
312
- unconfiguredLabel: "needs username, token, and clientId",
313
- configuredHint: "configured",
314
- unconfiguredHint: "needs setup",
438
+ configuredLabel: t("wizard.channels.statusConfigured"),
439
+ unconfiguredLabel: t("wizard.channels.statusNeedsUsernameTokenClientId"),
440
+ configuredHint: t("wizard.channels.statusConfigured"),
441
+ unconfiguredHint: t("wizard.channels.statusNeedsSetup"),
315
442
  resolveConfigured: ({ cfg, accountId }) => {
316
443
  return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
317
444
  },
318
445
  resolveStatusLines: ({ cfg, accountId }) => {
319
446
  const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
320
447
  const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
321
- return [`Twitch${resolvedAccountId !== "default" ? ` (${resolvedAccountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`];
448
+ return [`Twitch${resolvedAccountId !== "default" ? ` (${resolvedAccountId})` : ""}: ${configured ? t("wizard.channels.statusConfigured") : t("wizard.channels.statusNeedsUsernameTokenClientId")}`];
322
449
  }
323
450
  },
324
451
  credentials: [],
@@ -397,4 +524,4 @@ const twitchSetupPlugin = {
397
524
  setupWizard: twitchSetupWizard
398
525
  };
399
526
  //#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 };
527
+ 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.14-beta.1",
3
+ "version": "2026.5.16-beta.1",
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.14-beta.1"
30
+ "pluginApi": ">=2026.5.16-beta.1"
31
31
  },
32
32
  "build": {
33
- "openclawVersion": "2026.5.14-beta.1"
33
+ "openclawVersion": "2026.5.16-beta.1"
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.14-beta.1"
60
+ "openclaw": ">=2026.5.16-beta.1"
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 };