@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 +89 -0
- package/dist/api.js +3 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/index.js +18 -0
- package/dist/monitor-j1GtQVBd.js +337 -0
- package/dist/plugin-BMzrFFQR.js +1285 -0
- package/dist/runtime-CwXHrWo3.js +8 -0
- package/dist/runtime-api.js +1 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-plugin-api.js +2 -0
- package/dist/setup-surface-CovnRl9R.js +527 -0
- package/package.json +20 -3
- package/api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/setup-entry.js +0 -7
- package/setup-plugin-api.js +0 -7
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
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 };
|