@kodelyth/twitch 2026.5.39 → 2026.5.42
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/api.ts +21 -0
- package/channel-plugin-api.ts +1 -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/index.test.ts +13 -0
- package/index.ts +16 -0
- package/klaw.plugin.json +2 -219
- package/package.json +3 -3
- package/runtime-api.ts +22 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/access-control.test.ts +373 -0
- package/src/access-control.ts +195 -0
- package/src/actions.test.ts +75 -0
- package/src/actions.ts +175 -0
- package/src/client-manager-registry.ts +87 -0
- package/src/config-schema.test.ts +46 -0
- package/src/config-schema.ts +88 -0
- package/src/config.test.ts +233 -0
- package/src/config.ts +177 -0
- package/src/monitor.ts +311 -0
- package/src/outbound.test.ts +572 -0
- package/src/outbound.ts +242 -0
- package/src/plugin.lifecycle.test.ts +86 -0
- package/src/plugin.live.test.ts +120 -0
- package/src/plugin.test.ts +77 -0
- package/src/plugin.ts +220 -0
- package/src/probe.test.ts +196 -0
- package/src/probe.ts +130 -0
- package/src/resolver.ts +139 -0
- package/src/runtime.ts +9 -0
- package/src/send.test.ts +342 -0
- package/src/send.ts +191 -0
- package/src/setup-surface.test.ts +529 -0
- package/src/setup-surface.ts +526 -0
- package/src/status.test.ts +298 -0
- package/src/status.ts +179 -0
- package/src/test-fixtures.ts +30 -0
- package/src/token.test.ts +198 -0
- package/src/token.ts +93 -0
- package/src/twitch-client.test.ts +574 -0
- package/src/twitch-client.ts +276 -0
- package/src/types.ts +104 -0
- package/src/utils/markdown.ts +98 -0
- package/src/utils/twitch.ts +81 -0
- package/test/setup.ts +7 -0
- package/tsconfig.json +16 -0
- 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
|
@@ -0,0 +1,1285 @@
|
|
|
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-CovnRl9R.js";
|
|
2
|
+
import { describeAccountSnapshot } from "klaw/plugin-sdk/account-helpers";
|
|
3
|
+
import { buildChannelConfigSchema } from "klaw/plugin-sdk/channel-config-schema";
|
|
4
|
+
import { createChatChannelPlugin } from "klaw/plugin-sdk/channel-core";
|
|
5
|
+
import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from "klaw/plugin-sdk/channel-pairing";
|
|
6
|
+
import { buildPassiveProbedChannelStatusSummary, runStoppablePassiveMonitor } from "klaw/plugin-sdk/extension-shared";
|
|
7
|
+
import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "klaw/plugin-sdk/status-helpers";
|
|
8
|
+
import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
|
|
9
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
10
|
+
import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter } from "klaw/plugin-sdk/channel-message";
|
|
11
|
+
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
|
|
12
|
+
import { ChatClient, LogLevel } from "@twurple/chat";
|
|
13
|
+
import { MarkdownConfigSchema } from "klaw/plugin-sdk/channel-config-primitives";
|
|
14
|
+
import { z } from "zod";
|
|
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 KLAW_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 Klaw'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
|
|
317
|
+
//#region extensions/twitch/src/send.ts
|
|
318
|
+
/**
|
|
319
|
+
* Twitch message sending functions with dependency injection support.
|
|
320
|
+
*
|
|
321
|
+
* These functions are the primary interface for sending messages to Twitch.
|
|
322
|
+
* They support dependency injection via the `deps` parameter for testability.
|
|
323
|
+
*/
|
|
324
|
+
function createTwitchSendReceipt(params) {
|
|
325
|
+
const messageId = params.messageId.trim();
|
|
326
|
+
const conversationId = params.channel?.trim();
|
|
327
|
+
return createMessageReceiptFromOutboundResults({
|
|
328
|
+
results: params.visible === true && messageId && messageId !== "skipped" ? [{
|
|
329
|
+
channel: "twitch",
|
|
330
|
+
messageId,
|
|
331
|
+
...conversationId ? { conversationId } : {}
|
|
332
|
+
}] : [],
|
|
333
|
+
kind: "text"
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Internal send function used by the outbound adapter.
|
|
338
|
+
*
|
|
339
|
+
* This function has access to the full Klaw config and handles
|
|
340
|
+
* account resolution, markdown stripping, and actual message sending.
|
|
341
|
+
*
|
|
342
|
+
* @param channel - The channel name
|
|
343
|
+
* @param text - The message text
|
|
344
|
+
* @param cfg - Full Klaw configuration
|
|
345
|
+
* @param accountId - Account ID to use
|
|
346
|
+
* @param stripMarkdown - Whether to strip markdown (default: true)
|
|
347
|
+
* @param logger - Logger instance
|
|
348
|
+
* @returns Result with message ID and status
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* const result = await sendMessageTwitchInternal(
|
|
352
|
+
* "#mychannel",
|
|
353
|
+
* "Hello Twitch!",
|
|
354
|
+
* klawConfig,
|
|
355
|
+
* "default",
|
|
356
|
+
* true,
|
|
357
|
+
* console,
|
|
358
|
+
* );
|
|
359
|
+
*/
|
|
360
|
+
async function sendMessageTwitchInternal(channel, text, cfg, accountId, stripMarkdown = true, logger = console) {
|
|
361
|
+
const { account, configured, availableAccountIds, accountId: resolvedAccountId } = resolveTwitchAccountContext(cfg, accountId);
|
|
362
|
+
if (!account) return {
|
|
363
|
+
ok: false,
|
|
364
|
+
messageId: generateMessageId(),
|
|
365
|
+
receipt: createTwitchSendReceipt({
|
|
366
|
+
messageId: "",
|
|
367
|
+
channel,
|
|
368
|
+
visible: false
|
|
369
|
+
}),
|
|
370
|
+
error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`
|
|
371
|
+
};
|
|
372
|
+
if (!configured) return {
|
|
373
|
+
ok: false,
|
|
374
|
+
messageId: generateMessageId(),
|
|
375
|
+
receipt: createTwitchSendReceipt({
|
|
376
|
+
messageId: "",
|
|
377
|
+
channel,
|
|
378
|
+
visible: false
|
|
379
|
+
}),
|
|
380
|
+
error: `Account ${resolvedAccountId} is not properly configured. Required: username, clientId, and token (config or env for default account).`
|
|
381
|
+
};
|
|
382
|
+
const normalizedChannel = channel || account.channel;
|
|
383
|
+
if (!normalizedChannel) return {
|
|
384
|
+
ok: false,
|
|
385
|
+
messageId: generateMessageId(),
|
|
386
|
+
receipt: createTwitchSendReceipt({
|
|
387
|
+
messageId: "",
|
|
388
|
+
channel: normalizedChannel,
|
|
389
|
+
visible: false
|
|
390
|
+
}),
|
|
391
|
+
error: "No channel specified and no default channel in account config"
|
|
392
|
+
};
|
|
393
|
+
const deliveryChannel = normalizeTwitchChannel(normalizedChannel);
|
|
394
|
+
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
|
|
395
|
+
if (!cleanedText) return {
|
|
396
|
+
ok: true,
|
|
397
|
+
messageId: "skipped",
|
|
398
|
+
receipt: createTwitchSendReceipt({
|
|
399
|
+
messageId: "skipped",
|
|
400
|
+
channel: deliveryChannel,
|
|
401
|
+
visible: false
|
|
402
|
+
})
|
|
403
|
+
};
|
|
404
|
+
const clientManager = getClientManager(resolvedAccountId);
|
|
405
|
+
if (!clientManager) return {
|
|
406
|
+
ok: false,
|
|
407
|
+
messageId: generateMessageId(),
|
|
408
|
+
receipt: createTwitchSendReceipt({
|
|
409
|
+
messageId: "",
|
|
410
|
+
channel: deliveryChannel,
|
|
411
|
+
visible: false
|
|
412
|
+
}),
|
|
413
|
+
error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`
|
|
414
|
+
};
|
|
415
|
+
try {
|
|
416
|
+
const result = await clientManager.sendMessage(account, deliveryChannel, cleanedText, cfg, resolvedAccountId);
|
|
417
|
+
if (!result.ok) {
|
|
418
|
+
const messageId = result.messageId ?? generateMessageId();
|
|
419
|
+
return {
|
|
420
|
+
ok: false,
|
|
421
|
+
messageId,
|
|
422
|
+
receipt: createTwitchSendReceipt({
|
|
423
|
+
messageId,
|
|
424
|
+
channel: deliveryChannel,
|
|
425
|
+
visible: false
|
|
426
|
+
}),
|
|
427
|
+
error: result.error ?? "Send failed"
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const messageId = result.messageId ?? generateMessageId();
|
|
431
|
+
return {
|
|
432
|
+
ok: true,
|
|
433
|
+
messageId,
|
|
434
|
+
receipt: createTwitchSendReceipt({
|
|
435
|
+
messageId,
|
|
436
|
+
channel: deliveryChannel,
|
|
437
|
+
visible: true
|
|
438
|
+
})
|
|
439
|
+
};
|
|
440
|
+
} catch (error) {
|
|
441
|
+
const errorMsg = formatErrorMessage(error);
|
|
442
|
+
const messageId = generateMessageId();
|
|
443
|
+
logger.error(`Failed to send message: ${errorMsg}`);
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
messageId,
|
|
447
|
+
receipt: createTwitchSendReceipt({
|
|
448
|
+
messageId,
|
|
449
|
+
channel: deliveryChannel,
|
|
450
|
+
visible: false
|
|
451
|
+
}),
|
|
452
|
+
error: errorMsg
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region extensions/twitch/src/outbound.ts
|
|
458
|
+
/**
|
|
459
|
+
* Twitch outbound adapter for sending messages.
|
|
460
|
+
*
|
|
461
|
+
* Implements the ChannelOutboundAdapter interface for Twitch chat.
|
|
462
|
+
* Supports text and media (URL) sending with markdown stripping and chunking.
|
|
463
|
+
*/
|
|
464
|
+
/**
|
|
465
|
+
* Twitch outbound adapter.
|
|
466
|
+
*
|
|
467
|
+
* Handles sending text and media to Twitch channels with automatic
|
|
468
|
+
* markdown stripping and message chunking.
|
|
469
|
+
*/
|
|
470
|
+
const twitchOutbound = {
|
|
471
|
+
/** Direct delivery mode - messages are sent immediately */
|
|
472
|
+
deliveryMode: "direct",
|
|
473
|
+
deliveryCapabilities: { durableFinal: {
|
|
474
|
+
text: true,
|
|
475
|
+
media: true,
|
|
476
|
+
messageSendingHooks: true
|
|
477
|
+
} },
|
|
478
|
+
/** Twitch chat message limit is 500 characters */
|
|
479
|
+
textChunkLimit: 500,
|
|
480
|
+
/** Word-boundary chunker with markdown stripping */
|
|
481
|
+
chunker: chunkTextForTwitch,
|
|
482
|
+
/**
|
|
483
|
+
* Resolve target from context.
|
|
484
|
+
*
|
|
485
|
+
* Handles target resolution with allowlist support for implicit/heartbeat modes.
|
|
486
|
+
* For explicit mode, accepts any valid channel name.
|
|
487
|
+
*
|
|
488
|
+
* @param params - Resolution parameters
|
|
489
|
+
* @returns Resolved target or error
|
|
490
|
+
*/
|
|
491
|
+
resolveTarget: ({ to, allowFrom, mode }) => {
|
|
492
|
+
const trimmed = to?.trim() ?? "";
|
|
493
|
+
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
|
494
|
+
const hasWildcard = allowListRaw.includes("*");
|
|
495
|
+
const allowList = allowListRaw.filter((entry) => entry !== "*").map((entry) => normalizeTwitchChannel(entry)).filter((entry) => entry.length > 0);
|
|
496
|
+
if (trimmed) {
|
|
497
|
+
const normalizedTo = normalizeTwitchChannel(trimmed);
|
|
498
|
+
if (!normalizedTo) return {
|
|
499
|
+
ok: false,
|
|
500
|
+
error: missingTargetError("Twitch", "<channel-name>")
|
|
501
|
+
};
|
|
502
|
+
if (mode === "implicit" || mode === "heartbeat") {
|
|
503
|
+
if (hasWildcard || allowList.length === 0) return {
|
|
504
|
+
ok: true,
|
|
505
|
+
to: normalizedTo
|
|
506
|
+
};
|
|
507
|
+
if (allowList.includes(normalizedTo)) return {
|
|
508
|
+
ok: true,
|
|
509
|
+
to: normalizedTo
|
|
510
|
+
};
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
error: missingTargetError("Twitch", "<channel-name>")
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
ok: true,
|
|
518
|
+
to: normalizedTo
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
error: missingTargetError("Twitch", "<channel-name>")
|
|
524
|
+
};
|
|
525
|
+
},
|
|
526
|
+
/**
|
|
527
|
+
* Send a text message to a Twitch channel.
|
|
528
|
+
*
|
|
529
|
+
* Strips markdown if enabled, validates account configuration,
|
|
530
|
+
* and sends the message via the Twitch client.
|
|
531
|
+
*
|
|
532
|
+
* @param params - Send parameters including target, text, and config
|
|
533
|
+
* @returns Delivery result with message ID and status
|
|
534
|
+
*
|
|
535
|
+
* @example
|
|
536
|
+
* const result = await twitchOutbound.sendText({
|
|
537
|
+
* cfg: klawConfig,
|
|
538
|
+
* to: "#mychannel",
|
|
539
|
+
* text: "Hello Twitch!",
|
|
540
|
+
* accountId: "default",
|
|
541
|
+
* });
|
|
542
|
+
*/
|
|
543
|
+
sendText: async (params) => {
|
|
544
|
+
const { cfg, to, text, accountId } = params;
|
|
545
|
+
if (params.signal?.aborted) throw new Error("Outbound delivery aborted");
|
|
546
|
+
const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
|
|
547
|
+
const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
|
548
|
+
if (!account) throw new Error(`Twitch account not found: ${resolvedAccountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`);
|
|
549
|
+
const channel = to || account.channel;
|
|
550
|
+
if (!channel) throw new Error("No channel specified and no default channel in account config");
|
|
551
|
+
const result = await sendMessageTwitchInternal(normalizeTwitchChannel(channel), text, cfg, resolvedAccountId, true, console);
|
|
552
|
+
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
|
553
|
+
return {
|
|
554
|
+
channel: "twitch",
|
|
555
|
+
messageId: result.messageId,
|
|
556
|
+
receipt: result.receipt,
|
|
557
|
+
timestamp: Date.now()
|
|
558
|
+
};
|
|
559
|
+
},
|
|
560
|
+
/**
|
|
561
|
+
* Send media to a Twitch channel.
|
|
562
|
+
*
|
|
563
|
+
* Note: Twitch chat doesn't support direct media uploads.
|
|
564
|
+
* This sends the media URL as text instead.
|
|
565
|
+
*
|
|
566
|
+
* @param params - Send parameters including media URL
|
|
567
|
+
* @returns Delivery result with message ID and status
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* const result = await twitchOutbound.sendMedia({
|
|
571
|
+
* cfg: klawConfig,
|
|
572
|
+
* to: "#mychannel",
|
|
573
|
+
* text: "Check this out!",
|
|
574
|
+
* mediaUrl: "https://example.com/image.png",
|
|
575
|
+
* accountId: "default",
|
|
576
|
+
* });
|
|
577
|
+
*/
|
|
578
|
+
sendMedia: async (params) => {
|
|
579
|
+
const { text, mediaUrl } = params;
|
|
580
|
+
if (params.signal?.aborted) throw new Error("Outbound delivery aborted");
|
|
581
|
+
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
|
|
582
|
+
if (!twitchOutbound.sendText) throw new Error("sendText not implemented");
|
|
583
|
+
return twitchOutbound.sendText({
|
|
584
|
+
...params,
|
|
585
|
+
text: message
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
function toTwitchMessageSendResult(result, kind) {
|
|
590
|
+
const receipt = result.receipt ?? createMessageReceiptFromOutboundResults({
|
|
591
|
+
results: result.messageId ? [{
|
|
592
|
+
channel: "twitch",
|
|
593
|
+
messageId: result.messageId
|
|
594
|
+
}] : [],
|
|
595
|
+
kind
|
|
596
|
+
});
|
|
597
|
+
return {
|
|
598
|
+
messageId: result.messageId || receipt.primaryPlatformMessageId,
|
|
599
|
+
receipt
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const twitchMessageAdapter = defineChannelMessageAdapter({
|
|
603
|
+
id: "twitch",
|
|
604
|
+
durableFinal: { capabilities: {
|
|
605
|
+
text: true,
|
|
606
|
+
media: true,
|
|
607
|
+
messageSendingHooks: true
|
|
608
|
+
} },
|
|
609
|
+
send: {
|
|
610
|
+
text: async (ctx) => {
|
|
611
|
+
if (!twitchOutbound.sendText) throw new Error("Twitch text sending is not available.");
|
|
612
|
+
return toTwitchMessageSendResult(await twitchOutbound.sendText(ctx), "text");
|
|
613
|
+
},
|
|
614
|
+
media: async (ctx) => {
|
|
615
|
+
if (!twitchOutbound.sendMedia) throw new Error("Twitch media sending is not available.");
|
|
616
|
+
return toTwitchMessageSendResult(await twitchOutbound.sendMedia(ctx), "media");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region extensions/twitch/src/actions.ts
|
|
622
|
+
/**
|
|
623
|
+
* Twitch message actions adapter.
|
|
624
|
+
*
|
|
625
|
+
* Handles tool-based actions for Twitch, such as sending messages.
|
|
626
|
+
*/
|
|
627
|
+
/**
|
|
628
|
+
* Create a tool result with error content.
|
|
629
|
+
*/
|
|
630
|
+
function errorResponse(error) {
|
|
631
|
+
return {
|
|
632
|
+
content: [{
|
|
633
|
+
type: "text",
|
|
634
|
+
text: JSON.stringify({
|
|
635
|
+
ok: false,
|
|
636
|
+
error
|
|
637
|
+
})
|
|
638
|
+
}],
|
|
639
|
+
details: { ok: false }
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Read a string parameter from action arguments.
|
|
644
|
+
*
|
|
645
|
+
* @param args - Action arguments
|
|
646
|
+
* @param key - Parameter key
|
|
647
|
+
* @param options - Options for reading the parameter
|
|
648
|
+
* @returns The parameter value or undefined if not found
|
|
649
|
+
*/
|
|
650
|
+
function readStringParam(args, key, options = {}) {
|
|
651
|
+
const value = args[key];
|
|
652
|
+
if (value === void 0 || value === null) {
|
|
653
|
+
if (options.required) throw new Error(`Missing required parameter: ${key}`);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (typeof value === "string") return options.trim !== false ? value.trim() : value;
|
|
657
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
658
|
+
const str = String(value);
|
|
659
|
+
return options.trim !== false ? str.trim() : str;
|
|
660
|
+
}
|
|
661
|
+
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
|
|
662
|
+
}
|
|
663
|
+
/** Supported Twitch actions */
|
|
664
|
+
const TWITCH_ACTIONS = new Set(["send"]);
|
|
665
|
+
/**
|
|
666
|
+
* Twitch message actions adapter.
|
|
667
|
+
*/
|
|
668
|
+
const twitchMessageActions = {
|
|
669
|
+
/**
|
|
670
|
+
* List available actions for this channel.
|
|
671
|
+
*/
|
|
672
|
+
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
|
|
673
|
+
/**
|
|
674
|
+
* Check if an action is supported.
|
|
675
|
+
*/
|
|
676
|
+
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action),
|
|
677
|
+
/**
|
|
678
|
+
* Extract tool send parameters from action arguments.
|
|
679
|
+
*
|
|
680
|
+
* Parses and validates the "to" and "message" parameters for sending.
|
|
681
|
+
*
|
|
682
|
+
* @param params - Arguments from the tool call
|
|
683
|
+
* @returns Parsed send parameters or null if invalid
|
|
684
|
+
*
|
|
685
|
+
* @example
|
|
686
|
+
* const result = twitchMessageActions.extractToolSend!({
|
|
687
|
+
* args: { to: "#mychannel", message: "Hello!" }
|
|
688
|
+
* });
|
|
689
|
+
* // Returns: { to: "#mychannel", message: "Hello!" }
|
|
690
|
+
*/
|
|
691
|
+
extractToolSend: ({ args }) => {
|
|
692
|
+
try {
|
|
693
|
+
const to = readStringParam(args, "to", { required: true });
|
|
694
|
+
const message = readStringParam(args, "message", { required: true });
|
|
695
|
+
if (!to || !message) return null;
|
|
696
|
+
return {
|
|
697
|
+
to,
|
|
698
|
+
message
|
|
699
|
+
};
|
|
700
|
+
} catch {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
/**
|
|
705
|
+
* Handle an action execution.
|
|
706
|
+
*
|
|
707
|
+
* Processes the "send" action to send messages to Twitch.
|
|
708
|
+
*
|
|
709
|
+
* @param ctx - Action context including action type, parameters, and config
|
|
710
|
+
* @returns Tool result with content or null if action not supported
|
|
711
|
+
*
|
|
712
|
+
* @example
|
|
713
|
+
* const result = await twitchMessageActions.handleAction!({
|
|
714
|
+
* action: "send",
|
|
715
|
+
* params: { message: "Hello Twitch!", to: "#mychannel" },
|
|
716
|
+
* cfg: klawConfig,
|
|
717
|
+
* accountId: "default",
|
|
718
|
+
* });
|
|
719
|
+
*/
|
|
720
|
+
handleAction: async (ctx) => {
|
|
721
|
+
if (ctx.action !== "send") return {
|
|
722
|
+
content: [{
|
|
723
|
+
type: "text",
|
|
724
|
+
text: "Unsupported action"
|
|
725
|
+
}],
|
|
726
|
+
details: {
|
|
727
|
+
ok: false,
|
|
728
|
+
error: "Unsupported action"
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
const message = readStringParam(ctx.params, "message", { required: true });
|
|
732
|
+
const to = readStringParam(ctx.params, "to", { required: false });
|
|
733
|
+
const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
|
|
734
|
+
const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
|
|
735
|
+
if (!account) return errorResponse(`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`);
|
|
736
|
+
const targetChannel = to || account.channel;
|
|
737
|
+
if (!targetChannel) return errorResponse("No channel specified and no default channel in account config");
|
|
738
|
+
if (!twitchOutbound.sendText) return errorResponse("sendText not implemented");
|
|
739
|
+
try {
|
|
740
|
+
const result = await twitchOutbound.sendText({
|
|
741
|
+
cfg: ctx.cfg,
|
|
742
|
+
to: targetChannel,
|
|
743
|
+
text: message ?? "",
|
|
744
|
+
accountId
|
|
745
|
+
});
|
|
746
|
+
return {
|
|
747
|
+
content: [{
|
|
748
|
+
type: "text",
|
|
749
|
+
text: JSON.stringify(result)
|
|
750
|
+
}],
|
|
751
|
+
details: { ok: true }
|
|
752
|
+
};
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return errorResponse(formatErrorMessage(error));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region extensions/twitch/src/config-schema.ts
|
|
760
|
+
/**
|
|
761
|
+
* Twitch user roles that can be allowed to interact with the bot
|
|
762
|
+
*/
|
|
763
|
+
const TwitchRoleSchema = z.enum([
|
|
764
|
+
"moderator",
|
|
765
|
+
"owner",
|
|
766
|
+
"vip",
|
|
767
|
+
"subscriber",
|
|
768
|
+
"all"
|
|
769
|
+
]);
|
|
770
|
+
const TwitchAccountShape = {
|
|
771
|
+
/** Twitch username */
|
|
772
|
+
username: z.string(),
|
|
773
|
+
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
|
774
|
+
accessToken: z.string(),
|
|
775
|
+
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
|
|
776
|
+
clientId: z.string().optional(),
|
|
777
|
+
/** Channel name to join */
|
|
778
|
+
channel: z.string().min(1),
|
|
779
|
+
/** Enable this account */
|
|
780
|
+
enabled: z.boolean().optional(),
|
|
781
|
+
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
|
|
782
|
+
allowFrom: z.array(z.string()).optional(),
|
|
783
|
+
/** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
|
|
784
|
+
allowedRoles: z.array(TwitchRoleSchema).optional(),
|
|
785
|
+
/** Require @mention to trigger bot responses */
|
|
786
|
+
requireMention: z.boolean().optional(),
|
|
787
|
+
/** Outbound response prefix override for this channel/account. */
|
|
788
|
+
responsePrefix: z.string().optional(),
|
|
789
|
+
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
|
|
790
|
+
clientSecret: z.string().optional(),
|
|
791
|
+
/** Refresh token (required for automatic token refresh) */
|
|
792
|
+
refreshToken: z.string().optional(),
|
|
793
|
+
/** Token expiry time in seconds (optional, for token refresh tracking) */
|
|
794
|
+
expiresIn: z.number().nullable().optional(),
|
|
795
|
+
/** Timestamp when token was obtained (optional, for token refresh tracking) */
|
|
796
|
+
obtainmentTimestamp: z.number().optional()
|
|
797
|
+
};
|
|
798
|
+
/**
|
|
799
|
+
* Twitch account configuration schema
|
|
800
|
+
*/
|
|
801
|
+
const TwitchAccountSchema = z.object(TwitchAccountShape);
|
|
802
|
+
/**
|
|
803
|
+
* Base configuration properties shared by both single and multi-account modes
|
|
804
|
+
*/
|
|
805
|
+
const TwitchConfigBaseShape = {
|
|
806
|
+
name: z.string().optional(),
|
|
807
|
+
enabled: z.boolean().optional(),
|
|
808
|
+
markdown: MarkdownConfigSchema.optional(),
|
|
809
|
+
defaultAccount: z.string().optional()
|
|
810
|
+
};
|
|
811
|
+
/**
|
|
812
|
+
* Simplified single-account configuration schema
|
|
813
|
+
*
|
|
814
|
+
* Use this for single-account setups. Properties are at the top level,
|
|
815
|
+
* creating an implicit "default" account.
|
|
816
|
+
*/
|
|
817
|
+
const SimplifiedSchema = z.object({
|
|
818
|
+
...TwitchConfigBaseShape,
|
|
819
|
+
...TwitchAccountShape
|
|
820
|
+
});
|
|
821
|
+
/**
|
|
822
|
+
* Multi-account configuration schema
|
|
823
|
+
*
|
|
824
|
+
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
|
|
825
|
+
*/
|
|
826
|
+
const MultiAccountSchema = z.object({
|
|
827
|
+
...TwitchConfigBaseShape,
|
|
828
|
+
/** Per-account configuration (for multi-account setups) */
|
|
829
|
+
accounts: z.record(z.string(), TwitchAccountSchema)
|
|
830
|
+
}).refine((val) => Object.keys(val.accounts || {}).length > 0, { message: "accounts must contain at least one entry" });
|
|
831
|
+
/**
|
|
832
|
+
* Twitch plugin configuration schema
|
|
833
|
+
*
|
|
834
|
+
* Supports two mutually exclusive patterns:
|
|
835
|
+
* 1. Simplified single-account: username, accessToken, clientId, channel at top level
|
|
836
|
+
* 2. Multi-account: accounts object with named account configs
|
|
837
|
+
*
|
|
838
|
+
* The union ensures clear discrimination between the two modes.
|
|
839
|
+
*/
|
|
840
|
+
const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);
|
|
841
|
+
//#endregion
|
|
842
|
+
//#region extensions/twitch/src/probe.ts
|
|
843
|
+
/**
|
|
844
|
+
* Probe a Twitch account to verify the connection is working
|
|
845
|
+
*
|
|
846
|
+
* This tests the Twitch OAuth token by attempting to connect
|
|
847
|
+
* to the chat server and verify the bot's username.
|
|
848
|
+
*/
|
|
849
|
+
async function probeTwitch(account, timeoutMs) {
|
|
850
|
+
const started = Date.now();
|
|
851
|
+
if (!account.accessToken || !account.username) return {
|
|
852
|
+
ok: false,
|
|
853
|
+
error: "missing credentials (accessToken, username)",
|
|
854
|
+
username: account.username,
|
|
855
|
+
elapsedMs: Date.now() - started
|
|
856
|
+
};
|
|
857
|
+
const rawToken = normalizeToken(account.accessToken.trim());
|
|
858
|
+
let client;
|
|
859
|
+
try {
|
|
860
|
+
client = new ChatClient({ authProvider: new StaticAuthProvider(account.clientId ?? "", rawToken) });
|
|
861
|
+
const connectionPromise = new Promise((resolve, reject) => {
|
|
862
|
+
let settled = false;
|
|
863
|
+
let connectListener;
|
|
864
|
+
let disconnectListener;
|
|
865
|
+
let authFailListener;
|
|
866
|
+
const cleanup = () => {
|
|
867
|
+
if (settled) return;
|
|
868
|
+
settled = true;
|
|
869
|
+
connectListener?.unbind();
|
|
870
|
+
disconnectListener?.unbind();
|
|
871
|
+
authFailListener?.unbind();
|
|
872
|
+
};
|
|
873
|
+
connectListener = client?.onConnect(() => {
|
|
874
|
+
cleanup();
|
|
875
|
+
resolve();
|
|
876
|
+
});
|
|
877
|
+
disconnectListener = client?.onDisconnect((_manually, reason) => {
|
|
878
|
+
cleanup();
|
|
879
|
+
reject(reason || /* @__PURE__ */ new Error("Disconnected"));
|
|
880
|
+
});
|
|
881
|
+
authFailListener = client?.onAuthenticationFailure(() => {
|
|
882
|
+
cleanup();
|
|
883
|
+
reject(/* @__PURE__ */ new Error("Authentication failed"));
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
let timeoutHandle;
|
|
887
|
+
const timeout = new Promise((_, reject) => {
|
|
888
|
+
timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
889
|
+
});
|
|
890
|
+
client.connect();
|
|
891
|
+
try {
|
|
892
|
+
await Promise.race([connectionPromise, timeout]);
|
|
893
|
+
} finally {
|
|
894
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
895
|
+
}
|
|
896
|
+
client.quit();
|
|
897
|
+
client = void 0;
|
|
898
|
+
return {
|
|
899
|
+
ok: true,
|
|
900
|
+
connected: true,
|
|
901
|
+
username: account.username,
|
|
902
|
+
channel: account.channel,
|
|
903
|
+
elapsedMs: Date.now() - started
|
|
904
|
+
};
|
|
905
|
+
} catch (error) {
|
|
906
|
+
return {
|
|
907
|
+
ok: false,
|
|
908
|
+
error: formatErrorMessage(error),
|
|
909
|
+
username: account.username,
|
|
910
|
+
channel: account.channel,
|
|
911
|
+
elapsedMs: Date.now() - started
|
|
912
|
+
};
|
|
913
|
+
} finally {
|
|
914
|
+
if (client) try {
|
|
915
|
+
client.quit();
|
|
916
|
+
} catch {}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
//#endregion
|
|
920
|
+
//#region extensions/twitch/src/resolver.ts
|
|
921
|
+
/**
|
|
922
|
+
* Twitch resolver adapter for channel/user name resolution.
|
|
923
|
+
*
|
|
924
|
+
* This module implements the ChannelResolverAdapter interface to resolve
|
|
925
|
+
* Twitch usernames to user IDs via the Twitch Helix API.
|
|
926
|
+
*/
|
|
927
|
+
/**
|
|
928
|
+
* Normalize a Twitch username - strip @ prefix and convert to lowercase
|
|
929
|
+
*/
|
|
930
|
+
function normalizeUsername(input) {
|
|
931
|
+
const trimmed = input.trim();
|
|
932
|
+
if (trimmed.startsWith("@")) return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
|
|
933
|
+
return normalizeLowercaseStringOrEmpty(trimmed);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Create a logger that includes the Twitch prefix
|
|
937
|
+
*/
|
|
938
|
+
function createLogger(logger) {
|
|
939
|
+
return {
|
|
940
|
+
info: (msg) => logger?.info(msg),
|
|
941
|
+
warn: (msg) => logger?.warn(msg),
|
|
942
|
+
error: (msg) => logger?.error(msg),
|
|
943
|
+
debug: (msg) => logger?.debug?.(msg) ?? (() => {})
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Resolve Twitch usernames to user IDs via the Helix API
|
|
948
|
+
*
|
|
949
|
+
* @param inputs - Array of usernames or user IDs to resolve
|
|
950
|
+
* @param account - Twitch account configuration with auth credentials
|
|
951
|
+
* @param kind - Type of target to resolve ("user" or "group")
|
|
952
|
+
* @param logger - Optional logger
|
|
953
|
+
* @returns Promise resolving to array of ChannelResolveResult
|
|
954
|
+
*/
|
|
955
|
+
async function resolveTwitchTargets(inputs, account, _kind, logger) {
|
|
956
|
+
const log = createLogger(logger);
|
|
957
|
+
if (!account.clientId || !account.accessToken) {
|
|
958
|
+
log.error("Missing Twitch client ID or accessToken");
|
|
959
|
+
return inputs.map((input) => ({
|
|
960
|
+
input,
|
|
961
|
+
resolved: false,
|
|
962
|
+
note: "missing Twitch credentials"
|
|
963
|
+
}));
|
|
964
|
+
}
|
|
965
|
+
const normalizedToken = normalizeToken(account.accessToken);
|
|
966
|
+
const apiClient = new ApiClient({ authProvider: new StaticAuthProvider(account.clientId, normalizedToken) });
|
|
967
|
+
const results = [];
|
|
968
|
+
for (const input of inputs) {
|
|
969
|
+
const normalized = normalizeUsername(input);
|
|
970
|
+
if (!normalized) {
|
|
971
|
+
results.push({
|
|
972
|
+
input,
|
|
973
|
+
resolved: false,
|
|
974
|
+
note: "empty input"
|
|
975
|
+
});
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const looksLikeUserId = /^\d+$/.test(normalized);
|
|
979
|
+
try {
|
|
980
|
+
if (looksLikeUserId) {
|
|
981
|
+
const user = await apiClient.users.getUserById(normalized);
|
|
982
|
+
if (user) {
|
|
983
|
+
results.push({
|
|
984
|
+
input,
|
|
985
|
+
resolved: true,
|
|
986
|
+
id: user.id,
|
|
987
|
+
name: user.name
|
|
988
|
+
});
|
|
989
|
+
log.debug?.(`Resolved user ID ${normalized} -> ${user.name}`);
|
|
990
|
+
} else {
|
|
991
|
+
results.push({
|
|
992
|
+
input,
|
|
993
|
+
resolved: false,
|
|
994
|
+
note: "user ID not found"
|
|
995
|
+
});
|
|
996
|
+
log.warn(`User ID ${normalized} not found`);
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
const user = await apiClient.users.getUserByName(normalized);
|
|
1000
|
+
if (user) {
|
|
1001
|
+
results.push({
|
|
1002
|
+
input,
|
|
1003
|
+
resolved: true,
|
|
1004
|
+
id: user.id,
|
|
1005
|
+
name: user.name,
|
|
1006
|
+
note: user.displayName !== user.name ? `display: ${user.displayName}` : void 0
|
|
1007
|
+
});
|
|
1008
|
+
log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
|
|
1009
|
+
} else {
|
|
1010
|
+
results.push({
|
|
1011
|
+
input,
|
|
1012
|
+
resolved: false,
|
|
1013
|
+
note: "username not found"
|
|
1014
|
+
});
|
|
1015
|
+
log.warn(`Username ${normalized} not found`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
const errorMessage = formatErrorMessage(error);
|
|
1020
|
+
results.push({
|
|
1021
|
+
input,
|
|
1022
|
+
resolved: false,
|
|
1023
|
+
note: `API error: ${errorMessage}`
|
|
1024
|
+
});
|
|
1025
|
+
log.error(`Failed to resolve ${input}: ${errorMessage}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return results;
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region extensions/twitch/src/status.ts
|
|
1032
|
+
/**
|
|
1033
|
+
* Collect status issues for Twitch accounts.
|
|
1034
|
+
*
|
|
1035
|
+
* Analyzes account snapshots and detects configuration problems,
|
|
1036
|
+
* authentication issues, and other potential problems.
|
|
1037
|
+
*
|
|
1038
|
+
* @param accounts - Array of account snapshots to analyze
|
|
1039
|
+
* @param getCfg - Optional function to get full config for additional checks
|
|
1040
|
+
* @returns Array of detected status issues
|
|
1041
|
+
*
|
|
1042
|
+
* @example
|
|
1043
|
+
* const issues = collectTwitchStatusIssues(accountSnapshots);
|
|
1044
|
+
* if (issues.length > 0) {
|
|
1045
|
+
* console.warn("Twitch configuration issues detected:");
|
|
1046
|
+
* issues.forEach(issue => console.warn(`- ${issue.message}`));
|
|
1047
|
+
* }
|
|
1048
|
+
*/
|
|
1049
|
+
function collectTwitchStatusIssues(accounts, getCfg) {
|
|
1050
|
+
const issues = [];
|
|
1051
|
+
for (const entry of accounts) {
|
|
1052
|
+
const accountId = entry.accountId;
|
|
1053
|
+
if (!accountId) continue;
|
|
1054
|
+
let account = null;
|
|
1055
|
+
let cfg;
|
|
1056
|
+
if (getCfg) try {
|
|
1057
|
+
cfg = getCfg();
|
|
1058
|
+
account = getAccountConfig(cfg, accountId);
|
|
1059
|
+
} catch {}
|
|
1060
|
+
if (!entry.configured) {
|
|
1061
|
+
issues.push({
|
|
1062
|
+
channel: "twitch",
|
|
1063
|
+
accountId,
|
|
1064
|
+
kind: "config",
|
|
1065
|
+
message: "Twitch account is not properly configured",
|
|
1066
|
+
fix: "Add required fields: username, accessToken, and clientId to your account configuration"
|
|
1067
|
+
});
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (entry.enabled === false) {
|
|
1071
|
+
issues.push({
|
|
1072
|
+
channel: "twitch",
|
|
1073
|
+
accountId,
|
|
1074
|
+
kind: "config",
|
|
1075
|
+
message: "Twitch account is disabled",
|
|
1076
|
+
fix: "Set enabled: true in your account configuration to enable this account"
|
|
1077
|
+
});
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (account && account.username && account.accessToken && !account.clientId) issues.push({
|
|
1081
|
+
channel: "twitch",
|
|
1082
|
+
accountId,
|
|
1083
|
+
kind: "config",
|
|
1084
|
+
message: "Twitch client ID is required",
|
|
1085
|
+
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)"
|
|
1086
|
+
});
|
|
1087
|
+
const tokenResolution = cfg ? resolveTwitchToken(cfg, { accountId }) : {
|
|
1088
|
+
token: "",
|
|
1089
|
+
source: "none"
|
|
1090
|
+
};
|
|
1091
|
+
if (account && isAccountConfigured(account, tokenResolution.token)) {
|
|
1092
|
+
if (account.accessToken?.startsWith("oauth:")) issues.push({
|
|
1093
|
+
channel: "twitch",
|
|
1094
|
+
accountId,
|
|
1095
|
+
kind: "config",
|
|
1096
|
+
message: "Token contains 'oauth:' prefix (will be stripped)",
|
|
1097
|
+
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically)."
|
|
1098
|
+
});
|
|
1099
|
+
if (account.clientSecret && !account.refreshToken) issues.push({
|
|
1100
|
+
channel: "twitch",
|
|
1101
|
+
accountId,
|
|
1102
|
+
kind: "config",
|
|
1103
|
+
message: "clientSecret provided without refreshToken",
|
|
1104
|
+
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed."
|
|
1105
|
+
});
|
|
1106
|
+
if (account.allowFrom && account.allowFrom.length === 0) issues.push({
|
|
1107
|
+
channel: "twitch",
|
|
1108
|
+
accountId,
|
|
1109
|
+
kind: "config",
|
|
1110
|
+
message: "allowFrom is configured but empty",
|
|
1111
|
+
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead."
|
|
1112
|
+
});
|
|
1113
|
+
if (account.allowedRoles?.includes("all") && account.allowFrom && account.allowFrom.length > 0) issues.push({
|
|
1114
|
+
channel: "twitch",
|
|
1115
|
+
accountId,
|
|
1116
|
+
kind: "intent",
|
|
1117
|
+
message: "allowedRoles is set to 'all' but allowFrom is also configured",
|
|
1118
|
+
fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles."
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
if (entry.lastError) issues.push({
|
|
1122
|
+
channel: "twitch",
|
|
1123
|
+
accountId,
|
|
1124
|
+
kind: "runtime",
|
|
1125
|
+
message: `Last error: ${entry.lastError}`,
|
|
1126
|
+
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes."
|
|
1127
|
+
});
|
|
1128
|
+
if (entry.configured && !entry.running && !entry.lastStartAt && !entry.lastInboundAt && !entry.lastOutboundAt) issues.push({
|
|
1129
|
+
channel: "twitch",
|
|
1130
|
+
accountId,
|
|
1131
|
+
kind: "runtime",
|
|
1132
|
+
message: "Account has never connected successfully",
|
|
1133
|
+
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors."
|
|
1134
|
+
});
|
|
1135
|
+
if (entry.running && entry.lastStartAt) {
|
|
1136
|
+
const daysSinceStart = (Date.now() - entry.lastStartAt) / (1e3 * 60 * 60 * 24);
|
|
1137
|
+
if (daysSinceStart > 7) issues.push({
|
|
1138
|
+
channel: "twitch",
|
|
1139
|
+
accountId,
|
|
1140
|
+
kind: "runtime",
|
|
1141
|
+
message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
|
|
1142
|
+
fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods."
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return issues;
|
|
1147
|
+
}
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region extensions/twitch/src/plugin.ts
|
|
1150
|
+
/**
|
|
1151
|
+
* Twitch channel plugin for Klaw.
|
|
1152
|
+
*
|
|
1153
|
+
* Main plugin export combining all adapters (outbound, actions, status, gateway).
|
|
1154
|
+
* This is the primary entry point for the Twitch channel integration.
|
|
1155
|
+
*/
|
|
1156
|
+
/**
|
|
1157
|
+
* Twitch channel plugin.
|
|
1158
|
+
*
|
|
1159
|
+
* Implements the ChannelPlugin interface to provide Twitch chat integration
|
|
1160
|
+
* for Klaw. Supports message sending, receiving, access control, and
|
|
1161
|
+
* status monitoring.
|
|
1162
|
+
*/
|
|
1163
|
+
const twitchPlugin = createChatChannelPlugin({
|
|
1164
|
+
pairing: {
|
|
1165
|
+
idLabel: "twitchUserId",
|
|
1166
|
+
normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
|
|
1167
|
+
notifyApproval: createLoggedPairingApprovalNotifier(({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`, console.warn)
|
|
1168
|
+
},
|
|
1169
|
+
outbound: twitchOutbound,
|
|
1170
|
+
base: {
|
|
1171
|
+
id: "twitch",
|
|
1172
|
+
meta: {
|
|
1173
|
+
id: "twitch",
|
|
1174
|
+
label: "Twitch",
|
|
1175
|
+
selectionLabel: "Twitch (Chat)",
|
|
1176
|
+
docsPath: "/channels/twitch",
|
|
1177
|
+
blurb: "Twitch chat integration",
|
|
1178
|
+
aliases: ["twitch-chat"]
|
|
1179
|
+
},
|
|
1180
|
+
setup: twitchSetupAdapter,
|
|
1181
|
+
setupWizard: twitchSetupWizard,
|
|
1182
|
+
capabilities: { chatTypes: ["group"] },
|
|
1183
|
+
message: twitchMessageAdapter,
|
|
1184
|
+
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
|
|
1185
|
+
config: {
|
|
1186
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
1187
|
+
resolveAccount: (cfg, accountId) => {
|
|
1188
|
+
const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
|
|
1189
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
1190
|
+
if (!account) return {
|
|
1191
|
+
accountId: resolvedAccountId,
|
|
1192
|
+
channel: "",
|
|
1193
|
+
username: "",
|
|
1194
|
+
accessToken: "",
|
|
1195
|
+
clientId: "",
|
|
1196
|
+
enabled: false
|
|
1197
|
+
};
|
|
1198
|
+
return {
|
|
1199
|
+
accountId: resolvedAccountId,
|
|
1200
|
+
...account
|
|
1201
|
+
};
|
|
1202
|
+
},
|
|
1203
|
+
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
|
|
1204
|
+
isConfigured: (_account, cfg) => resolveTwitchAccountContext(cfg).configured,
|
|
1205
|
+
isEnabled: (account) => account?.enabled !== false,
|
|
1206
|
+
describeAccount: (account) => account ? describeAccountSnapshot({
|
|
1207
|
+
account,
|
|
1208
|
+
configured: isAccountConfigured(account, account.accessToken)
|
|
1209
|
+
}) : {
|
|
1210
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
1211
|
+
enabled: false,
|
|
1212
|
+
configured: false
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
actions: twitchMessageActions,
|
|
1216
|
+
resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => {
|
|
1217
|
+
const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
|
|
1218
|
+
if (!account) return inputs.map((input) => ({
|
|
1219
|
+
input,
|
|
1220
|
+
resolved: false,
|
|
1221
|
+
note: "account not configured"
|
|
1222
|
+
}));
|
|
1223
|
+
return await resolveTwitchTargets(inputs, account, kind, {
|
|
1224
|
+
info: (msg) => runtime.log(msg),
|
|
1225
|
+
warn: (msg) => runtime.log(msg),
|
|
1226
|
+
error: (msg) => runtime.error(msg),
|
|
1227
|
+
debug: (msg) => runtime.log(msg)
|
|
1228
|
+
});
|
|
1229
|
+
} },
|
|
1230
|
+
status: createComputedAccountStatusAdapter({
|
|
1231
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
1232
|
+
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
|
|
1233
|
+
probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
|
|
1234
|
+
collectStatusIssues: collectTwitchStatusIssues,
|
|
1235
|
+
resolveAccountSnapshot: ({ account, cfg }) => {
|
|
1236
|
+
const resolvedAccountId = account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
|
|
1237
|
+
const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
|
1238
|
+
return {
|
|
1239
|
+
accountId: resolvedAccountId,
|
|
1240
|
+
enabled: account.enabled !== false,
|
|
1241
|
+
configured
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
}),
|
|
1245
|
+
gateway: {
|
|
1246
|
+
startAccount: async (ctx) => {
|
|
1247
|
+
const account = ctx.account;
|
|
1248
|
+
const accountId = ctx.accountId;
|
|
1249
|
+
ctx.setStatus?.({
|
|
1250
|
+
accountId,
|
|
1251
|
+
running: true,
|
|
1252
|
+
lastStartAt: Date.now(),
|
|
1253
|
+
lastError: null
|
|
1254
|
+
});
|
|
1255
|
+
ctx.log?.info(`Starting Twitch connection for ${account.username}`);
|
|
1256
|
+
await runStoppablePassiveMonitor({
|
|
1257
|
+
abortSignal: ctx.abortSignal,
|
|
1258
|
+
start: async () => {
|
|
1259
|
+
const { monitorTwitchProvider } = await import("./monitor-j1GtQVBd.js");
|
|
1260
|
+
return monitorTwitchProvider({
|
|
1261
|
+
account,
|
|
1262
|
+
accountId,
|
|
1263
|
+
config: ctx.cfg,
|
|
1264
|
+
runtime: ctx.runtime,
|
|
1265
|
+
abortSignal: ctx.abortSignal
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
},
|
|
1270
|
+
stopAccount: async (ctx) => {
|
|
1271
|
+
const account = ctx.account;
|
|
1272
|
+
const accountId = ctx.accountId;
|
|
1273
|
+
await removeClientManager(accountId);
|
|
1274
|
+
ctx.setStatus?.({
|
|
1275
|
+
accountId,
|
|
1276
|
+
running: false,
|
|
1277
|
+
lastStopAt: Date.now()
|
|
1278
|
+
});
|
|
1279
|
+
ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
//#endregion
|
|
1285
|
+
export { stripMarkdownForTwitch as n, getOrCreateClientManager as r, twitchPlugin as t };
|