@gakr-gakr/twitch 0.1.0

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.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Twitch setup wizard surface for CLI setup.
3
+ */
4
+
5
+ import { normalizeOptionalAccountId } from "autobot/plugin-sdk/account-id";
6
+ import { getChatChannelMeta, type ChannelPlugin } from "autobot/plugin-sdk/core";
7
+ import {
8
+ formatDocsLink,
9
+ type ChannelSetupAdapter,
10
+ type ChannelSetupDmPolicy,
11
+ type ChannelSetupWizard,
12
+ type AutoBotConfig,
13
+ type WizardPrompter,
14
+ normalizeAccountId,
15
+ createSetupTranslator,
16
+ } from "autobot/plugin-sdk/setup";
17
+ import {
18
+ DEFAULT_ACCOUNT_ID,
19
+ getAccountConfig,
20
+ listAccountIds,
21
+ resolveDefaultTwitchAccountId,
22
+ resolveTwitchAccountContext,
23
+ } from "./config.js";
24
+ import type { TwitchAccountConfig, TwitchRole } from "./types.js";
25
+ import { isAccountConfigured } from "./utils/twitch.js";
26
+
27
+ const channel = "twitch" as const;
28
+ const t = createSetupTranslator();
29
+ const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
30
+
31
+ function normalizeRequestedSetupAccountId(accountId: string): string {
32
+ const normalized = normalizeOptionalAccountId(accountId);
33
+ if (!normalized) {
34
+ throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
35
+ }
36
+ return normalized;
37
+ }
38
+
39
+ function resolveSetupAccountId(cfg: AutoBotConfig, requestedAccountId?: string): string {
40
+ const requested = requestedAccountId?.trim();
41
+ if (requested) {
42
+ return normalizeRequestedSetupAccountId(requested);
43
+ }
44
+
45
+ const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
46
+ return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg);
47
+ }
48
+
49
+ export function setTwitchAccount(
50
+ cfg: AutoBotConfig,
51
+ account: Partial<TwitchAccountConfig>,
52
+ accountId: string = resolveSetupAccountId(cfg),
53
+ ): AutoBotConfig {
54
+ const resolvedAccountId = accountId.trim()
55
+ ? normalizeRequestedSetupAccountId(accountId)
56
+ : resolveSetupAccountId(cfg);
57
+ const existing = getAccountConfig(cfg, resolvedAccountId);
58
+ const merged: TwitchAccountConfig = {
59
+ username: account.username ?? existing?.username ?? "",
60
+ accessToken: account.accessToken ?? existing?.accessToken ?? "",
61
+ clientId: account.clientId ?? existing?.clientId ?? "",
62
+ channel: account.channel ?? existing?.channel ?? "",
63
+ enabled: account.enabled ?? existing?.enabled ?? true,
64
+ allowFrom: account.allowFrom ?? existing?.allowFrom,
65
+ allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
66
+ requireMention: account.requireMention ?? existing?.requireMention,
67
+ clientSecret: account.clientSecret ?? existing?.clientSecret,
68
+ refreshToken: account.refreshToken ?? existing?.refreshToken,
69
+ expiresIn: account.expiresIn ?? existing?.expiresIn,
70
+ obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp,
71
+ };
72
+
73
+ return {
74
+ ...cfg,
75
+ channels: {
76
+ ...cfg.channels,
77
+ twitch: {
78
+ ...((cfg.channels as Record<string, unknown>)?.twitch as
79
+ | Record<string, unknown>
80
+ | undefined),
81
+ enabled: true,
82
+ accounts: {
83
+ ...((
84
+ (cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
85
+ )?.accounts as Record<string, unknown> | undefined),
86
+ [resolvedAccountId]: merged,
87
+ },
88
+ },
89
+ },
90
+ };
91
+ }
92
+
93
+ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
94
+ await prompter.note(
95
+ [
96
+ t("wizard.twitch.helpRequiresBot"),
97
+ t("wizard.twitch.helpCreateApp"),
98
+ t("wizard.twitch.helpGenerateToken"),
99
+ t("wizard.twitch.helpTokenTools"),
100
+ t("wizard.twitch.helpCopyToken"),
101
+ t("wizard.twitch.helpEnvVars"),
102
+ `Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`,
103
+ ].join("\n"),
104
+ t("wizard.twitch.setupTitle"),
105
+ );
106
+ }
107
+
108
+ export async function promptToken(
109
+ prompter: WizardPrompter,
110
+ account: TwitchAccountConfig | null,
111
+ envToken: string | undefined,
112
+ ): Promise<string> {
113
+ const existingToken = account?.accessToken ?? "";
114
+
115
+ if (existingToken && !envToken) {
116
+ const keepToken = await prompter.confirm({
117
+ message: t("wizard.twitch.accessTokenKeep"),
118
+ initialValue: true,
119
+ });
120
+ if (keepToken) {
121
+ return existingToken;
122
+ }
123
+ }
124
+
125
+ return (
126
+ await prompter.text({
127
+ message: t("wizard.twitch.oauthTokenPrompt"),
128
+ initialValue: envToken ?? "",
129
+ validate: (value) => {
130
+ const raw = value?.trim() ?? "";
131
+ if (!raw) {
132
+ return "Required";
133
+ }
134
+ if (!raw.startsWith("oauth:")) {
135
+ return "Token should start with 'oauth:'";
136
+ }
137
+ return undefined;
138
+ },
139
+ })
140
+ ).trim();
141
+ }
142
+
143
+ export async function promptUsername(
144
+ prompter: WizardPrompter,
145
+ account: TwitchAccountConfig | null,
146
+ ): Promise<string> {
147
+ return (
148
+ await prompter.text({
149
+ message: t("wizard.twitch.botUsernamePrompt"),
150
+ initialValue: account?.username ?? "",
151
+ validate: (value) => (value?.trim() ? undefined : "Required"),
152
+ })
153
+ ).trim();
154
+ }
155
+
156
+ export async function promptClientId(
157
+ prompter: WizardPrompter,
158
+ account: TwitchAccountConfig | null,
159
+ ): Promise<string> {
160
+ return (
161
+ await prompter.text({
162
+ message: t("wizard.twitch.clientIdPrompt"),
163
+ initialValue: account?.clientId ?? "",
164
+ validate: (value) => (value?.trim() ? undefined : "Required"),
165
+ })
166
+ ).trim();
167
+ }
168
+
169
+ export async function promptChannelName(
170
+ prompter: WizardPrompter,
171
+ account: TwitchAccountConfig | null,
172
+ ): Promise<string> {
173
+ return (
174
+ await prompter.text({
175
+ message: t("wizard.twitch.channelJoinPrompt"),
176
+ initialValue: account?.channel ?? "",
177
+ validate: (value) => (value?.trim() ? undefined : "Required"),
178
+ })
179
+ ).trim();
180
+ }
181
+
182
+ export async function promptRefreshTokenSetup(
183
+ prompter: WizardPrompter,
184
+ account: TwitchAccountConfig | null,
185
+ ): Promise<{ clientSecret?: string; refreshToken?: string }> {
186
+ const useRefresh = await prompter.confirm({
187
+ message: t("wizard.twitch.refreshTokenPrompt"),
188
+ initialValue: Boolean(account?.clientSecret && account?.refreshToken),
189
+ });
190
+
191
+ if (!useRefresh) {
192
+ return {};
193
+ }
194
+
195
+ const clientSecret =
196
+ (
197
+ await prompter.text({
198
+ message: t("wizard.twitch.clientSecretPrompt"),
199
+ initialValue: account?.clientSecret ?? "",
200
+ validate: (value) => (value?.trim() ? undefined : "Required"),
201
+ })
202
+ ).trim() || undefined;
203
+
204
+ const refreshToken =
205
+ (
206
+ await prompter.text({
207
+ message: t("wizard.twitch.refreshTokenInputPrompt"),
208
+ initialValue: account?.refreshToken ?? "",
209
+ validate: (value) => (value?.trim() ? undefined : "Required"),
210
+ })
211
+ ).trim() || undefined;
212
+
213
+ return { clientSecret, refreshToken };
214
+ }
215
+
216
+ export async function configureWithEnvToken(
217
+ cfg: AutoBotConfig,
218
+ prompter: WizardPrompter,
219
+ account: TwitchAccountConfig | null,
220
+ envToken: string,
221
+ forceAllowFrom: boolean,
222
+ dmPolicy: ChannelSetupDmPolicy,
223
+ accountId: string = resolveSetupAccountId(cfg),
224
+ ): Promise<{ cfg: AutoBotConfig } | null> {
225
+ const resolvedAccountId = accountId.trim()
226
+ ? normalizeRequestedSetupAccountId(accountId)
227
+ : resolveSetupAccountId(cfg);
228
+ if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
229
+ return null;
230
+ }
231
+
232
+ const useEnv = await prompter.confirm({
233
+ message: t("wizard.twitch.envPrompt"),
234
+ initialValue: true,
235
+ });
236
+ if (!useEnv) {
237
+ return null;
238
+ }
239
+
240
+ const username = await promptUsername(prompter, account);
241
+ const clientId = await promptClientId(prompter, account);
242
+
243
+ const cfgWithAccount = setTwitchAccount(
244
+ cfg,
245
+ {
246
+ username,
247
+ clientId,
248
+ accessToken: envToken,
249
+ enabled: true,
250
+ },
251
+ resolvedAccountId,
252
+ );
253
+
254
+ if (forceAllowFrom && dmPolicy.promptAllowFrom) {
255
+ return {
256
+ cfg: await dmPolicy.promptAllowFrom({
257
+ cfg: cfgWithAccount,
258
+ prompter,
259
+ accountId: resolvedAccountId,
260
+ }),
261
+ };
262
+ }
263
+
264
+ return { cfg: cfgWithAccount };
265
+ }
266
+
267
+ function setTwitchAccessControl(
268
+ cfg: AutoBotConfig,
269
+ allowedRoles: TwitchRole[],
270
+ requireMention: boolean,
271
+ accountId?: string,
272
+ ): AutoBotConfig {
273
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
274
+ const account = getAccountConfig(cfg, resolvedAccountId);
275
+ if (!account) {
276
+ return cfg;
277
+ }
278
+
279
+ return setTwitchAccount(
280
+ cfg,
281
+ {
282
+ ...account,
283
+ allowedRoles,
284
+ requireMention,
285
+ },
286
+ resolvedAccountId,
287
+ );
288
+ }
289
+
290
+ function resolveTwitchGroupPolicy(
291
+ cfg: AutoBotConfig,
292
+ accountId?: string,
293
+ ): "open" | "allowlist" | "disabled" {
294
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
295
+ if (account?.allowedRoles?.includes("all")) {
296
+ return "open";
297
+ }
298
+ if (account?.allowedRoles?.includes("moderator")) {
299
+ return "allowlist";
300
+ }
301
+ return "disabled";
302
+ }
303
+
304
+ function setTwitchGroupPolicy(
305
+ cfg: AutoBotConfig,
306
+ policy: "open" | "allowlist" | "disabled",
307
+ accountId?: string,
308
+ ): AutoBotConfig {
309
+ const allowedRoles: TwitchRole[] =
310
+ policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [];
311
+ return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
312
+ }
313
+
314
+ const twitchDmPolicy: ChannelSetupDmPolicy = {
315
+ label: "Twitch",
316
+ channel,
317
+ policyKey: "channels.twitch.accounts.default.allowedRoles",
318
+ allowFromKey: "channels.twitch.accounts.default.allowFrom",
319
+ resolveConfigKeys: (cfg, accountId) => {
320
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
321
+ return {
322
+ policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
323
+ allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`,
324
+ };
325
+ },
326
+ getCurrent: (cfg, accountId) => {
327
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
328
+ if (account?.allowedRoles?.includes("all")) {
329
+ return "open";
330
+ }
331
+ if (account?.allowFrom && account.allowFrom.length > 0) {
332
+ return "allowlist";
333
+ }
334
+ return "disabled";
335
+ },
336
+ setPolicy: (cfg, policy, accountId) => {
337
+ const allowedRoles: TwitchRole[] =
338
+ policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
339
+ return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
340
+ },
341
+ promptAllowFrom: async ({ cfg, prompter, accountId }) => {
342
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
343
+ const account = getAccountConfig(cfg, resolvedAccountId);
344
+ const existingAllowFrom = account?.allowFrom ?? [];
345
+
346
+ const entry = await prompter.text({
347
+ message: t("wizard.twitch.allowFromPrompt"),
348
+ placeholder: "123456789",
349
+ initialValue: existingAllowFrom[0] || undefined,
350
+ });
351
+
352
+ const allowFrom = (entry ?? "")
353
+ .split(/[\n,;]+/g)
354
+ .map((s) => s.trim())
355
+ .filter(Boolean);
356
+
357
+ return setTwitchAccount(
358
+ cfg,
359
+ {
360
+ ...(account ?? undefined),
361
+ allowFrom,
362
+ },
363
+ resolvedAccountId,
364
+ );
365
+ },
366
+ };
367
+
368
+ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
369
+ label: "Twitch chat",
370
+ placeholder: "",
371
+ skipAllowlistEntries: true,
372
+ currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
373
+ currentEntries: ({ cfg, accountId }) => {
374
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
375
+ return account?.allowFrom ?? [];
376
+ },
377
+ updatePrompt: ({ cfg, accountId }) => {
378
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
379
+ return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
380
+ },
381
+ setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
382
+ resolveAllowlist: async () => [],
383
+ applyAllowlist: ({ cfg }) => cfg,
384
+ };
385
+
386
+ export const twitchSetupAdapter: ChannelSetupAdapter = {
387
+ resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
388
+ applyAccountConfig: ({ cfg, accountId }) =>
389
+ setTwitchAccount(
390
+ cfg,
391
+ {
392
+ enabled: true,
393
+ },
394
+ accountId,
395
+ ),
396
+ };
397
+
398
+ export const twitchSetupWizard: ChannelSetupWizard = {
399
+ channel,
400
+ resolveAccountIdForConfigure: ({ cfg, accountOverride }) =>
401
+ resolveSetupAccountId(cfg, accountOverride),
402
+ resolveShouldPromptAccountIds: () => false,
403
+ status: {
404
+ configuredLabel: t("wizard.channels.statusConfigured"),
405
+ unconfiguredLabel: t("wizard.channels.statusNeedsUsernameTokenClientId"),
406
+ configuredHint: t("wizard.channels.statusConfigured"),
407
+ unconfiguredHint: t("wizard.channels.statusNeedsSetup"),
408
+ resolveConfigured: ({ cfg, accountId }) => {
409
+ return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
410
+ },
411
+ resolveStatusLines: ({ cfg, accountId }) => {
412
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
413
+ const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
414
+ return [
415
+ `Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${
416
+ configured
417
+ ? t("wizard.channels.statusConfigured")
418
+ : t("wizard.channels.statusNeedsUsernameTokenClientId")
419
+ }`,
420
+ ];
421
+ },
422
+ },
423
+ credentials: [],
424
+ finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
425
+ const accountId = resolveSetupAccountId(cfg, requestedAccountId);
426
+ const account = getAccountConfig(cfg, accountId);
427
+
428
+ if (!account || !isAccountConfigured(account)) {
429
+ await noteTwitchSetupHelp(prompter);
430
+ }
431
+
432
+ const envToken = process.env.AUTOBOT_TWITCH_ACCESS_TOKEN?.trim();
433
+
434
+ if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) {
435
+ const envResult = await configureWithEnvToken(
436
+ cfg,
437
+ prompter,
438
+ account,
439
+ envToken,
440
+ forceAllowFrom,
441
+ twitchDmPolicy,
442
+ accountId,
443
+ );
444
+ if (envResult) {
445
+ return envResult;
446
+ }
447
+ }
448
+
449
+ const username = await promptUsername(prompter, account);
450
+ const token = await promptToken(prompter, account, envToken);
451
+ const clientId = await promptClientId(prompter, account);
452
+ const channelName = await promptChannelName(prompter, account);
453
+ const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
454
+
455
+ const cfgWithAccount = setTwitchAccount(
456
+ cfg,
457
+ {
458
+ username,
459
+ accessToken: token,
460
+ clientId,
461
+ channel: channelName,
462
+ clientSecret,
463
+ refreshToken,
464
+ enabled: true,
465
+ },
466
+ accountId,
467
+ );
468
+
469
+ const cfgWithAllowFrom =
470
+ forceAllowFrom && twitchDmPolicy.promptAllowFrom
471
+ ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId })
472
+ : cfgWithAccount;
473
+
474
+ return { cfg: cfgWithAllowFrom };
475
+ },
476
+ dmPolicy: twitchDmPolicy,
477
+ groupAccess: twitchGroupAccess,
478
+ disable: (cfg) => {
479
+ const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
480
+ | Record<string, unknown>
481
+ | undefined;
482
+ return {
483
+ ...cfg,
484
+ channels: {
485
+ ...cfg.channels,
486
+ twitch: { ...twitch, enabled: false },
487
+ },
488
+ };
489
+ },
490
+ };
491
+
492
+ type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
493
+
494
+ export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
495
+ id: channel,
496
+ meta: getChatChannelMeta(channel),
497
+ capabilities: {
498
+ chatTypes: ["group"],
499
+ },
500
+ config: {
501
+ listAccountIds: (cfg) => listAccountIds(cfg),
502
+ resolveAccount: (cfg, accountId) => {
503
+ const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
504
+ const account = getAccountConfig(cfg, resolvedAccountId);
505
+ if (!account) {
506
+ return {
507
+ accountId: resolvedAccountId,
508
+ username: "",
509
+ accessToken: "",
510
+ clientId: "",
511
+ channel: "",
512
+ enabled: false,
513
+ };
514
+ }
515
+ return {
516
+ accountId: resolvedAccountId,
517
+ ...account,
518
+ };
519
+ },
520
+ defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
521
+ isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
522
+ isEnabled: (account) => account.enabled !== false,
523
+ },
524
+ setup: twitchSetupAdapter,
525
+ setupWizard: twitchSetupWizard,
526
+ };
package/src/status.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Twitch status issues collector.
3
+ *
4
+ * Detects and reports configuration issues for Twitch accounts.
5
+ */
6
+
7
+ import type { ChannelStatusIssue } from "autobot/plugin-sdk/channel-contract";
8
+ import { getAccountConfig } from "./config.js";
9
+ import { resolveTwitchToken } from "./token.js";
10
+ import type { ChannelAccountSnapshot } from "./types.js";
11
+ import { isAccountConfigured } from "./utils/twitch.js";
12
+
13
+ /**
14
+ * Collect status issues for Twitch accounts.
15
+ *
16
+ * Analyzes account snapshots and detects configuration problems,
17
+ * authentication issues, and other potential problems.
18
+ *
19
+ * @param accounts - Array of account snapshots to analyze
20
+ * @param getCfg - Optional function to get full config for additional checks
21
+ * @returns Array of detected status issues
22
+ *
23
+ * @example
24
+ * const issues = collectTwitchStatusIssues(accountSnapshots);
25
+ * if (issues.length > 0) {
26
+ * console.warn("Twitch configuration issues detected:");
27
+ * issues.forEach(issue => console.warn(`- ${issue.message}`));
28
+ * }
29
+ */
30
+ export function collectTwitchStatusIssues(
31
+ accounts: ChannelAccountSnapshot[],
32
+ getCfg?: () => unknown,
33
+ ): ChannelStatusIssue[] {
34
+ const issues: ChannelStatusIssue[] = [];
35
+
36
+ for (const entry of accounts) {
37
+ const accountId = entry.accountId;
38
+
39
+ if (!accountId) {
40
+ continue;
41
+ }
42
+
43
+ let account: ReturnType<typeof getAccountConfig> | null = null;
44
+ let cfg: Parameters<typeof resolveTwitchToken>[0] | undefined;
45
+ if (getCfg) {
46
+ try {
47
+ cfg = getCfg() as {
48
+ channels?: { twitch?: { accounts?: Record<string, unknown> } };
49
+ };
50
+ account = getAccountConfig(cfg, accountId);
51
+ } catch {
52
+ // Ignore config access errors
53
+ }
54
+ }
55
+
56
+ if (!entry.configured) {
57
+ issues.push({
58
+ channel: "twitch",
59
+ accountId,
60
+ kind: "config",
61
+ message: "Twitch account is not properly configured",
62
+ fix: "Add required fields: username, accessToken, and clientId to your account configuration",
63
+ });
64
+ continue;
65
+ }
66
+
67
+ if (entry.enabled === false) {
68
+ issues.push({
69
+ channel: "twitch",
70
+ accountId,
71
+ kind: "config",
72
+ message: "Twitch account is disabled",
73
+ fix: "Set enabled: true in your account configuration to enable this account",
74
+ });
75
+ continue;
76
+ }
77
+
78
+ if (account && account.username && account.accessToken && !account.clientId) {
79
+ issues.push({
80
+ channel: "twitch",
81
+ accountId,
82
+ kind: "config",
83
+ message: "Twitch client ID is required",
84
+ fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
85
+ });
86
+ }
87
+
88
+ const tokenResolution = cfg
89
+ ? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
90
+ : { token: "", source: "none" };
91
+ if (account && isAccountConfigured(account, tokenResolution.token)) {
92
+ if (account.accessToken?.startsWith("oauth:")) {
93
+ issues.push({
94
+ channel: "twitch",
95
+ accountId,
96
+ kind: "config",
97
+ message: "Token contains 'oauth:' prefix (will be stripped)",
98
+ fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
99
+ });
100
+ }
101
+
102
+ if (account.clientSecret && !account.refreshToken) {
103
+ issues.push({
104
+ channel: "twitch",
105
+ accountId,
106
+ kind: "config",
107
+ message: "clientSecret provided without refreshToken",
108
+ fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
109
+ });
110
+ }
111
+
112
+ if (account.allowFrom && account.allowFrom.length === 0) {
113
+ issues.push({
114
+ channel: "twitch",
115
+ accountId,
116
+ kind: "config",
117
+ message: "allowFrom is configured but empty",
118
+ fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
119
+ });
120
+ }
121
+
122
+ if (
123
+ account.allowedRoles?.includes("all") &&
124
+ account.allowFrom &&
125
+ account.allowFrom.length > 0
126
+ ) {
127
+ issues.push({
128
+ channel: "twitch",
129
+ accountId,
130
+ kind: "intent",
131
+ message: "allowedRoles is set to 'all' but allowFrom is also configured",
132
+ fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
133
+ });
134
+ }
135
+ }
136
+
137
+ if (entry.lastError) {
138
+ issues.push({
139
+ channel: "twitch",
140
+ accountId,
141
+ kind: "runtime",
142
+ message: `Last error: ${entry.lastError}`,
143
+ fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
144
+ });
145
+ }
146
+
147
+ if (
148
+ entry.configured &&
149
+ !entry.running &&
150
+ !entry.lastStartAt &&
151
+ !entry.lastInboundAt &&
152
+ !entry.lastOutboundAt
153
+ ) {
154
+ issues.push({
155
+ channel: "twitch",
156
+ accountId,
157
+ kind: "runtime",
158
+ message: "Account has never connected successfully",
159
+ fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
160
+ });
161
+ }
162
+
163
+ if (entry.running && entry.lastStartAt) {
164
+ const uptime = Date.now() - entry.lastStartAt;
165
+ const daysSinceStart = uptime / (1000 * 60 * 60 * 24);
166
+ if (daysSinceStart > 7) {
167
+ issues.push({
168
+ channel: "twitch",
169
+ accountId,
170
+ kind: "runtime",
171
+ message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
172
+ fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ return issues;
179
+ }