@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,233 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getAccountConfig,
|
|
4
|
+
listAccountIds,
|
|
5
|
+
resolveDefaultTwitchAccountId,
|
|
6
|
+
resolveTwitchAccountContext,
|
|
7
|
+
} from "./config.js";
|
|
8
|
+
|
|
9
|
+
describe("getAccountConfig", () => {
|
|
10
|
+
const mockMultiAccountConfig = {
|
|
11
|
+
channels: {
|
|
12
|
+
twitch: {
|
|
13
|
+
accounts: {
|
|
14
|
+
default: {
|
|
15
|
+
username: "testbot",
|
|
16
|
+
accessToken: "oauth:test123",
|
|
17
|
+
},
|
|
18
|
+
secondary: {
|
|
19
|
+
username: "secondbot",
|
|
20
|
+
accessToken: "oauth:secondary",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const mockSimplifiedConfig = {
|
|
28
|
+
channels: {
|
|
29
|
+
twitch: {
|
|
30
|
+
username: "testbot",
|
|
31
|
+
accessToken: "oauth:test123",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
it("returns account config for valid account ID (multi-account)", () => {
|
|
37
|
+
const result = getAccountConfig(mockMultiAccountConfig, "default");
|
|
38
|
+
|
|
39
|
+
expect(result?.username).toBe("testbot");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns account config for default account (simplified config)", () => {
|
|
43
|
+
const result = getAccountConfig(mockSimplifiedConfig, "default");
|
|
44
|
+
|
|
45
|
+
expect(result?.username).toBe("testbot");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns non-default account from multi-account config", () => {
|
|
49
|
+
const result = getAccountConfig(mockMultiAccountConfig, "secondary");
|
|
50
|
+
|
|
51
|
+
expect(result?.username).toBe("secondbot");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("normalizes account ids without reading inherited account properties", () => {
|
|
55
|
+
const accounts = Object.create({
|
|
56
|
+
inherited: {
|
|
57
|
+
username: "inherited-bot",
|
|
58
|
+
accessToken: "oauth:inherited",
|
|
59
|
+
},
|
|
60
|
+
}) as Record<string, unknown>;
|
|
61
|
+
accounts.Secondary = {
|
|
62
|
+
username: "secondbot",
|
|
63
|
+
accessToken: "oauth:secondary",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const cfg = {
|
|
67
|
+
channels: {
|
|
68
|
+
twitch: {
|
|
69
|
+
accounts,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
expect(getAccountConfig(cfg, "SECONDARY\r\n")).toEqual({
|
|
75
|
+
username: "secondbot",
|
|
76
|
+
accessToken: "oauth:secondary",
|
|
77
|
+
});
|
|
78
|
+
expect(getAccountConfig(cfg, "inherited")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns null for non-existent account ID", () => {
|
|
82
|
+
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
|
|
83
|
+
|
|
84
|
+
expect(result).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns null when core config is null", () => {
|
|
88
|
+
const result = getAccountConfig(null, "default");
|
|
89
|
+
|
|
90
|
+
expect(result).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns null when core config is undefined", () => {
|
|
94
|
+
const result = getAccountConfig(undefined, "default");
|
|
95
|
+
|
|
96
|
+
expect(result).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null when channels are not defined", () => {
|
|
100
|
+
const result = getAccountConfig({}, "default");
|
|
101
|
+
|
|
102
|
+
expect(result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns null when twitch is not defined", () => {
|
|
106
|
+
const result = getAccountConfig({ channels: {} }, "default");
|
|
107
|
+
|
|
108
|
+
expect(result).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns null when accounts are not defined", () => {
|
|
112
|
+
const result = getAccountConfig({ channels: { twitch: {} } }, "default");
|
|
113
|
+
|
|
114
|
+
expect(result).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("listAccountIds", () => {
|
|
119
|
+
it("includes the implicit default account from simplified config", () => {
|
|
120
|
+
expect(
|
|
121
|
+
listAccountIds({
|
|
122
|
+
channels: {
|
|
123
|
+
twitch: {
|
|
124
|
+
username: "testbot",
|
|
125
|
+
accessToken: "oauth:test123",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
} as Parameters<typeof listAccountIds>[0]),
|
|
129
|
+
).toEqual(["default"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("combines explicit accounts with the implicit default account once", () => {
|
|
133
|
+
expect(
|
|
134
|
+
listAccountIds({
|
|
135
|
+
channels: {
|
|
136
|
+
twitch: {
|
|
137
|
+
username: "testbot",
|
|
138
|
+
accounts: {
|
|
139
|
+
default: { username: "testbot" },
|
|
140
|
+
secondary: { username: "secondbot" },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
} as Parameters<typeof listAccountIds>[0]),
|
|
145
|
+
).toEqual(["default", "secondary"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("normalizes configured account ids", () => {
|
|
149
|
+
expect(
|
|
150
|
+
listAccountIds({
|
|
151
|
+
channels: {
|
|
152
|
+
twitch: {
|
|
153
|
+
accounts: {
|
|
154
|
+
Secondary: { username: "secondbot" },
|
|
155
|
+
"Alerts\r\n\u001b[31m": { username: "alerts" },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
} as Parameters<typeof listAccountIds>[0]),
|
|
160
|
+
).toEqual(["alerts-31m", "secondary"]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("resolveDefaultTwitchAccountId", () => {
|
|
165
|
+
it("prefers channels.twitch.defaultAccount when configured", () => {
|
|
166
|
+
expect(
|
|
167
|
+
resolveDefaultTwitchAccountId({
|
|
168
|
+
channels: {
|
|
169
|
+
twitch: {
|
|
170
|
+
defaultAccount: "secondary",
|
|
171
|
+
accounts: {
|
|
172
|
+
default: { username: "default" },
|
|
173
|
+
secondary: { username: "secondary" },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
} as Parameters<typeof resolveDefaultTwitchAccountId>[0]),
|
|
178
|
+
).toBe("secondary");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("resolveTwitchAccountContext", () => {
|
|
183
|
+
it("uses configured defaultAccount when accountId is omitted", () => {
|
|
184
|
+
const context = resolveTwitchAccountContext({
|
|
185
|
+
channels: {
|
|
186
|
+
twitch: {
|
|
187
|
+
defaultAccount: "secondary",
|
|
188
|
+
accounts: {
|
|
189
|
+
default: {
|
|
190
|
+
username: "default-bot",
|
|
191
|
+
accessToken: "oauth:default-token",
|
|
192
|
+
},
|
|
193
|
+
secondary: {
|
|
194
|
+
username: "second-bot",
|
|
195
|
+
accessToken: "oauth:second-token",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
} as Parameters<typeof resolveTwitchAccountContext>[0]);
|
|
201
|
+
|
|
202
|
+
expect(context.accountId).toBe("secondary");
|
|
203
|
+
expect(context.account?.username).toBe("second-bot");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("keeps account and token lookup aligned after account id normalization", () => {
|
|
207
|
+
const context = resolveTwitchAccountContext(
|
|
208
|
+
{
|
|
209
|
+
channels: {
|
|
210
|
+
twitch: {
|
|
211
|
+
accounts: {
|
|
212
|
+
Secondary: {
|
|
213
|
+
username: "second-bot",
|
|
214
|
+
accessToken: "oauth:second-token",
|
|
215
|
+
clientId: "second-client",
|
|
216
|
+
channel: "#second",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
} as Parameters<typeof resolveTwitchAccountContext>[0],
|
|
222
|
+
"secondary",
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(context.accountId).toBe("secondary");
|
|
226
|
+
expect(context.account?.username).toBe("second-bot");
|
|
227
|
+
expect(context.tokenResolution).toEqual({
|
|
228
|
+
token: "oauth:second-token",
|
|
229
|
+
source: "config",
|
|
230
|
+
});
|
|
231
|
+
expect(context.configured).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listCombinedAccountIds,
|
|
3
|
+
normalizeAccountId,
|
|
4
|
+
resolveNormalizedAccountEntry,
|
|
5
|
+
} from "klaw/plugin-sdk/account-resolution";
|
|
6
|
+
import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
|
|
7
|
+
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
|
|
8
|
+
import type { TwitchAccountConfig } from "./types.js";
|
|
9
|
+
import { isAccountConfigured } from "./utils/twitch.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default account ID for Twitch
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_ACCOUNT_ID = "default";
|
|
15
|
+
|
|
16
|
+
export type ResolvedTwitchAccountContext = {
|
|
17
|
+
accountId: string;
|
|
18
|
+
account: TwitchAccountConfig | null;
|
|
19
|
+
tokenResolution: TwitchTokenResolution;
|
|
20
|
+
configured: boolean;
|
|
21
|
+
availableAccountIds: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get account config from core config
|
|
26
|
+
*
|
|
27
|
+
* Handles two patterns:
|
|
28
|
+
* 1. Simplified single-account: base-level properties create implicit "default" account
|
|
29
|
+
* 2. Multi-account: explicit accounts object
|
|
30
|
+
*
|
|
31
|
+
* For "default" account, base-level properties take precedence over accounts.default
|
|
32
|
+
* For other accounts, only the accounts object is checked
|
|
33
|
+
*/
|
|
34
|
+
export function getAccountConfig(
|
|
35
|
+
coreConfig: unknown,
|
|
36
|
+
accountId: string,
|
|
37
|
+
): TwitchAccountConfig | null {
|
|
38
|
+
if (!coreConfig || typeof coreConfig !== "object") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cfg = coreConfig as KlawConfig;
|
|
43
|
+
const normalizedAccountId = normalizeAccountId(accountId);
|
|
44
|
+
const twitch = cfg.channels?.twitch;
|
|
45
|
+
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
|
46
|
+
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
|
47
|
+
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
|
|
48
|
+
|
|
49
|
+
// For default account, check base-level config first
|
|
50
|
+
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
51
|
+
const accountFromAccounts = resolveNormalizedAccountEntry(
|
|
52
|
+
accounts,
|
|
53
|
+
DEFAULT_ACCOUNT_ID,
|
|
54
|
+
normalizeAccountId,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Base-level properties that can form an implicit default account
|
|
58
|
+
const baseLevel = {
|
|
59
|
+
username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
|
|
60
|
+
accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
|
|
61
|
+
clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
|
|
62
|
+
channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
|
|
63
|
+
enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
|
|
64
|
+
allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
|
|
65
|
+
allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
|
|
66
|
+
requireMention:
|
|
67
|
+
typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
|
|
68
|
+
clientSecret:
|
|
69
|
+
typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
|
|
70
|
+
refreshToken:
|
|
71
|
+
typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
|
|
72
|
+
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
|
|
73
|
+
obtainmentTimestamp:
|
|
74
|
+
typeof twitchRaw?.obtainmentTimestamp === "number"
|
|
75
|
+
? twitchRaw.obtainmentTimestamp
|
|
76
|
+
: undefined,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Merge: base-level takes precedence over accounts.default
|
|
80
|
+
const merged: Partial<TwitchAccountConfig> = {
|
|
81
|
+
...accountFromAccounts,
|
|
82
|
+
...baseLevel,
|
|
83
|
+
} as Partial<TwitchAccountConfig>;
|
|
84
|
+
|
|
85
|
+
// Only return if we have at least username
|
|
86
|
+
if (merged.username) {
|
|
87
|
+
return merged as TwitchAccountConfig;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Fall through to accounts.default if no base-level username
|
|
91
|
+
if (accountFromAccounts) {
|
|
92
|
+
return accountFromAccounts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// For non-default accounts, only check accounts object
|
|
99
|
+
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
|
|
100
|
+
if (!account) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return account;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* List all configured account IDs
|
|
109
|
+
*
|
|
110
|
+
* Includes both explicit accounts and implicit "default" from base-level config
|
|
111
|
+
*/
|
|
112
|
+
export function listAccountIds(cfg: KlawConfig): string[] {
|
|
113
|
+
const twitch = cfg.channels?.twitch;
|
|
114
|
+
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
|
115
|
+
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
|
116
|
+
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
|
|
117
|
+
|
|
118
|
+
// Add implicit "default" if base-level config exists and "default" not already present
|
|
119
|
+
const hasBaseLevelConfig =
|
|
120
|
+
twitchRaw &&
|
|
121
|
+
(typeof twitchRaw.username === "string" ||
|
|
122
|
+
typeof twitchRaw.accessToken === "string" ||
|
|
123
|
+
typeof twitchRaw.channel === "string");
|
|
124
|
+
|
|
125
|
+
return listCombinedAccountIds({
|
|
126
|
+
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
|
|
127
|
+
normalizeAccountId(accountId),
|
|
128
|
+
),
|
|
129
|
+
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolveDefaultTwitchAccountId(cfg: KlawConfig): string {
|
|
134
|
+
const preferredRaw =
|
|
135
|
+
typeof cfg.channels?.twitch?.defaultAccount === "string"
|
|
136
|
+
? cfg.channels.twitch.defaultAccount.trim()
|
|
137
|
+
: "";
|
|
138
|
+
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
|
|
139
|
+
const ids = listAccountIds(cfg);
|
|
140
|
+
if (preferred && ids.includes(preferred)) {
|
|
141
|
+
return preferred;
|
|
142
|
+
}
|
|
143
|
+
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
144
|
+
return DEFAULT_ACCOUNT_ID;
|
|
145
|
+
}
|
|
146
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveTwitchAccountContext(
|
|
150
|
+
cfg: KlawConfig,
|
|
151
|
+
accountId?: string | null,
|
|
152
|
+
): ResolvedTwitchAccountContext {
|
|
153
|
+
const resolvedAccountId = accountId?.trim()
|
|
154
|
+
? normalizeAccountId(accountId)
|
|
155
|
+
: resolveDefaultTwitchAccountId(cfg);
|
|
156
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
157
|
+
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
|
158
|
+
return {
|
|
159
|
+
accountId: resolvedAccountId,
|
|
160
|
+
account,
|
|
161
|
+
tokenResolution,
|
|
162
|
+
configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
|
|
163
|
+
availableAccountIds: listAccountIds(cfg),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveTwitchSnapshotAccountId(
|
|
168
|
+
cfg: KlawConfig,
|
|
169
|
+
account: TwitchAccountConfig,
|
|
170
|
+
): string {
|
|
171
|
+
const twitch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
|
|
172
|
+
const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
|
|
173
|
+
const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
|
|
174
|
+
return (
|
|
175
|
+
Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? DEFAULT_ACCOUNT_ID
|
|
176
|
+
);
|
|
177
|
+
}
|