@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,8 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "klaw/plugin-sdk/runtime-store";
|
|
2
|
+
//#region extensions/twitch/src/runtime.ts
|
|
3
|
+
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = createPluginRuntimeStore({
|
|
4
|
+
pluginId: "twitch",
|
|
5
|
+
errorMessage: "Twitch runtime not initialized"
|
|
6
|
+
});
|
|
7
|
+
//#endregion
|
|
8
|
+
export { setTwitchRuntime as n, getTwitchRuntime as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineBundledChannelSetupEntry } from "klaw/plugin-sdk/channel-entry-contract";
|
|
2
|
+
//#region extensions/twitch/setup-entry.ts
|
|
3
|
+
var setup_entry_default = defineBundledChannelSetupEntry({
|
|
4
|
+
importMetaUrl: import.meta.url,
|
|
5
|
+
plugin: {
|
|
6
|
+
specifier: "./setup-plugin-api.js",
|
|
7
|
+
exportName: "twitchSetupPlugin"
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
//#endregion
|
|
11
|
+
export { setup_entry_default as default };
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, listCombinedAccountIds, normalizeAccountId, resolveNormalizedAccountEntry } from "klaw/plugin-sdk/account-resolution";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
4
|
+
import { normalizeOptionalAccountId } from "klaw/plugin-sdk/account-id";
|
|
5
|
+
import { getChatChannelMeta } from "klaw/plugin-sdk/core";
|
|
6
|
+
import { createSetupTranslator, formatDocsLink, normalizeAccountId as normalizeAccountId$1 } from "klaw/plugin-sdk/setup";
|
|
7
|
+
//#region extensions/twitch/src/token.ts
|
|
8
|
+
/**
|
|
9
|
+
* Twitch access token resolution with environment variable support.
|
|
10
|
+
*
|
|
11
|
+
* Supports reading Twitch OAuth access tokens from config or environment variable.
|
|
12
|
+
* The KLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
|
|
13
|
+
*
|
|
14
|
+
* Token resolution priority:
|
|
15
|
+
* 1. Account access token from merged config (accounts.{id} or base-level for default)
|
|
16
|
+
* 2. Environment variable: KLAW_TWITCH_ACCESS_TOKEN (default account only)
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Normalize a Twitch OAuth token - ensure it has the oauth: prefix
|
|
20
|
+
*/
|
|
21
|
+
function normalizeTwitchToken(raw) {
|
|
22
|
+
if (!raw) return;
|
|
23
|
+
const trimmed = raw.trim();
|
|
24
|
+
if (!trimmed) return;
|
|
25
|
+
return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve Twitch access token from config or environment variable.
|
|
29
|
+
*
|
|
30
|
+
* Priority:
|
|
31
|
+
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
|
|
32
|
+
* 2. Environment variable: KLAW_TWITCH_ACCESS_TOKEN (default account only)
|
|
33
|
+
*
|
|
34
|
+
* The getAccountConfig function handles merging base-level config with accounts.default,
|
|
35
|
+
* so this logic works for both simplified and multi-account patterns.
|
|
36
|
+
*
|
|
37
|
+
* @param cfg - Klaw config
|
|
38
|
+
* @param opts - Options including accountId and optional envToken override
|
|
39
|
+
* @returns Token resolution with source
|
|
40
|
+
*/
|
|
41
|
+
function resolveTwitchToken(cfg, opts = {}) {
|
|
42
|
+
const accountId = normalizeAccountId(opts.accountId);
|
|
43
|
+
const twitchCfg = cfg?.channels?.twitch;
|
|
44
|
+
const accounts = twitchCfg?.accounts;
|
|
45
|
+
const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
|
|
46
|
+
let token;
|
|
47
|
+
if (accountId === DEFAULT_ACCOUNT_ID) token = normalizeTwitchToken((typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : void 0) || accountCfg?.accessToken);
|
|
48
|
+
else token = normalizeTwitchToken(accountCfg?.accessToken);
|
|
49
|
+
if (token) return {
|
|
50
|
+
token,
|
|
51
|
+
source: "config"
|
|
52
|
+
};
|
|
53
|
+
const envToken = accountId === DEFAULT_ACCOUNT_ID ? normalizeTwitchToken(opts.envToken ?? process.env.KLAW_TWITCH_ACCESS_TOKEN) : void 0;
|
|
54
|
+
if (envToken) return {
|
|
55
|
+
token: envToken,
|
|
56
|
+
source: "env"
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
token: "",
|
|
60
|
+
source: "none"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region extensions/twitch/src/utils/twitch.ts
|
|
65
|
+
/**
|
|
66
|
+
* Twitch-specific utility functions
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Normalize Twitch channel names.
|
|
70
|
+
*
|
|
71
|
+
* Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
|
|
72
|
+
* Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
|
|
73
|
+
*
|
|
74
|
+
* @param channel - The channel name to normalize
|
|
75
|
+
* @returns Normalized channel name
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
|
|
79
|
+
* normalizeTwitchChannel("MyChannel") // "mychannel"
|
|
80
|
+
*/
|
|
81
|
+
function normalizeTwitchChannel(channel) {
|
|
82
|
+
const trimmed = normalizeLowercaseStringOrEmpty(channel);
|
|
83
|
+
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Create a standardized error message for missing target.
|
|
87
|
+
*
|
|
88
|
+
* @param provider - The provider name (e.g., "Twitch")
|
|
89
|
+
* @param hint - Optional hint for how to fix the issue
|
|
90
|
+
* @returns Error object with descriptive message
|
|
91
|
+
*/
|
|
92
|
+
function missingTargetError(provider, hint) {
|
|
93
|
+
return /* @__PURE__ */ new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Generate a unique message ID for Twitch messages.
|
|
97
|
+
*
|
|
98
|
+
* Twurple's say() doesn't return the message ID, so we generate one
|
|
99
|
+
* for tracking purposes.
|
|
100
|
+
*
|
|
101
|
+
* @returns A unique message ID
|
|
102
|
+
*/
|
|
103
|
+
function generateMessageId() {
|
|
104
|
+
return `${Date.now()}-${randomUUID()}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Normalize OAuth token by removing the "oauth:" prefix if present.
|
|
108
|
+
*
|
|
109
|
+
* Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
|
|
110
|
+
*
|
|
111
|
+
* @param token - The OAuth token to normalize
|
|
112
|
+
* @returns Normalized token without "oauth:" prefix
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* normalizeToken("oauth:abc123") // "abc123"
|
|
116
|
+
* normalizeToken("abc123") // "abc123"
|
|
117
|
+
*/
|
|
118
|
+
function normalizeToken(token) {
|
|
119
|
+
return token.startsWith("oauth:") ? token.slice(6) : token;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Check if an account is properly configured with required credentials.
|
|
123
|
+
*
|
|
124
|
+
* @param account - The Twitch account config to check
|
|
125
|
+
* @returns true if the account has required credentials
|
|
126
|
+
*/
|
|
127
|
+
function isAccountConfigured(account, resolvedToken) {
|
|
128
|
+
const token = resolvedToken ?? account?.accessToken;
|
|
129
|
+
return Boolean(account?.username && token && account?.clientId);
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region extensions/twitch/src/config.ts
|
|
133
|
+
/**
|
|
134
|
+
* Default account ID for Twitch
|
|
135
|
+
*/
|
|
136
|
+
const DEFAULT_ACCOUNT_ID$1 = "default";
|
|
137
|
+
/**
|
|
138
|
+
* Get account config from core config
|
|
139
|
+
*
|
|
140
|
+
* Handles two patterns:
|
|
141
|
+
* 1. Simplified single-account: base-level properties create implicit "default" account
|
|
142
|
+
* 2. Multi-account: explicit accounts object
|
|
143
|
+
*
|
|
144
|
+
* For "default" account, base-level properties take precedence over accounts.default
|
|
145
|
+
* For other accounts, only the accounts object is checked
|
|
146
|
+
*/
|
|
147
|
+
function getAccountConfig(coreConfig, accountId) {
|
|
148
|
+
if (!coreConfig || typeof coreConfig !== "object") return null;
|
|
149
|
+
const cfg = coreConfig;
|
|
150
|
+
const normalizedAccountId = normalizeAccountId(accountId);
|
|
151
|
+
const twitchRaw = cfg.channels?.twitch;
|
|
152
|
+
const accounts = twitchRaw?.accounts;
|
|
153
|
+
if (normalizedAccountId === "default") {
|
|
154
|
+
const accountFromAccounts = resolveNormalizedAccountEntry(accounts, DEFAULT_ACCOUNT_ID$1, normalizeAccountId);
|
|
155
|
+
const baseLevel = {
|
|
156
|
+
username: typeof twitchRaw?.username === "string" ? twitchRaw.username : void 0,
|
|
157
|
+
accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : void 0,
|
|
158
|
+
clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : void 0,
|
|
159
|
+
channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : void 0,
|
|
160
|
+
enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : void 0,
|
|
161
|
+
allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : void 0,
|
|
162
|
+
allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : void 0,
|
|
163
|
+
requireMention: typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : void 0,
|
|
164
|
+
clientSecret: typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : void 0,
|
|
165
|
+
refreshToken: typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : void 0,
|
|
166
|
+
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : void 0,
|
|
167
|
+
obtainmentTimestamp: typeof twitchRaw?.obtainmentTimestamp === "number" ? twitchRaw.obtainmentTimestamp : void 0
|
|
168
|
+
};
|
|
169
|
+
const merged = {
|
|
170
|
+
...accountFromAccounts,
|
|
171
|
+
...baseLevel
|
|
172
|
+
};
|
|
173
|
+
if (merged.username) return merged;
|
|
174
|
+
if (accountFromAccounts) return accountFromAccounts;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
|
|
178
|
+
if (!account) return null;
|
|
179
|
+
return account;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* List all configured account IDs
|
|
183
|
+
*
|
|
184
|
+
* Includes both explicit accounts and implicit "default" from base-level config
|
|
185
|
+
*/
|
|
186
|
+
function listAccountIds(cfg) {
|
|
187
|
+
const twitchRaw = cfg.channels?.twitch;
|
|
188
|
+
const accountMap = twitchRaw?.accounts;
|
|
189
|
+
const hasBaseLevelConfig = twitchRaw && (typeof twitchRaw.username === "string" || typeof twitchRaw.accessToken === "string" || typeof twitchRaw.channel === "string");
|
|
190
|
+
return listCombinedAccountIds({
|
|
191
|
+
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) => normalizeAccountId(accountId)),
|
|
192
|
+
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID$1 : void 0
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function resolveDefaultTwitchAccountId(cfg) {
|
|
196
|
+
const preferredRaw = typeof cfg.channels?.twitch?.defaultAccount === "string" ? cfg.channels.twitch.defaultAccount.trim() : "";
|
|
197
|
+
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
|
|
198
|
+
const ids = listAccountIds(cfg);
|
|
199
|
+
if (preferred && ids.includes(preferred)) return preferred;
|
|
200
|
+
if (ids.includes("default")) return DEFAULT_ACCOUNT_ID$1;
|
|
201
|
+
return ids[0] ?? "default";
|
|
202
|
+
}
|
|
203
|
+
function resolveTwitchAccountContext(cfg, accountId) {
|
|
204
|
+
const resolvedAccountId = accountId?.trim() ? normalizeAccountId(accountId) : resolveDefaultTwitchAccountId(cfg);
|
|
205
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
206
|
+
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
|
207
|
+
return {
|
|
208
|
+
accountId: resolvedAccountId,
|
|
209
|
+
account,
|
|
210
|
+
tokenResolution,
|
|
211
|
+
configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
|
|
212
|
+
availableAccountIds: listAccountIds(cfg)
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function resolveTwitchSnapshotAccountId(cfg, account) {
|
|
216
|
+
const accountMap = (cfg.channels?.twitch)?.accounts ?? {};
|
|
217
|
+
return Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? "default";
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region extensions/twitch/src/setup-surface.ts
|
|
221
|
+
/**
|
|
222
|
+
* Twitch setup wizard surface for CLI setup.
|
|
223
|
+
*/
|
|
224
|
+
const channel = "twitch";
|
|
225
|
+
const t = createSetupTranslator();
|
|
226
|
+
const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
|
|
227
|
+
function normalizeRequestedSetupAccountId(accountId) {
|
|
228
|
+
const normalized = normalizeOptionalAccountId(accountId);
|
|
229
|
+
if (!normalized) throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
|
|
230
|
+
return normalized;
|
|
231
|
+
}
|
|
232
|
+
function resolveSetupAccountId(cfg, requestedAccountId) {
|
|
233
|
+
const requested = requestedAccountId?.trim();
|
|
234
|
+
if (requested) return normalizeRequestedSetupAccountId(requested);
|
|
235
|
+
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
|
|
236
|
+
return preferred ? normalizeAccountId$1(preferred) : resolveDefaultTwitchAccountId(cfg);
|
|
237
|
+
}
|
|
238
|
+
function setTwitchAccount(cfg, account, accountId = resolveSetupAccountId(cfg)) {
|
|
239
|
+
const resolvedAccountId = accountId.trim() ? normalizeRequestedSetupAccountId(accountId) : resolveSetupAccountId(cfg);
|
|
240
|
+
const existing = getAccountConfig(cfg, resolvedAccountId);
|
|
241
|
+
const merged = {
|
|
242
|
+
username: account.username ?? existing?.username ?? "",
|
|
243
|
+
accessToken: account.accessToken ?? existing?.accessToken ?? "",
|
|
244
|
+
clientId: account.clientId ?? existing?.clientId ?? "",
|
|
245
|
+
channel: account.channel ?? existing?.channel ?? "",
|
|
246
|
+
enabled: account.enabled ?? existing?.enabled ?? true,
|
|
247
|
+
allowFrom: account.allowFrom ?? existing?.allowFrom,
|
|
248
|
+
allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
|
|
249
|
+
requireMention: account.requireMention ?? existing?.requireMention,
|
|
250
|
+
clientSecret: account.clientSecret ?? existing?.clientSecret,
|
|
251
|
+
refreshToken: account.refreshToken ?? existing?.refreshToken,
|
|
252
|
+
expiresIn: account.expiresIn ?? existing?.expiresIn,
|
|
253
|
+
obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
...cfg,
|
|
257
|
+
channels: {
|
|
258
|
+
...cfg.channels,
|
|
259
|
+
twitch: {
|
|
260
|
+
...cfg.channels?.twitch,
|
|
261
|
+
enabled: true,
|
|
262
|
+
accounts: {
|
|
263
|
+
...(cfg.channels?.twitch)?.accounts,
|
|
264
|
+
[resolvedAccountId]: merged
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async function noteTwitchSetupHelp(prompter) {
|
|
271
|
+
await prompter.note([
|
|
272
|
+
t("wizard.twitch.helpRequiresBot"),
|
|
273
|
+
t("wizard.twitch.helpCreateApp"),
|
|
274
|
+
t("wizard.twitch.helpGenerateToken"),
|
|
275
|
+
t("wizard.twitch.helpTokenTools"),
|
|
276
|
+
t("wizard.twitch.helpCopyToken"),
|
|
277
|
+
t("wizard.twitch.helpEnvVars"),
|
|
278
|
+
`Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`
|
|
279
|
+
].join("\n"), t("wizard.twitch.setupTitle"));
|
|
280
|
+
}
|
|
281
|
+
async function promptToken(prompter, account, envToken) {
|
|
282
|
+
const existingToken = account?.accessToken ?? "";
|
|
283
|
+
if (existingToken && !envToken) {
|
|
284
|
+
if (await prompter.confirm({
|
|
285
|
+
message: t("wizard.twitch.accessTokenKeep"),
|
|
286
|
+
initialValue: true
|
|
287
|
+
})) return existingToken;
|
|
288
|
+
}
|
|
289
|
+
return (await prompter.text({
|
|
290
|
+
message: t("wizard.twitch.oauthTokenPrompt"),
|
|
291
|
+
initialValue: envToken ?? "",
|
|
292
|
+
validate: (value) => {
|
|
293
|
+
const raw = value?.trim() ?? "";
|
|
294
|
+
if (!raw) return "Required";
|
|
295
|
+
if (!raw.startsWith("oauth:")) return "Token should start with 'oauth:'";
|
|
296
|
+
}
|
|
297
|
+
})).trim();
|
|
298
|
+
}
|
|
299
|
+
async function promptUsername(prompter, account) {
|
|
300
|
+
return (await prompter.text({
|
|
301
|
+
message: t("wizard.twitch.botUsernamePrompt"),
|
|
302
|
+
initialValue: account?.username ?? "",
|
|
303
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
304
|
+
})).trim();
|
|
305
|
+
}
|
|
306
|
+
async function promptClientId(prompter, account) {
|
|
307
|
+
return (await prompter.text({
|
|
308
|
+
message: t("wizard.twitch.clientIdPrompt"),
|
|
309
|
+
initialValue: account?.clientId ?? "",
|
|
310
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
311
|
+
})).trim();
|
|
312
|
+
}
|
|
313
|
+
async function promptChannelName(prompter, account) {
|
|
314
|
+
return (await prompter.text({
|
|
315
|
+
message: t("wizard.twitch.channelJoinPrompt"),
|
|
316
|
+
initialValue: account?.channel ?? "",
|
|
317
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
318
|
+
})).trim();
|
|
319
|
+
}
|
|
320
|
+
async function promptRefreshTokenSetup(prompter, account) {
|
|
321
|
+
if (!await prompter.confirm({
|
|
322
|
+
message: t("wizard.twitch.refreshTokenPrompt"),
|
|
323
|
+
initialValue: Boolean(account?.clientSecret && account?.refreshToken)
|
|
324
|
+
})) return {};
|
|
325
|
+
return {
|
|
326
|
+
clientSecret: (await prompter.text({
|
|
327
|
+
message: t("wizard.twitch.clientSecretPrompt"),
|
|
328
|
+
initialValue: account?.clientSecret ?? "",
|
|
329
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
330
|
+
})).trim() || void 0,
|
|
331
|
+
refreshToken: (await prompter.text({
|
|
332
|
+
message: t("wizard.twitch.refreshTokenInputPrompt"),
|
|
333
|
+
initialValue: account?.refreshToken ?? "",
|
|
334
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
335
|
+
})).trim() || void 0
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async function configureWithEnvToken(cfg, prompter, account, envToken, forceAllowFrom, dmPolicy, accountId = resolveSetupAccountId(cfg)) {
|
|
339
|
+
const resolvedAccountId = accountId.trim() ? normalizeRequestedSetupAccountId(accountId) : resolveSetupAccountId(cfg);
|
|
340
|
+
if (resolvedAccountId !== "default") return null;
|
|
341
|
+
if (!await prompter.confirm({
|
|
342
|
+
message: t("wizard.twitch.envPrompt"),
|
|
343
|
+
initialValue: true
|
|
344
|
+
})) return null;
|
|
345
|
+
const cfgWithAccount = setTwitchAccount(cfg, {
|
|
346
|
+
username: await promptUsername(prompter, account),
|
|
347
|
+
clientId: await promptClientId(prompter, account),
|
|
348
|
+
accessToken: envToken,
|
|
349
|
+
enabled: true
|
|
350
|
+
}, resolvedAccountId);
|
|
351
|
+
if (forceAllowFrom && dmPolicy.promptAllowFrom) return { cfg: await dmPolicy.promptAllowFrom({
|
|
352
|
+
cfg: cfgWithAccount,
|
|
353
|
+
prompter,
|
|
354
|
+
accountId: resolvedAccountId
|
|
355
|
+
}) };
|
|
356
|
+
return { cfg: cfgWithAccount };
|
|
357
|
+
}
|
|
358
|
+
function setTwitchAccessControl(cfg, allowedRoles, requireMention, accountId) {
|
|
359
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
360
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
361
|
+
if (!account) return cfg;
|
|
362
|
+
return setTwitchAccount(cfg, {
|
|
363
|
+
...account,
|
|
364
|
+
allowedRoles,
|
|
365
|
+
requireMention
|
|
366
|
+
}, resolvedAccountId);
|
|
367
|
+
}
|
|
368
|
+
function resolveTwitchGroupPolicy(cfg, accountId) {
|
|
369
|
+
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
|
370
|
+
if (account?.allowedRoles?.includes("all")) return "open";
|
|
371
|
+
if (account?.allowedRoles?.includes("moderator")) return "allowlist";
|
|
372
|
+
return "disabled";
|
|
373
|
+
}
|
|
374
|
+
function setTwitchGroupPolicy(cfg, policy, accountId) {
|
|
375
|
+
return setTwitchAccessControl(cfg, policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [], true, accountId);
|
|
376
|
+
}
|
|
377
|
+
const twitchDmPolicy = {
|
|
378
|
+
label: "Twitch",
|
|
379
|
+
channel,
|
|
380
|
+
policyKey: "channels.twitch.accounts.default.allowedRoles",
|
|
381
|
+
allowFromKey: "channels.twitch.accounts.default.allowFrom",
|
|
382
|
+
resolveConfigKeys: (cfg, accountId) => {
|
|
383
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
384
|
+
return {
|
|
385
|
+
policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
|
|
386
|
+
allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
getCurrent: (cfg, accountId) => {
|
|
390
|
+
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
|
391
|
+
if (account?.allowedRoles?.includes("all")) return "open";
|
|
392
|
+
if (account?.allowFrom && account.allowFrom.length > 0) return "allowlist";
|
|
393
|
+
return "disabled";
|
|
394
|
+
},
|
|
395
|
+
setPolicy: (cfg, policy, accountId) => {
|
|
396
|
+
return setTwitchAccessControl(cfg, policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"], true, accountId);
|
|
397
|
+
},
|
|
398
|
+
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
|
399
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
400
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
401
|
+
const existingAllowFrom = account?.allowFrom ?? [];
|
|
402
|
+
const allowFrom = (await prompter.text({
|
|
403
|
+
message: t("wizard.twitch.allowFromPrompt"),
|
|
404
|
+
placeholder: "123456789",
|
|
405
|
+
initialValue: existingAllowFrom[0] || void 0
|
|
406
|
+
}) ?? "").split(/[\n,;]+/g).map((s) => s.trim()).filter(Boolean);
|
|
407
|
+
return setTwitchAccount(cfg, {
|
|
408
|
+
...account ?? void 0,
|
|
409
|
+
allowFrom
|
|
410
|
+
}, resolvedAccountId);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const twitchGroupAccess = {
|
|
414
|
+
label: "Twitch chat",
|
|
415
|
+
placeholder: "",
|
|
416
|
+
skipAllowlistEntries: true,
|
|
417
|
+
currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
|
|
418
|
+
currentEntries: ({ cfg, accountId }) => {
|
|
419
|
+
return getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId))?.allowFrom ?? [];
|
|
420
|
+
},
|
|
421
|
+
updatePrompt: ({ cfg, accountId }) => {
|
|
422
|
+
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
|
423
|
+
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
|
|
424
|
+
},
|
|
425
|
+
setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
|
|
426
|
+
resolveAllowlist: async () => [],
|
|
427
|
+
applyAllowlist: ({ cfg }) => cfg
|
|
428
|
+
};
|
|
429
|
+
const twitchSetupAdapter = {
|
|
430
|
+
resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
|
|
431
|
+
applyAccountConfig: ({ cfg, accountId }) => setTwitchAccount(cfg, { enabled: true }, accountId)
|
|
432
|
+
};
|
|
433
|
+
const twitchSetupWizard = {
|
|
434
|
+
channel,
|
|
435
|
+
resolveAccountIdForConfigure: ({ cfg, accountOverride }) => resolveSetupAccountId(cfg, accountOverride),
|
|
436
|
+
resolveShouldPromptAccountIds: () => false,
|
|
437
|
+
status: {
|
|
438
|
+
configuredLabel: t("wizard.channels.statusConfigured"),
|
|
439
|
+
unconfiguredLabel: t("wizard.channels.statusNeedsUsernameTokenClientId"),
|
|
440
|
+
configuredHint: t("wizard.channels.statusConfigured"),
|
|
441
|
+
unconfiguredHint: t("wizard.channels.statusNeedsSetup"),
|
|
442
|
+
resolveConfigured: ({ cfg, accountId }) => {
|
|
443
|
+
return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
|
|
444
|
+
},
|
|
445
|
+
resolveStatusLines: ({ cfg, accountId }) => {
|
|
446
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
447
|
+
const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
|
|
448
|
+
return [`Twitch${resolvedAccountId !== "default" ? ` (${resolvedAccountId})` : ""}: ${configured ? t("wizard.channels.statusConfigured") : t("wizard.channels.statusNeedsUsernameTokenClientId")}`];
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
credentials: [],
|
|
452
|
+
finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
|
|
453
|
+
const accountId = resolveSetupAccountId(cfg, requestedAccountId);
|
|
454
|
+
const account = getAccountConfig(cfg, accountId);
|
|
455
|
+
if (!account || !isAccountConfigured(account)) await noteTwitchSetupHelp(prompter);
|
|
456
|
+
const envToken = process.env.KLAW_TWITCH_ACCESS_TOKEN?.trim();
|
|
457
|
+
if (accountId === "default" && envToken && !account?.accessToken) {
|
|
458
|
+
const envResult = await configureWithEnvToken(cfg, prompter, account, envToken, forceAllowFrom, twitchDmPolicy, accountId);
|
|
459
|
+
if (envResult) return envResult;
|
|
460
|
+
}
|
|
461
|
+
const username = await promptUsername(prompter, account);
|
|
462
|
+
const token = await promptToken(prompter, account, envToken);
|
|
463
|
+
const clientId = await promptClientId(prompter, account);
|
|
464
|
+
const channelName = await promptChannelName(prompter, account);
|
|
465
|
+
const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
|
|
466
|
+
const cfgWithAccount = setTwitchAccount(cfg, {
|
|
467
|
+
username,
|
|
468
|
+
accessToken: token,
|
|
469
|
+
clientId,
|
|
470
|
+
channel: channelName,
|
|
471
|
+
clientSecret,
|
|
472
|
+
refreshToken,
|
|
473
|
+
enabled: true
|
|
474
|
+
}, accountId);
|
|
475
|
+
return { cfg: forceAllowFrom && twitchDmPolicy.promptAllowFrom ? await twitchDmPolicy.promptAllowFrom({
|
|
476
|
+
cfg: cfgWithAccount,
|
|
477
|
+
prompter,
|
|
478
|
+
accountId
|
|
479
|
+
}) : cfgWithAccount };
|
|
480
|
+
},
|
|
481
|
+
dmPolicy: twitchDmPolicy,
|
|
482
|
+
groupAccess: twitchGroupAccess,
|
|
483
|
+
disable: (cfg) => {
|
|
484
|
+
const twitch = cfg.channels?.twitch;
|
|
485
|
+
return {
|
|
486
|
+
...cfg,
|
|
487
|
+
channels: {
|
|
488
|
+
...cfg.channels,
|
|
489
|
+
twitch: {
|
|
490
|
+
...twitch,
|
|
491
|
+
enabled: false
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
const twitchSetupPlugin = {
|
|
498
|
+
id: channel,
|
|
499
|
+
meta: getChatChannelMeta(channel),
|
|
500
|
+
capabilities: { chatTypes: ["group"] },
|
|
501
|
+
config: {
|
|
502
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
503
|
+
resolveAccount: (cfg, accountId) => {
|
|
504
|
+
const resolvedAccountId = normalizeAccountId$1(accountId ?? resolveDefaultTwitchAccountId(cfg));
|
|
505
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
506
|
+
if (!account) return {
|
|
507
|
+
accountId: resolvedAccountId,
|
|
508
|
+
username: "",
|
|
509
|
+
accessToken: "",
|
|
510
|
+
clientId: "",
|
|
511
|
+
channel: "",
|
|
512
|
+
enabled: false
|
|
513
|
+
};
|
|
514
|
+
return {
|
|
515
|
+
accountId: resolvedAccountId,
|
|
516
|
+
...account
|
|
517
|
+
};
|
|
518
|
+
},
|
|
519
|
+
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
|
|
520
|
+
isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
|
|
521
|
+
isEnabled: (account) => account.enabled !== false
|
|
522
|
+
},
|
|
523
|
+
setup: twitchSetupAdapter,
|
|
524
|
+
setupWizard: twitchSetupWizard
|
|
525
|
+
};
|
|
526
|
+
//#endregion
|
|
527
|
+
export { getAccountConfig as a, resolveTwitchAccountContext as c, isAccountConfigured as d, missingTargetError as f, resolveTwitchToken as h, DEFAULT_ACCOUNT_ID$1 as i, resolveTwitchSnapshotAccountId as l, normalizeTwitchChannel as m, twitchSetupPlugin as n, listAccountIds as o, normalizeToken as p, twitchSetupWizard as r, resolveDefaultTwitchAccountId as s, twitchSetupAdapter as t, generateMessageId as u };
|
package/index.test.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { assertBundledChannelEntries } from "klaw/plugin-sdk/channel-test-helpers";
|
|
2
|
+
import { describe } from "vitest";
|
|
3
|
+
import entry from "./index.js";
|
|
4
|
+
import setupEntry from "./setup-entry.js";
|
|
5
|
+
|
|
6
|
+
describe("twitch bundled entries", () => {
|
|
7
|
+
assertBundledChannelEntries({
|
|
8
|
+
entry,
|
|
9
|
+
expectedId: "twitch",
|
|
10
|
+
expectedName: "Twitch",
|
|
11
|
+
setupEntry,
|
|
12
|
+
});
|
|
13
|
+
});
|
package/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineBundledChannelEntry } from "klaw/plugin-sdk/channel-entry-contract";
|
|
2
|
+
|
|
3
|
+
export 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
|
+
});
|