@openclaw/twitch 2026.5.2 → 2026.5.3-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.
Files changed (54) hide show
  1. package/dist/api.js +3 -0
  2. package/dist/channel-plugin-api.js +2 -0
  3. package/dist/index.js +18 -0
  4. package/dist/markdown-MRdI1sR7.js +306 -0
  5. package/dist/monitor-DS0YTAPB.js +333 -0
  6. package/dist/plugin-BQX9GiIn.js +878 -0
  7. package/dist/runtime-QZ5I3GlI.js +8 -0
  8. package/dist/runtime-api.js +1 -0
  9. package/dist/setup-entry.js +11 -0
  10. package/dist/setup-plugin-api.js +2 -0
  11. package/dist/setup-surface-yArVgckI.js +400 -0
  12. package/dist/twitch-CklAMZL5.js +131 -0
  13. package/package.json +20 -3
  14. package/api.ts +0 -21
  15. package/channel-plugin-api.ts +0 -1
  16. package/index.test.ts +0 -13
  17. package/index.ts +0 -16
  18. package/runtime-api.ts +0 -22
  19. package/setup-entry.ts +0 -9
  20. package/setup-plugin-api.ts +0 -3
  21. package/src/access-control.test.ts +0 -388
  22. package/src/access-control.ts +0 -173
  23. package/src/actions.test.ts +0 -74
  24. package/src/actions.ts +0 -175
  25. package/src/client-manager-registry.ts +0 -87
  26. package/src/config-schema.test.ts +0 -46
  27. package/src/config-schema.ts +0 -88
  28. package/src/config.test.ts +0 -233
  29. package/src/config.ts +0 -177
  30. package/src/monitor.ts +0 -314
  31. package/src/outbound.test.ts +0 -468
  32. package/src/outbound.ts +0 -186
  33. package/src/plugin.test.ts +0 -77
  34. package/src/plugin.ts +0 -208
  35. package/src/probe.test.ts +0 -196
  36. package/src/probe.ts +0 -130
  37. package/src/resolver.ts +0 -139
  38. package/src/runtime.ts +0 -9
  39. package/src/send.test.ts +0 -309
  40. package/src/send.ts +0 -139
  41. package/src/setup-surface.test.ts +0 -511
  42. package/src/setup-surface.ts +0 -520
  43. package/src/status.test.ts +0 -237
  44. package/src/status.ts +0 -179
  45. package/src/test-fixtures.ts +0 -30
  46. package/src/token.test.ts +0 -192
  47. package/src/token.ts +0 -93
  48. package/src/twitch-client.test.ts +0 -582
  49. package/src/twitch-client.ts +0 -276
  50. package/src/types.ts +0 -104
  51. package/src/utils/markdown.ts +0 -98
  52. package/src/utils/twitch.ts +0 -84
  53. package/test/setup.ts +0 -7
  54. package/tsconfig.json +0 -16
package/src/config.ts DELETED
@@ -1,177 +0,0 @@
1
- import {
2
- listCombinedAccountIds,
3
- normalizeAccountId,
4
- resolveNormalizedAccountEntry,
5
- } from "openclaw/plugin-sdk/account-resolution";
6
- import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
7
- import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
8
- import type { TwitchAccountConfig } from "./types.js";
9
- import { isAccountConfigured } from "./utils/twitch.js";
10
-
11
- /**
12
- * Default account ID for Twitch
13
- */
14
- export const DEFAULT_ACCOUNT_ID = "default";
15
-
16
- export type ResolvedTwitchAccountContext = {
17
- accountId: string;
18
- account: TwitchAccountConfig | null;
19
- tokenResolution: TwitchTokenResolution;
20
- configured: boolean;
21
- availableAccountIds: string[];
22
- };
23
-
24
- /**
25
- * Get account config from core config
26
- *
27
- * Handles two patterns:
28
- * 1. Simplified single-account: base-level properties create implicit "default" account
29
- * 2. Multi-account: explicit accounts object
30
- *
31
- * For "default" account, base-level properties take precedence over accounts.default
32
- * For other accounts, only the accounts object is checked
33
- */
34
- export function getAccountConfig(
35
- coreConfig: unknown,
36
- accountId: string,
37
- ): TwitchAccountConfig | null {
38
- if (!coreConfig || typeof coreConfig !== "object") {
39
- return null;
40
- }
41
-
42
- const cfg = coreConfig as OpenClawConfig;
43
- const normalizedAccountId = normalizeAccountId(accountId);
44
- const twitch = cfg.channels?.twitch;
45
- // Access accounts via unknown to handle union type (single-account vs multi-account)
46
- const twitchRaw = twitch as Record<string, unknown> | undefined;
47
- const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
48
-
49
- // For default account, check base-level config first
50
- if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
51
- const accountFromAccounts = resolveNormalizedAccountEntry(
52
- accounts,
53
- DEFAULT_ACCOUNT_ID,
54
- normalizeAccountId,
55
- );
56
-
57
- // Base-level properties that can form an implicit default account
58
- const baseLevel = {
59
- username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
60
- accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
61
- clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
62
- channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
63
- enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
64
- allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
65
- allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
66
- requireMention:
67
- typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
68
- clientSecret:
69
- typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
70
- refreshToken:
71
- typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
72
- expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
73
- obtainmentTimestamp:
74
- typeof twitchRaw?.obtainmentTimestamp === "number"
75
- ? twitchRaw.obtainmentTimestamp
76
- : undefined,
77
- };
78
-
79
- // Merge: base-level takes precedence over accounts.default
80
- const merged: Partial<TwitchAccountConfig> = {
81
- ...accountFromAccounts,
82
- ...baseLevel,
83
- } as Partial<TwitchAccountConfig>;
84
-
85
- // Only return if we have at least username
86
- if (merged.username) {
87
- return merged as TwitchAccountConfig;
88
- }
89
-
90
- // Fall through to accounts.default if no base-level username
91
- if (accountFromAccounts) {
92
- return accountFromAccounts;
93
- }
94
-
95
- return null;
96
- }
97
-
98
- // For non-default accounts, only check accounts object
99
- const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
100
- if (!account) {
101
- return null;
102
- }
103
-
104
- return account;
105
- }
106
-
107
- /**
108
- * List all configured account IDs
109
- *
110
- * Includes both explicit accounts and implicit "default" from base-level config
111
- */
112
- export function listAccountIds(cfg: OpenClawConfig): string[] {
113
- const twitch = cfg.channels?.twitch;
114
- // Access accounts via unknown to handle union type (single-account vs multi-account)
115
- const twitchRaw = twitch as Record<string, unknown> | undefined;
116
- const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
117
-
118
- // Add implicit "default" if base-level config exists and "default" not already present
119
- const hasBaseLevelConfig =
120
- twitchRaw &&
121
- (typeof twitchRaw.username === "string" ||
122
- typeof twitchRaw.accessToken === "string" ||
123
- typeof twitchRaw.channel === "string");
124
-
125
- return listCombinedAccountIds({
126
- configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
127
- normalizeAccountId(accountId),
128
- ),
129
- implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
130
- });
131
- }
132
-
133
- export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
134
- const preferredRaw =
135
- typeof cfg.channels?.twitch?.defaultAccount === "string"
136
- ? cfg.channels.twitch.defaultAccount.trim()
137
- : "";
138
- const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
139
- const ids = listAccountIds(cfg);
140
- if (preferred && ids.includes(preferred)) {
141
- return preferred;
142
- }
143
- if (ids.includes(DEFAULT_ACCOUNT_ID)) {
144
- return DEFAULT_ACCOUNT_ID;
145
- }
146
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
147
- }
148
-
149
- export function resolveTwitchAccountContext(
150
- cfg: OpenClawConfig,
151
- accountId?: string | null,
152
- ): ResolvedTwitchAccountContext {
153
- const resolvedAccountId = accountId?.trim()
154
- ? normalizeAccountId(accountId)
155
- : resolveDefaultTwitchAccountId(cfg);
156
- const account = getAccountConfig(cfg, resolvedAccountId);
157
- const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
158
- return {
159
- accountId: resolvedAccountId,
160
- account,
161
- tokenResolution,
162
- configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
163
- availableAccountIds: listAccountIds(cfg),
164
- };
165
- }
166
-
167
- export function resolveTwitchSnapshotAccountId(
168
- cfg: OpenClawConfig,
169
- account: TwitchAccountConfig,
170
- ): string {
171
- const twitch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
172
- const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
173
- const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
174
- return (
175
- Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? DEFAULT_ACCOUNT_ID
176
- );
177
- }
package/src/monitor.ts DELETED
@@ -1,314 +0,0 @@
1
- /**
2
- * Twitch message monitor - processes incoming messages and routes to agents.
3
- *
4
- * This monitor connects to the Twitch client manager, processes incoming messages,
5
- * resolves agent routes, and handles replies.
6
- */
7
-
8
- import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
9
- import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
10
- import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
11
- import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
12
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
13
- import { checkTwitchAccessControl } from "./access-control.js";
14
- import { getOrCreateClientManager } from "./client-manager-registry.js";
15
- import { getTwitchRuntime } from "./runtime.js";
16
- import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
17
- import { stripMarkdownForTwitch } from "./utils/markdown.js";
18
-
19
- export type TwitchRuntimeEnv = {
20
- log?: (message: string) => void;
21
- error?: (message: string) => void;
22
- };
23
-
24
- export type TwitchMonitorOptions = {
25
- account: TwitchAccountConfig;
26
- accountId: string;
27
- config: unknown; // OpenClawConfig
28
- runtime: TwitchRuntimeEnv;
29
- abortSignal: AbortSignal;
30
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
31
- };
32
-
33
- export type TwitchMonitorResult = {
34
- stop: () => void;
35
- };
36
-
37
- type TwitchCoreRuntime = ReturnType<typeof getTwitchRuntime>;
38
-
39
- /**
40
- * Process an incoming Twitch message and dispatch to agent.
41
- */
42
- async function processTwitchMessage(params: {
43
- message: TwitchChatMessage;
44
- account: TwitchAccountConfig;
45
- accountId: string;
46
- config: unknown;
47
- runtime: TwitchRuntimeEnv;
48
- core: TwitchCoreRuntime;
49
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
50
- }): Promise<void> {
51
- const { message, account, accountId, config, runtime, core, statusSink } = params;
52
- const cfg = config as OpenClawConfig;
53
-
54
- await core.channel.turn.run({
55
- channel: "twitch",
56
- accountId,
57
- raw: message,
58
- adapter: {
59
- ingest: (incoming) => ({
60
- id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
61
- timestamp: incoming.timestamp?.getTime(),
62
- rawText: incoming.message,
63
- textForAgent: incoming.message,
64
- textForCommands: incoming.message,
65
- raw: incoming,
66
- }),
67
- resolveTurn: (input) => {
68
- const route = core.channel.routing.resolveAgentRoute({
69
- cfg,
70
- channel: "twitch",
71
- accountId,
72
- peer: {
73
- kind: "group",
74
- id: message.channel,
75
- },
76
- });
77
- const senderId = message.userId ?? message.username;
78
- const fromLabel = message.displayName ?? message.username;
79
- const body = core.channel.reply.formatAgentEnvelope({
80
- channel: "Twitch",
81
- from: fromLabel,
82
- timestamp: input.timestamp,
83
- envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
84
- body: input.rawText,
85
- });
86
- const ctxPayload = core.channel.turn.buildContext({
87
- channel: "twitch",
88
- accountId,
89
- messageId: input.id,
90
- timestamp: input.timestamp,
91
- from: `twitch:user:${senderId}`,
92
- sender: {
93
- id: senderId,
94
- name: fromLabel,
95
- username: message.username,
96
- },
97
- conversation: {
98
- kind: "group",
99
- id: message.channel,
100
- label: message.channel,
101
- routePeer: {
102
- kind: "group",
103
- id: message.channel,
104
- },
105
- },
106
- route: {
107
- agentId: route.agentId,
108
- accountId: route.accountId,
109
- routeSessionKey: route.sessionKey,
110
- },
111
- reply: {
112
- to: `twitch:channel:${message.channel}`,
113
- originatingTo: `twitch:channel:${message.channel}`,
114
- },
115
- message: {
116
- body,
117
- rawBody: input.rawText,
118
- bodyForAgent: input.textForAgent,
119
- commandBody: input.textForCommands,
120
- envelopeFrom: fromLabel,
121
- },
122
- });
123
- const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
124
- agentId: route.agentId,
125
- });
126
- const tableMode = core.channel.text.resolveMarkdownTableMode({
127
- cfg,
128
- channel: "twitch",
129
- accountId,
130
- });
131
- const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
132
- cfg,
133
- agentId: route.agentId,
134
- channel: "twitch",
135
- accountId,
136
- });
137
- return {
138
- cfg,
139
- channel: "twitch",
140
- accountId,
141
- agentId: route.agentId,
142
- routeSessionKey: route.sessionKey,
143
- storePath,
144
- ctxPayload,
145
- recordInboundSession: core.channel.session.recordInboundSession,
146
- dispatchReplyWithBufferedBlockDispatcher:
147
- core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
148
- delivery: {
149
- deliver: async (payload) => {
150
- await deliverTwitchReply({
151
- payload,
152
- channel: message.channel,
153
- account,
154
- accountId,
155
- config,
156
- tableMode,
157
- runtime,
158
- statusSink,
159
- });
160
- },
161
- onError: (err, info) => {
162
- runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
163
- },
164
- },
165
- dispatcherOptions: replyPipeline,
166
- replyOptions: {
167
- onModelSelected,
168
- },
169
- record: {
170
- onRecordError: (err) => {
171
- runtime.error?.(`Failed updating session meta: ${String(err)}`);
172
- },
173
- },
174
- };
175
- },
176
- },
177
- });
178
- }
179
-
180
- /**
181
- * Deliver a reply to Twitch chat.
182
- */
183
- async function deliverTwitchReply(params: {
184
- payload: ReplyPayload;
185
- channel: string;
186
- account: TwitchAccountConfig;
187
- accountId: string;
188
- config: unknown;
189
- tableMode: MarkdownTableMode;
190
- runtime: TwitchRuntimeEnv;
191
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
192
- }): Promise<void> {
193
- const { payload, channel, account, accountId, config, runtime, statusSink } = params;
194
-
195
- try {
196
- const clientManager = getOrCreateClientManager(accountId, {
197
- info: (msg) => runtime.log?.(msg),
198
- warn: (msg) => runtime.log?.(msg),
199
- error: (msg) => runtime.error?.(msg),
200
- debug: (msg) => runtime.log?.(msg),
201
- });
202
-
203
- const client = await clientManager.getClient(
204
- account,
205
- config as Parameters<typeof clientManager.getClient>[1],
206
- accountId,
207
- );
208
- if (!client) {
209
- runtime.error?.(`No client available for sending reply`);
210
- return;
211
- }
212
-
213
- // Send the reply
214
- if (!payload.text) {
215
- runtime.error?.(`No text to send in reply payload`);
216
- return;
217
- }
218
-
219
- const textToSend = stripMarkdownForTwitch(payload.text);
220
-
221
- await client.say(channel, textToSend);
222
- statusSink?.({ lastOutboundAt: Date.now() });
223
- } catch (err) {
224
- runtime.error?.(`Failed to send reply: ${String(err)}`);
225
- }
226
- }
227
-
228
- /**
229
- * Main monitor provider for Twitch.
230
- *
231
- * Sets up message handlers and processes incoming messages.
232
- */
233
- export async function monitorTwitchProvider(
234
- options: TwitchMonitorOptions,
235
- ): Promise<TwitchMonitorResult> {
236
- const { account, accountId, config, runtime, abortSignal, statusSink } = options;
237
-
238
- const core = getTwitchRuntime();
239
- let stopped = false;
240
-
241
- const coreLogger = core.logging.getChildLogger({ module: "twitch" });
242
- const logVerboseMessage = (message: string) => {
243
- if (!core.logging.shouldLogVerbose()) {
244
- return;
245
- }
246
- coreLogger.debug?.(message);
247
- };
248
- const logger = {
249
- info: (msg: string) => coreLogger.info(msg),
250
- warn: (msg: string) => coreLogger.warn(msg),
251
- error: (msg: string) => coreLogger.error(msg),
252
- debug: logVerboseMessage,
253
- };
254
-
255
- const clientManager = getOrCreateClientManager(accountId, logger);
256
-
257
- try {
258
- await clientManager.getClient(
259
- account,
260
- config as Parameters<typeof clientManager.getClient>[1],
261
- accountId,
262
- );
263
- } catch (error) {
264
- const errorMsg = formatErrorMessage(error);
265
- runtime.error?.(`Failed to connect: ${errorMsg}`);
266
- throw error;
267
- }
268
-
269
- const unregisterHandler = clientManager.onMessage(account, (message) => {
270
- if (stopped) {
271
- return;
272
- }
273
-
274
- // Access control check
275
- const botUsername = normalizeLowercaseStringOrEmpty(account.username);
276
- if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) {
277
- return; // Ignore own messages
278
- }
279
-
280
- const access = checkTwitchAccessControl({
281
- message,
282
- account,
283
- botUsername,
284
- });
285
-
286
- if (!access.allowed) {
287
- return;
288
- }
289
-
290
- statusSink?.({ lastInboundAt: Date.now() });
291
-
292
- // Fire-and-forget: process message without blocking
293
- void processTwitchMessage({
294
- message,
295
- account,
296
- accountId,
297
- config,
298
- runtime,
299
- core,
300
- statusSink,
301
- }).catch((err) => {
302
- runtime.error?.(`Message processing failed: ${String(err)}`);
303
- });
304
- });
305
-
306
- const stop = () => {
307
- stopped = true;
308
- unregisterHandler();
309
- };
310
-
311
- abortSignal.addEventListener("abort", stop, { once: true });
312
-
313
- return { stop };
314
- }