@kodelyth/twitch 2026.5.39 → 2026.6.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/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @klaw/twitch
2
+
3
+ Twitch channel plugin for Klaw.
4
+
5
+ ## Install (local checkout)
6
+
7
+ ```bash
8
+ klaw plugins install ./path/to/local/twitch-plugin
9
+ ```
10
+
11
+ ## Install (npm)
12
+
13
+ ```bash
14
+ klaw plugins install @klaw/twitch
15
+ ```
16
+
17
+ Onboarding: select Twitch and confirm the install prompt to fetch the plugin automatically.
18
+
19
+ ## Config
20
+
21
+ Minimal config (simplified single-account):
22
+
23
+ **⚠️ Important:** `requireMention` defaults to `true`. Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot.
24
+
25
+ ```json5
26
+ {
27
+ channels: {
28
+ twitch: {
29
+ enabled: true,
30
+ username: "klaw",
31
+ accessToken: "oauth:abc123...", // OAuth Access Token (add oauth: prefix)
32
+ clientId: "xyz789...", // Client ID from Token Generator
33
+ channel: "vevisk", // Channel to join (required)
34
+ allowFrom: ["123456789"], // (recommended) Your Twitch user ID only (Convert your twitch username to ID at https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/)
35
+ },
36
+ },
37
+ }
38
+ ```
39
+
40
+ **Access control options:**
41
+
42
+ - `requireMention: false` - Disable the default mention requirement to respond to all messages
43
+ - `allowFrom: ["your_user_id"]` - Restrict to your Twitch user ID only (find your ID at https://www.twitchangles.com/xqc or similar)
44
+ - `allowedRoles: ["moderator", "vip", "subscriber"]` - Restrict to specific roles
45
+
46
+ Multi-account config (advanced):
47
+
48
+ ```json5
49
+ {
50
+ channels: {
51
+ twitch: {
52
+ enabled: true,
53
+ accounts: {
54
+ default: {
55
+ username: "klaw",
56
+ accessToken: "oauth:abc123...",
57
+ clientId: "xyz789...",
58
+ channel: "vevisk",
59
+ },
60
+ channel2: {
61
+ username: "klaw",
62
+ accessToken: "oauth:def456...",
63
+ clientId: "uvw012...",
64
+ channel: "secondchannel",
65
+ },
66
+ },
67
+ },
68
+ },
69
+ }
70
+ ```
71
+
72
+ ## Setup
73
+
74
+ 1. Create a dedicated Twitch account for the bot, then generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
75
+ - Select **Bot Token**
76
+ - Verify scopes `chat:read` and `chat:write` are selected
77
+ - Copy the **Access Token** to `token` property
78
+ - Copy the **Client ID** to `clientId` property
79
+ 2. Start the gateway
80
+
81
+ ## Full documentation
82
+
83
+ See https://klaw.kodelyth.com/channels/twitch for:
84
+
85
+ - Token refresh setup
86
+ - Access control patterns
87
+ - Multi-account configuration
88
+ - Troubleshooting
89
+ - Capabilities & limits
package/dist/api.js ADDED
@@ -0,0 +1,3 @@
1
+ import { t as twitchPlugin } from "./plugin-BMzrFFQR.js";
2
+ import { n as setTwitchRuntime } from "./runtime-CwXHrWo3.js";
3
+ export { setTwitchRuntime, twitchPlugin };
@@ -0,0 +1,2 @@
1
+ import { t as twitchPlugin } from "./plugin-BMzrFFQR.js";
2
+ export { twitchPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { defineBundledChannelEntry } from "klaw/plugin-sdk/channel-entry-contract";
2
+ //#region extensions/twitch/index.ts
3
+ var twitch_default = defineBundledChannelEntry({
4
+ id: "twitch",
5
+ name: "Twitch",
6
+ description: "Twitch IRC chat channel plugin",
7
+ importMetaUrl: import.meta.url,
8
+ plugin: {
9
+ specifier: "./channel-plugin-api.js",
10
+ exportName: "twitchPlugin"
11
+ },
12
+ runtime: {
13
+ specifier: "./api.js",
14
+ exportName: "setTwitchRuntime"
15
+ }
16
+ });
17
+ //#endregion
18
+ export { twitch_default as default };
@@ -0,0 +1,337 @@
1
+ import { n as stripMarkdownForTwitch, r as getOrCreateClientManager } from "./plugin-BMzrFFQR.js";
2
+ import { t as getTwitchRuntime } from "./runtime-CwXHrWo3.js";
3
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
4
+ import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
5
+ import { createChannelIngressResolver, defineStableChannelIngressIdentity } from "klaw/plugin-sdk/channel-ingress-runtime";
6
+ //#region extensions/twitch/src/access-control.ts
7
+ const twitchUserIdentity = defineStableChannelIngressIdentity({
8
+ key: "sender-id",
9
+ entryIdPrefix: "twitch-user-entry"
10
+ });
11
+ const twitchRoleIdentity = defineStableChannelIngressIdentity({
12
+ key: "role-moderator",
13
+ kind: "role",
14
+ normalizeEntry: normalizeTwitchRole,
15
+ normalizeSubject: normalizeTwitchRole,
16
+ aliases: [
17
+ "owner",
18
+ "vip",
19
+ "subscriber"
20
+ ].map((role) => ({
21
+ key: `role-${role}`,
22
+ kind: "role",
23
+ normalizeEntry: () => null,
24
+ normalizeSubject: normalizeTwitchRole
25
+ })),
26
+ isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
27
+ resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`
28
+ });
29
+ async function checkTwitchAccessControl(params) {
30
+ const { message, account, botUsername } = params;
31
+ const policyKind = resolveTwitchPolicyKind(account);
32
+ const decision = (await createChannelIngressResolver({
33
+ channelId: "twitch",
34
+ accountId: "default",
35
+ identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity
36
+ }).message({
37
+ subject: policyKind === "role" ? twitchRoleSubject(message) : { stableId: message.userId },
38
+ conversation: {
39
+ kind: "group",
40
+ id: message.channel
41
+ },
42
+ event: { mayPair: false },
43
+ mentionFacts: {
44
+ canDetectMention: true,
45
+ wasMentioned: mentionsBot(message.message, botUsername)
46
+ },
47
+ dmPolicy: "open",
48
+ groupPolicy: policyKind === "open" ? "open" : "allowlist",
49
+ policy: { activation: {
50
+ requireMention: account.requireMention ?? true,
51
+ allowTextCommands: false,
52
+ order: "before-sender"
53
+ } },
54
+ groupAllowFrom: policyKind === "allowFrom" ? account.allowFrom : policyKind === "role" ? account.allowedRoles : void 0
55
+ })).ingress;
56
+ if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") return {
57
+ allowed: false,
58
+ reason: "message does not mention the bot (requireMention is enabled)"
59
+ };
60
+ if (decision.admission === "dispatch") {
61
+ if (policyKind === "allowFrom") return {
62
+ allowed: true,
63
+ matchKey: params.message.userId,
64
+ matchSource: "allowlist"
65
+ };
66
+ if (policyKind === "role") return {
67
+ allowed: true,
68
+ matchKey: params.account.allowedRoles?.join(","),
69
+ matchSource: "role"
70
+ };
71
+ return { allowed: true };
72
+ }
73
+ if (policyKind === "allowFrom") {
74
+ if (!params.message.userId) return {
75
+ allowed: false,
76
+ reason: "sender user ID not available for allowlist check"
77
+ };
78
+ return {
79
+ allowed: false,
80
+ reason: "sender is not in allowFrom allowlist"
81
+ };
82
+ }
83
+ if (policyKind === "role") return {
84
+ allowed: false,
85
+ reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`
86
+ };
87
+ return {
88
+ allowed: false,
89
+ reason: reasonForTwitchIngressDecision(decision)
90
+ };
91
+ }
92
+ function resolveTwitchPolicyKind(account) {
93
+ if (account.allowFrom !== void 0) return "allowFrom";
94
+ if (account.allowedRoles && account.allowedRoles.length > 0) return "role";
95
+ return "open";
96
+ }
97
+ function twitchRoleSubject(message) {
98
+ return {
99
+ stableId: message.isMod ? "moderator" : void 0,
100
+ aliases: {
101
+ "role-owner": message.isOwner ? "owner" : void 0,
102
+ "role-vip": message.isVip ? "vip" : void 0,
103
+ "role-subscriber": message.isSub ? "subscriber" : void 0
104
+ }
105
+ };
106
+ }
107
+ function normalizeTwitchRole(value) {
108
+ const role = normalizeLowercaseStringOrEmpty(value);
109
+ if (role === "*") return "all";
110
+ return role === "moderator" || role === "owner" || role === "vip" || role === "subscriber" || role === "all" ? role : null;
111
+ }
112
+ function reasonForTwitchIngressDecision(decision) {
113
+ switch (decision.reasonCode) {
114
+ case "activation_skipped": return "message does not mention the bot (requireMention is enabled)";
115
+ case "group_policy_empty_allowlist":
116
+ case "group_policy_not_allowlisted": return "sender is not in allowFrom allowlist";
117
+ default: return decision.reasonCode;
118
+ }
119
+ }
120
+ function mentionsBot(message, botUsername) {
121
+ const expected = normalizeLowercaseStringOrEmpty(botUsername);
122
+ const mentionRegex = /@(\w+)/g;
123
+ let match;
124
+ while ((match = mentionRegex.exec(message)) !== null) if ((match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "") === expected) return true;
125
+ return false;
126
+ }
127
+ //#endregion
128
+ //#region extensions/twitch/src/monitor.ts
129
+ /**
130
+ * Process an incoming Twitch message and dispatch to agent.
131
+ */
132
+ async function processTwitchMessage(params) {
133
+ const { message, account, accountId, config, runtime, core, statusSink } = params;
134
+ const cfg = config;
135
+ await core.channel.turn.run({
136
+ channel: "twitch",
137
+ accountId,
138
+ raw: message,
139
+ adapter: {
140
+ ingest: (incoming) => ({
141
+ id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
142
+ timestamp: incoming.timestamp?.getTime(),
143
+ rawText: incoming.message,
144
+ textForAgent: incoming.message,
145
+ textForCommands: incoming.message,
146
+ raw: incoming
147
+ }),
148
+ resolveTurn: (input) => {
149
+ const route = core.channel.routing.resolveAgentRoute({
150
+ cfg,
151
+ channel: "twitch",
152
+ accountId,
153
+ peer: {
154
+ kind: "group",
155
+ id: message.channel
156
+ }
157
+ });
158
+ const senderId = message.userId ?? message.username;
159
+ const fromLabel = message.displayName ?? message.username;
160
+ const body = core.channel.reply.formatAgentEnvelope({
161
+ channel: "Twitch",
162
+ from: fromLabel,
163
+ timestamp: input.timestamp,
164
+ envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
165
+ body: input.rawText
166
+ });
167
+ const ctxPayload = core.channel.turn.buildContext({
168
+ channel: "twitch",
169
+ accountId,
170
+ messageId: input.id,
171
+ timestamp: input.timestamp,
172
+ from: `twitch:user:${senderId}`,
173
+ sender: {
174
+ id: senderId,
175
+ name: fromLabel,
176
+ username: message.username
177
+ },
178
+ conversation: {
179
+ kind: "group",
180
+ id: message.channel,
181
+ label: message.channel,
182
+ routePeer: {
183
+ kind: "group",
184
+ id: message.channel
185
+ }
186
+ },
187
+ route: {
188
+ agentId: route.agentId,
189
+ accountId: route.accountId,
190
+ routeSessionKey: route.sessionKey
191
+ },
192
+ reply: {
193
+ to: `twitch:channel:${message.channel}`,
194
+ originatingTo: `twitch:channel:${message.channel}`
195
+ },
196
+ message: {
197
+ body,
198
+ rawBody: input.rawText,
199
+ bodyForAgent: input.textForAgent,
200
+ commandBody: input.textForCommands,
201
+ envelopeFrom: fromLabel
202
+ }
203
+ });
204
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
205
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
206
+ cfg,
207
+ channel: "twitch",
208
+ accountId
209
+ });
210
+ return {
211
+ cfg,
212
+ channel: "twitch",
213
+ accountId,
214
+ agentId: route.agentId,
215
+ routeSessionKey: route.sessionKey,
216
+ storePath,
217
+ ctxPayload,
218
+ recordInboundSession: core.channel.session.recordInboundSession,
219
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
220
+ delivery: {
221
+ durable: () => ({ to: `twitch:channel:${message.channel}` }),
222
+ deliver: async (payload) => {
223
+ return await deliverTwitchReply({
224
+ payload,
225
+ channel: message.channel,
226
+ account,
227
+ accountId,
228
+ config,
229
+ tableMode,
230
+ runtime
231
+ });
232
+ },
233
+ onDelivered: (_payload, _info, result) => {
234
+ if (result?.visibleReplySent !== false) statusSink?.({ lastOutboundAt: Date.now() });
235
+ },
236
+ onError: (err, info) => {
237
+ runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
238
+ }
239
+ },
240
+ replyPipeline: {},
241
+ record: { onRecordError: (err) => {
242
+ runtime.error?.(`Failed updating session meta: ${String(err)}`);
243
+ } }
244
+ };
245
+ }
246
+ }
247
+ });
248
+ }
249
+ /**
250
+ * Deliver a reply to Twitch chat.
251
+ */
252
+ async function deliverTwitchReply(params) {
253
+ const { payload, channel, account, accountId, config, runtime } = params;
254
+ try {
255
+ const client = await getOrCreateClientManager(accountId, {
256
+ info: (msg) => runtime.log?.(msg),
257
+ warn: (msg) => runtime.log?.(msg),
258
+ error: (msg) => runtime.error?.(msg),
259
+ debug: (msg) => runtime.log?.(msg)
260
+ }).getClient(account, config, accountId);
261
+ if (!client) {
262
+ runtime.error?.(`No client available for sending reply`);
263
+ return { visibleReplySent: false };
264
+ }
265
+ if (!payload.text) {
266
+ runtime.error?.(`No text to send in reply payload`);
267
+ return { visibleReplySent: false };
268
+ }
269
+ const textToSend = stripMarkdownForTwitch(payload.text);
270
+ await client.say(channel, textToSend);
271
+ return { visibleReplySent: true };
272
+ } catch (err) {
273
+ runtime.error?.(`Failed to send reply: ${String(err)}`);
274
+ return { visibleReplySent: false };
275
+ }
276
+ }
277
+ /**
278
+ * Main monitor provider for Twitch.
279
+ *
280
+ * Sets up message handlers and processes incoming messages.
281
+ */
282
+ async function monitorTwitchProvider(options) {
283
+ const { account, accountId, config, runtime, abortSignal, statusSink } = options;
284
+ const core = getTwitchRuntime();
285
+ let stopped = false;
286
+ const coreLogger = core.logging.getChildLogger({ module: "twitch" });
287
+ const logVerboseMessage = (message) => {
288
+ if (!core.logging.shouldLogVerbose()) return;
289
+ coreLogger.debug?.(message);
290
+ };
291
+ const clientManager = getOrCreateClientManager(accountId, {
292
+ info: (msg) => coreLogger.info(msg),
293
+ warn: (msg) => coreLogger.warn(msg),
294
+ error: (msg) => coreLogger.error(msg),
295
+ debug: logVerboseMessage
296
+ });
297
+ try {
298
+ await clientManager.getClient(account, config, accountId);
299
+ } catch (error) {
300
+ const errorMsg = formatErrorMessage(error);
301
+ runtime.error?.(`Failed to connect: ${errorMsg}`);
302
+ throw error;
303
+ }
304
+ const unregisterHandler = clientManager.onMessage(account, (message) => {
305
+ if (stopped) return;
306
+ (async () => {
307
+ const botUsername = normalizeLowercaseStringOrEmpty(account.username);
308
+ if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) return;
309
+ const access = await checkTwitchAccessControl({
310
+ message,
311
+ account,
312
+ botUsername
313
+ });
314
+ if (stopped || !access.allowed) return;
315
+ statusSink?.({ lastInboundAt: Date.now() });
316
+ await processTwitchMessage({
317
+ message,
318
+ account,
319
+ accountId,
320
+ config,
321
+ runtime,
322
+ core,
323
+ statusSink
324
+ });
325
+ })().catch((err) => {
326
+ runtime.error?.(`Message processing failed: ${String(err)}`);
327
+ });
328
+ });
329
+ const stop = () => {
330
+ stopped = true;
331
+ unregisterHandler();
332
+ };
333
+ abortSignal.addEventListener("abort", stop, { once: true });
334
+ return { stop };
335
+ }
336
+ //#endregion
337
+ export { monitorTwitchProvider };