@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.
Files changed (62) hide show
  1. package/README.md +89 -0
  2. package/api.ts +21 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/dist/api.js +3 -0
  5. package/dist/channel-plugin-api.js +2 -0
  6. package/dist/index.js +18 -0
  7. package/dist/monitor-j1GtQVBd.js +337 -0
  8. package/dist/plugin-BMzrFFQR.js +1285 -0
  9. package/dist/runtime-CwXHrWo3.js +8 -0
  10. package/dist/runtime-api.js +1 -0
  11. package/dist/setup-entry.js +11 -0
  12. package/dist/setup-plugin-api.js +2 -0
  13. package/dist/setup-surface-CovnRl9R.js +527 -0
  14. package/index.test.ts +13 -0
  15. package/index.ts +16 -0
  16. package/klaw.plugin.json +2 -219
  17. package/package.json +3 -3
  18. package/runtime-api.ts +22 -0
  19. package/setup-entry.ts +9 -0
  20. package/setup-plugin-api.ts +3 -0
  21. package/src/access-control.test.ts +373 -0
  22. package/src/access-control.ts +195 -0
  23. package/src/actions.test.ts +75 -0
  24. package/src/actions.ts +175 -0
  25. package/src/client-manager-registry.ts +87 -0
  26. package/src/config-schema.test.ts +46 -0
  27. package/src/config-schema.ts +88 -0
  28. package/src/config.test.ts +233 -0
  29. package/src/config.ts +177 -0
  30. package/src/monitor.ts +311 -0
  31. package/src/outbound.test.ts +572 -0
  32. package/src/outbound.ts +242 -0
  33. package/src/plugin.lifecycle.test.ts +86 -0
  34. package/src/plugin.live.test.ts +120 -0
  35. package/src/plugin.test.ts +77 -0
  36. package/src/plugin.ts +220 -0
  37. package/src/probe.test.ts +196 -0
  38. package/src/probe.ts +130 -0
  39. package/src/resolver.ts +139 -0
  40. package/src/runtime.ts +9 -0
  41. package/src/send.test.ts +342 -0
  42. package/src/send.ts +191 -0
  43. package/src/setup-surface.test.ts +529 -0
  44. package/src/setup-surface.ts +526 -0
  45. package/src/status.test.ts +298 -0
  46. package/src/status.ts +179 -0
  47. package/src/test-fixtures.ts +30 -0
  48. package/src/token.test.ts +198 -0
  49. package/src/token.ts +93 -0
  50. package/src/twitch-client.test.ts +574 -0
  51. package/src/twitch-client.ts +276 -0
  52. package/src/types.ts +104 -0
  53. package/src/utils/markdown.ts +98 -0
  54. package/src/utils/twitch.ts +81 -0
  55. package/test/setup.ts +7 -0
  56. package/tsconfig.json +16 -0
  57. package/api.js +0 -7
  58. package/channel-plugin-api.js +0 -7
  59. package/index.js +0 -7
  60. package/runtime-api.js +0 -7
  61. package/setup-entry.js +0 -7
  62. package/setup-plugin-api.js +0 -7
@@ -0,0 +1,195 @@
1
+ import {
2
+ createChannelIngressResolver,
3
+ defineStableChannelIngressIdentity,
4
+ type ChannelIngressIdentitySubjectInput,
5
+ type IngressReasonCode,
6
+ } from "klaw/plugin-sdk/channel-ingress-runtime";
7
+ import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
8
+ import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
9
+
10
+ type TwitchAccessControlResult = {
11
+ allowed: boolean;
12
+ reason?: string;
13
+ matchKey?: string;
14
+ matchSource?: string;
15
+ };
16
+
17
+ type TwitchPolicyKind = "open" | "allowFrom" | "role";
18
+
19
+ const twitchUserIdentity = defineStableChannelIngressIdentity({
20
+ key: "sender-id",
21
+ entryIdPrefix: "twitch-user-entry",
22
+ });
23
+
24
+ const twitchRoleIdentity = defineStableChannelIngressIdentity({
25
+ key: "role-moderator",
26
+ kind: "role",
27
+ normalizeEntry: normalizeTwitchRole,
28
+ normalizeSubject: normalizeTwitchRole,
29
+ aliases: ["owner", "vip", "subscriber"].map((role) => ({
30
+ key: `role-${role}`,
31
+ kind: "role",
32
+ normalizeEntry: () => null,
33
+ normalizeSubject: normalizeTwitchRole,
34
+ })),
35
+ isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
36
+ resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`,
37
+ });
38
+
39
+ export async function checkTwitchAccessControl(params: {
40
+ message: TwitchChatMessage;
41
+ account: TwitchAccountConfig;
42
+ botUsername: string;
43
+ }): Promise<TwitchAccessControlResult> {
44
+ const { message, account, botUsername } = params;
45
+ const policyKind = resolveTwitchPolicyKind(account);
46
+ const resolved = await createChannelIngressResolver({
47
+ channelId: "twitch",
48
+ accountId: "default",
49
+ identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity,
50
+ }).message({
51
+ subject:
52
+ policyKind === "role"
53
+ ? twitchRoleSubject(message)
54
+ : ({ stableId: message.userId } satisfies ChannelIngressIdentitySubjectInput),
55
+ conversation: {
56
+ kind: "group",
57
+ id: message.channel,
58
+ },
59
+ event: { mayPair: false },
60
+ mentionFacts: {
61
+ canDetectMention: true,
62
+ wasMentioned: mentionsBot(message.message, botUsername),
63
+ },
64
+ dmPolicy: "open",
65
+ groupPolicy: policyKind === "open" ? "open" : "allowlist",
66
+ policy: {
67
+ activation: {
68
+ requireMention: account.requireMention ?? true,
69
+ allowTextCommands: false,
70
+ order: "before-sender",
71
+ },
72
+ },
73
+ groupAllowFrom:
74
+ policyKind === "allowFrom"
75
+ ? account.allowFrom
76
+ : policyKind === "role"
77
+ ? account.allowedRoles
78
+ : undefined,
79
+ });
80
+ const decision = resolved.ingress;
81
+
82
+ if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") {
83
+ return {
84
+ allowed: false,
85
+ reason: "message does not mention the bot (requireMention is enabled)",
86
+ };
87
+ }
88
+
89
+ if (decision.admission === "dispatch") {
90
+ if (policyKind === "allowFrom") {
91
+ return {
92
+ allowed: true,
93
+ matchKey: params.message.userId,
94
+ matchSource: "allowlist",
95
+ };
96
+ }
97
+ if (policyKind === "role") {
98
+ return {
99
+ allowed: true,
100
+ matchKey: params.account.allowedRoles?.join(","),
101
+ matchSource: "role",
102
+ };
103
+ }
104
+ return {
105
+ allowed: true,
106
+ };
107
+ }
108
+
109
+ if (policyKind === "allowFrom") {
110
+ if (!params.message.userId) {
111
+ return {
112
+ allowed: false,
113
+ reason: "sender user ID not available for allowlist check",
114
+ };
115
+ }
116
+ return {
117
+ allowed: false,
118
+ reason: "sender is not in allowFrom allowlist",
119
+ };
120
+ }
121
+
122
+ if (policyKind === "role") {
123
+ return {
124
+ allowed: false,
125
+ reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`,
126
+ };
127
+ }
128
+
129
+ return {
130
+ allowed: false,
131
+ reason: reasonForTwitchIngressDecision(decision),
132
+ };
133
+ }
134
+
135
+ function resolveTwitchPolicyKind(account: TwitchAccountConfig): TwitchPolicyKind {
136
+ if (account.allowFrom !== undefined) {
137
+ return "allowFrom";
138
+ }
139
+ if (account.allowedRoles && account.allowedRoles.length > 0) {
140
+ return "role";
141
+ }
142
+ return "open";
143
+ }
144
+
145
+ function twitchRoleSubject(message: TwitchChatMessage): ChannelIngressIdentitySubjectInput {
146
+ return {
147
+ stableId: message.isMod ? "moderator" : undefined,
148
+ aliases: {
149
+ "role-owner": message.isOwner ? "owner" : undefined,
150
+ "role-vip": message.isVip ? "vip" : undefined,
151
+ "role-subscriber": message.isSub ? "subscriber" : undefined,
152
+ },
153
+ };
154
+ }
155
+
156
+ function normalizeTwitchRole(value: string): string | null {
157
+ const role = normalizeLowercaseStringOrEmpty(value);
158
+ if (role === "*") {
159
+ return "all";
160
+ }
161
+ return role === "moderator" ||
162
+ role === "owner" ||
163
+ role === "vip" ||
164
+ role === "subscriber" ||
165
+ role === "all"
166
+ ? role
167
+ : null;
168
+ }
169
+
170
+ function reasonForTwitchIngressDecision(decision: { reasonCode: IngressReasonCode }): string {
171
+ switch (decision.reasonCode) {
172
+ case "activation_skipped":
173
+ return "message does not mention the bot (requireMention is enabled)";
174
+ case "group_policy_empty_allowlist":
175
+ case "group_policy_not_allowlisted":
176
+ return "sender is not in allowFrom allowlist";
177
+ default:
178
+ return decision.reasonCode;
179
+ }
180
+ }
181
+
182
+ function mentionsBot(message: string, botUsername: string): boolean {
183
+ const expected = normalizeLowercaseStringOrEmpty(botUsername);
184
+ const mentionRegex = /@(\w+)/g;
185
+ let match: RegExpExecArray | null;
186
+
187
+ while ((match = mentionRegex.exec(message)) !== null) {
188
+ const username = match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "";
189
+ if (username === expected) {
190
+ return true;
191
+ }
192
+ }
193
+
194
+ return false;
195
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { twitchMessageActions } from "./actions.js";
3
+ import type { ResolvedTwitchAccountContext } from "./config.js";
4
+ import { resolveTwitchAccountContext } from "./config.js";
5
+ import { twitchOutbound } from "./outbound.js";
6
+
7
+ vi.mock("./config.js", () => ({
8
+ DEFAULT_ACCOUNT_ID: "default",
9
+ resolveTwitchAccountContext: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("./outbound.js", () => ({
13
+ twitchOutbound: {
14
+ sendText: vi.fn(),
15
+ },
16
+ }));
17
+
18
+ function createSecondaryAccountContext(accountId = "secondary"): ResolvedTwitchAccountContext {
19
+ return {
20
+ accountId,
21
+ account: {
22
+ channel: "secondary-channel",
23
+ username: "secondary",
24
+ accessToken: "oauth:secondary-token",
25
+ clientId: "secondary-client",
26
+ enabled: true,
27
+ },
28
+ tokenResolution: { source: "config", token: "oauth:secondary-token" },
29
+ configured: true,
30
+ availableAccountIds: ["default", "secondary"],
31
+ };
32
+ }
33
+
34
+ describe("twitchMessageActions", () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ it("uses configured defaultAccount when action accountId is omitted", async () => {
40
+ vi.mocked(resolveTwitchAccountContext)
41
+ .mockImplementationOnce(() => createSecondaryAccountContext())
42
+ .mockImplementation((_cfg, accountId) =>
43
+ createSecondaryAccountContext(accountId?.trim() || "secondary"),
44
+ );
45
+ const sendText = twitchOutbound.sendText;
46
+ if (!sendText) {
47
+ throw new Error("twitchOutbound.sendText is unavailable");
48
+ }
49
+ vi.mocked(sendText).mockResolvedValue({
50
+ channel: "twitch",
51
+ messageId: "msg-1",
52
+ timestamp: 1,
53
+ });
54
+ const cfg = {
55
+ channels: {
56
+ twitch: {
57
+ defaultAccount: "secondary",
58
+ },
59
+ },
60
+ };
61
+
62
+ await twitchMessageActions.handleAction!({
63
+ action: "send",
64
+ params: { message: "Hello!" },
65
+ cfg,
66
+ } as never);
67
+
68
+ expect(twitchOutbound.sendText).toHaveBeenCalledWith({
69
+ cfg,
70
+ to: "secondary-channel",
71
+ text: "Hello!",
72
+ accountId: "secondary",
73
+ });
74
+ });
75
+ });
package/src/actions.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Twitch message actions adapter.
3
+ *
4
+ * Handles tool-based actions for Twitch, such as sending messages.
5
+ */
6
+
7
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
8
+ import { resolveTwitchAccountContext } from "./config.js";
9
+ import { twitchOutbound } from "./outbound.js";
10
+ import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
11
+
12
+ /**
13
+ * Create a tool result with error content.
14
+ */
15
+ function errorResponse(error: string) {
16
+ return {
17
+ content: [
18
+ {
19
+ type: "text" as const,
20
+ text: JSON.stringify({ ok: false, error }),
21
+ },
22
+ ],
23
+ details: { ok: false },
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Read a string parameter from action arguments.
29
+ *
30
+ * @param args - Action arguments
31
+ * @param key - Parameter key
32
+ * @param options - Options for reading the parameter
33
+ * @returns The parameter value or undefined if not found
34
+ */
35
+ function readStringParam(
36
+ args: Record<string, unknown>,
37
+ key: string,
38
+ options: { required?: boolean; trim?: boolean } = {},
39
+ ): string | undefined {
40
+ const value = args[key];
41
+ if (value === undefined || value === null) {
42
+ if (options.required) {
43
+ throw new Error(`Missing required parameter: ${key}`);
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ // Convert value to string safely
49
+ if (typeof value === "string") {
50
+ return options.trim !== false ? value.trim() : value;
51
+ }
52
+
53
+ if (typeof value === "number" || typeof value === "boolean") {
54
+ const str = String(value);
55
+ return options.trim !== false ? str.trim() : str;
56
+ }
57
+
58
+ throw new Error(`Parameter ${key} must be a string, number, or boolean`);
59
+ }
60
+
61
+ /** Supported Twitch actions */
62
+ const TWITCH_ACTIONS = new Set(["send" as const]);
63
+ type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
64
+
65
+ /**
66
+ * Twitch message actions adapter.
67
+ */
68
+ export const twitchMessageActions: ChannelMessageActionAdapter = {
69
+ /**
70
+ * List available actions for this channel.
71
+ */
72
+ describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
73
+
74
+ /**
75
+ * Check if an action is supported.
76
+ */
77
+ supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
78
+
79
+ /**
80
+ * Extract tool send parameters from action arguments.
81
+ *
82
+ * Parses and validates the "to" and "message" parameters for sending.
83
+ *
84
+ * @param params - Arguments from the tool call
85
+ * @returns Parsed send parameters or null if invalid
86
+ *
87
+ * @example
88
+ * const result = twitchMessageActions.extractToolSend!({
89
+ * args: { to: "#mychannel", message: "Hello!" }
90
+ * });
91
+ * // Returns: { to: "#mychannel", message: "Hello!" }
92
+ */
93
+ extractToolSend: ({ args }) => {
94
+ try {
95
+ const to = readStringParam(args, "to", { required: true });
96
+ const message = readStringParam(args, "message", { required: true });
97
+
98
+ if (!to || !message) {
99
+ return null;
100
+ }
101
+
102
+ return { to, message };
103
+ } catch {
104
+ return null;
105
+ }
106
+ },
107
+
108
+ /**
109
+ * Handle an action execution.
110
+ *
111
+ * Processes the "send" action to send messages to Twitch.
112
+ *
113
+ * @param ctx - Action context including action type, parameters, and config
114
+ * @returns Tool result with content or null if action not supported
115
+ *
116
+ * @example
117
+ * const result = await twitchMessageActions.handleAction!({
118
+ * action: "send",
119
+ * params: { message: "Hello Twitch!", to: "#mychannel" },
120
+ * cfg: klawConfig,
121
+ * accountId: "default",
122
+ * });
123
+ */
124
+ handleAction: async (ctx: ChannelMessageActionContext) => {
125
+ if (ctx.action !== "send") {
126
+ return {
127
+ content: [{ type: "text" as const, text: "Unsupported action" }],
128
+ details: { ok: false, error: "Unsupported action" },
129
+ };
130
+ }
131
+
132
+ const message = readStringParam(ctx.params, "message", { required: true });
133
+ const to = readStringParam(ctx.params, "to", { required: false });
134
+ const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
135
+
136
+ const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
137
+ if (!account) {
138
+ return errorResponse(
139
+ `Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
140
+ );
141
+ }
142
+
143
+ // Use the channel from account config (or override with `to` parameter)
144
+ const targetChannel = to || account.channel;
145
+ if (!targetChannel) {
146
+ return errorResponse("No channel specified and no default channel in account config");
147
+ }
148
+
149
+ if (!twitchOutbound.sendText) {
150
+ return errorResponse("sendText not implemented");
151
+ }
152
+
153
+ try {
154
+ const result = await twitchOutbound.sendText({
155
+ cfg: ctx.cfg,
156
+ to: targetChannel,
157
+ text: message ?? "",
158
+ accountId,
159
+ });
160
+
161
+ return {
162
+ content: [
163
+ {
164
+ type: "text" as const,
165
+ text: JSON.stringify(result),
166
+ },
167
+ ],
168
+ details: { ok: true },
169
+ };
170
+ } catch (error) {
171
+ const errorMsg = formatErrorMessage(error);
172
+ return errorResponse(errorMsg);
173
+ }
174
+ },
175
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Client manager registry for Twitch plugin.
3
+ *
4
+ * Manages the lifecycle of TwitchClientManager instances across the plugin,
5
+ * ensuring proper cleanup when accounts are stopped or reconfigured.
6
+ */
7
+
8
+ import { TwitchClientManager } from "./twitch-client.js";
9
+ import type { ChannelLogSink } from "./types.js";
10
+
11
+ /**
12
+ * Registry entry tracking a client manager and its associated account.
13
+ */
14
+ type RegistryEntry = {
15
+ /** The client manager instance */
16
+ manager: TwitchClientManager;
17
+ /** The account ID this manager is for */
18
+ accountId: string;
19
+ /** Logger for this entry */
20
+ logger: ChannelLogSink;
21
+ /** When this entry was created */
22
+ createdAt: number;
23
+ };
24
+
25
+ /**
26
+ * Global registry of client managers.
27
+ * Keyed by account ID.
28
+ */
29
+ const registry = new Map<string, RegistryEntry>();
30
+
31
+ /**
32
+ * Get or create a client manager for an account.
33
+ *
34
+ * @param accountId - The account ID
35
+ * @param logger - Logger instance
36
+ * @returns The client manager
37
+ */
38
+ export function getOrCreateClientManager(
39
+ accountId: string,
40
+ logger: ChannelLogSink,
41
+ ): TwitchClientManager {
42
+ const existing = registry.get(accountId);
43
+ if (existing) {
44
+ return existing.manager;
45
+ }
46
+
47
+ const manager = new TwitchClientManager(logger);
48
+ registry.set(accountId, {
49
+ manager,
50
+ accountId,
51
+ logger,
52
+ createdAt: Date.now(),
53
+ });
54
+
55
+ logger.info(`Registered client manager for account: ${accountId}`);
56
+ return manager;
57
+ }
58
+
59
+ /**
60
+ * Get an existing client manager for an account.
61
+ *
62
+ * @param accountId - The account ID
63
+ * @returns The client manager, or undefined if not registered
64
+ */
65
+ export function getClientManager(accountId: string): TwitchClientManager | undefined {
66
+ return registry.get(accountId)?.manager;
67
+ }
68
+
69
+ /**
70
+ * Disconnect and remove a client manager from the registry.
71
+ *
72
+ * @param accountId - The account ID
73
+ * @returns Promise that resolves when cleanup is complete
74
+ */
75
+ export async function removeClientManager(accountId: string): Promise<void> {
76
+ const entry = registry.get(accountId);
77
+ if (!entry) {
78
+ return;
79
+ }
80
+
81
+ // Disconnect the client manager
82
+ await entry.manager.disconnectAll();
83
+
84
+ // Remove from registry
85
+ registry.delete(accountId);
86
+ entry.logger.info(`Unregistered client manager for account: ${accountId}`);
87
+ }
@@ -0,0 +1,46 @@
1
+ import AjvPkg from "ajv";
2
+ import { buildChannelConfigSchema } from "klaw/plugin-sdk/channel-config-schema";
3
+ import { describe, expect, it } from "vitest";
4
+ import { TwitchConfigSchema } from "./config-schema.js";
5
+
6
+ function validateTwitchConfig(value: unknown): boolean {
7
+ const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
8
+ const schema = buildChannelConfigSchema(TwitchConfigSchema).schema;
9
+ const validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
10
+ const ok = validate(value);
11
+ if (!ok) {
12
+ throw new Error(`expected valid Twitch config: ${JSON.stringify(validate.errors)}`);
13
+ }
14
+ return true;
15
+ }
16
+
17
+ describe("TwitchConfigSchema JSON schema", () => {
18
+ it("accepts single-account channel config with base fields", () => {
19
+ expect(
20
+ validateTwitchConfig({
21
+ enabled: false,
22
+ username: "klaw",
23
+ accessToken: "oauth:test",
24
+ clientId: "test-client-id",
25
+ channel: "klaw-test",
26
+ }),
27
+ ).toBe(true);
28
+ });
29
+
30
+ it("accepts multi-account channel config with defaultAccount", () => {
31
+ expect(
32
+ validateTwitchConfig({
33
+ enabled: true,
34
+ defaultAccount: "stream",
35
+ accounts: {
36
+ stream: {
37
+ username: "klaw",
38
+ accessToken: "oauth:test",
39
+ clientId: "test-client-id",
40
+ channel: "klaw-test",
41
+ },
42
+ },
43
+ }),
44
+ ).toBe(true);
45
+ });
46
+ });
@@ -0,0 +1,88 @@
1
+ import { MarkdownConfigSchema } from "klaw/plugin-sdk/channel-config-primitives";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Twitch user roles that can be allowed to interact with the bot
6
+ */
7
+ const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
8
+
9
+ const TwitchAccountShape = {
10
+ /** Twitch username */
11
+ username: z.string(),
12
+ /** Twitch OAuth access token (requires chat:read and chat:write scopes) */
13
+ accessToken: z.string(),
14
+ /** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
15
+ clientId: z.string().optional(),
16
+ /** Channel name to join */
17
+ channel: z.string().min(1),
18
+ /** Enable this account */
19
+ enabled: z.boolean().optional(),
20
+ /** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
21
+ allowFrom: z.array(z.string()).optional(),
22
+ /** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
23
+ allowedRoles: z.array(TwitchRoleSchema).optional(),
24
+ /** Require @mention to trigger bot responses */
25
+ requireMention: z.boolean().optional(),
26
+ /** Outbound response prefix override for this channel/account. */
27
+ responsePrefix: z.string().optional(),
28
+ /** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
29
+ clientSecret: z.string().optional(),
30
+ /** Refresh token (required for automatic token refresh) */
31
+ refreshToken: z.string().optional(),
32
+ /** Token expiry time in seconds (optional, for token refresh tracking) */
33
+ expiresIn: z.number().nullable().optional(),
34
+ /** Timestamp when token was obtained (optional, for token refresh tracking) */
35
+ obtainmentTimestamp: z.number().optional(),
36
+ };
37
+
38
+ /**
39
+ * Twitch account configuration schema
40
+ */
41
+ const TwitchAccountSchema = z.object(TwitchAccountShape);
42
+
43
+ /**
44
+ * Base configuration properties shared by both single and multi-account modes
45
+ */
46
+ const TwitchConfigBaseShape = {
47
+ name: z.string().optional(),
48
+ enabled: z.boolean().optional(),
49
+ markdown: MarkdownConfigSchema.optional(),
50
+ defaultAccount: z.string().optional(),
51
+ };
52
+
53
+ /**
54
+ * Simplified single-account configuration schema
55
+ *
56
+ * Use this for single-account setups. Properties are at the top level,
57
+ * creating an implicit "default" account.
58
+ */
59
+ const SimplifiedSchema = z.object({
60
+ ...TwitchConfigBaseShape,
61
+ ...TwitchAccountShape,
62
+ });
63
+
64
+ /**
65
+ * Multi-account configuration schema
66
+ *
67
+ * Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
68
+ */
69
+ const MultiAccountSchema = z
70
+ .object({
71
+ ...TwitchConfigBaseShape,
72
+ /** Per-account configuration (for multi-account setups) */
73
+ accounts: z.record(z.string(), TwitchAccountSchema),
74
+ })
75
+ .refine((val) => Object.keys(val.accounts || {}).length > 0, {
76
+ message: "accounts must contain at least one entry",
77
+ });
78
+
79
+ /**
80
+ * Twitch plugin configuration schema
81
+ *
82
+ * Supports two mutually exclusive patterns:
83
+ * 1. Simplified single-account: username, accessToken, clientId, channel at top level
84
+ * 2. Multi-account: accounts object with named account configs
85
+ *
86
+ * The union ensures clear discrimination between the two modes.
87
+ */
88
+ export const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);