@openclaw/twitch 2026.5.2 → 2026.5.3-beta.2
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/dist/api.js +3 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/index.js +18 -0
- package/dist/markdown-MRdI1sR7.js +306 -0
- package/dist/monitor-DS0YTAPB.js +333 -0
- package/dist/plugin-BQX9GiIn.js +878 -0
- package/dist/runtime-QZ5I3GlI.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-yArVgckI.js +400 -0
- package/dist/twitch-CklAMZL5.js +131 -0
- package/package.json +20 -3
- package/api.ts +0 -21
- package/channel-plugin-api.ts +0 -1
- package/index.test.ts +0 -13
- package/index.ts +0 -16
- package/runtime-api.ts +0 -22
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/access-control.test.ts +0 -388
- package/src/access-control.ts +0 -173
- package/src/actions.test.ts +0 -74
- package/src/actions.ts +0 -175
- package/src/client-manager-registry.ts +0 -87
- package/src/config-schema.test.ts +0 -46
- package/src/config-schema.ts +0 -88
- package/src/config.test.ts +0 -233
- package/src/config.ts +0 -177
- package/src/monitor.ts +0 -314
- package/src/outbound.test.ts +0 -468
- package/src/outbound.ts +0 -186
- package/src/plugin.test.ts +0 -77
- package/src/plugin.ts +0 -208
- package/src/probe.test.ts +0 -196
- package/src/probe.ts +0 -130
- package/src/resolver.ts +0 -139
- package/src/runtime.ts +0 -9
- package/src/send.test.ts +0 -309
- package/src/send.ts +0 -139
- package/src/setup-surface.test.ts +0 -511
- package/src/setup-surface.ts +0 -520
- package/src/status.test.ts +0 -237
- package/src/status.ts +0 -179
- package/src/test-fixtures.ts +0 -30
- package/src/token.test.ts +0 -192
- package/src/token.ts +0 -93
- package/src/twitch-client.test.ts +0 -582
- package/src/twitch-client.ts +0 -276
- package/src/types.ts +0 -104
- package/src/utils/markdown.ts +0 -98
- package/src/utils/twitch.ts +0 -84
- package/test/setup.ts +0 -7
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/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 "openclaw/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,400 @@
|
|
|
1
|
+
import { n as isAccountConfigured, o as resolveTwitchToken } from "./twitch-CklAMZL5.js";
|
|
2
|
+
import { listCombinedAccountIds, normalizeAccountId, resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
|
|
3
|
+
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
|
4
|
+
import { getChatChannelMeta } from "openclaw/plugin-sdk/core";
|
|
5
|
+
import { formatDocsLink, normalizeAccountId as normalizeAccountId$1 } from "openclaw/plugin-sdk/setup";
|
|
6
|
+
//#region extensions/twitch/src/config.ts
|
|
7
|
+
/**
|
|
8
|
+
* Default account ID for Twitch
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_ACCOUNT_ID$1 = "default";
|
|
11
|
+
/**
|
|
12
|
+
* Get account config from core config
|
|
13
|
+
*
|
|
14
|
+
* Handles two patterns:
|
|
15
|
+
* 1. Simplified single-account: base-level properties create implicit "default" account
|
|
16
|
+
* 2. Multi-account: explicit accounts object
|
|
17
|
+
*
|
|
18
|
+
* For "default" account, base-level properties take precedence over accounts.default
|
|
19
|
+
* For other accounts, only the accounts object is checked
|
|
20
|
+
*/
|
|
21
|
+
function getAccountConfig(coreConfig, accountId) {
|
|
22
|
+
if (!coreConfig || typeof coreConfig !== "object") return null;
|
|
23
|
+
const cfg = coreConfig;
|
|
24
|
+
const normalizedAccountId = normalizeAccountId(accountId);
|
|
25
|
+
const twitchRaw = cfg.channels?.twitch;
|
|
26
|
+
const accounts = twitchRaw?.accounts;
|
|
27
|
+
if (normalizedAccountId === "default") {
|
|
28
|
+
const accountFromAccounts = resolveNormalizedAccountEntry(accounts, DEFAULT_ACCOUNT_ID$1, normalizeAccountId);
|
|
29
|
+
const baseLevel = {
|
|
30
|
+
username: typeof twitchRaw?.username === "string" ? twitchRaw.username : void 0,
|
|
31
|
+
accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : void 0,
|
|
32
|
+
clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : void 0,
|
|
33
|
+
channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : void 0,
|
|
34
|
+
enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : void 0,
|
|
35
|
+
allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : void 0,
|
|
36
|
+
allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : void 0,
|
|
37
|
+
requireMention: typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : void 0,
|
|
38
|
+
clientSecret: typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : void 0,
|
|
39
|
+
refreshToken: typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : void 0,
|
|
40
|
+
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : void 0,
|
|
41
|
+
obtainmentTimestamp: typeof twitchRaw?.obtainmentTimestamp === "number" ? twitchRaw.obtainmentTimestamp : void 0
|
|
42
|
+
};
|
|
43
|
+
const merged = {
|
|
44
|
+
...accountFromAccounts,
|
|
45
|
+
...baseLevel
|
|
46
|
+
};
|
|
47
|
+
if (merged.username) return merged;
|
|
48
|
+
if (accountFromAccounts) return accountFromAccounts;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
|
|
52
|
+
if (!account) return null;
|
|
53
|
+
return account;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* List all configured account IDs
|
|
57
|
+
*
|
|
58
|
+
* Includes both explicit accounts and implicit "default" from base-level config
|
|
59
|
+
*/
|
|
60
|
+
function listAccountIds(cfg) {
|
|
61
|
+
const twitchRaw = cfg.channels?.twitch;
|
|
62
|
+
const accountMap = twitchRaw?.accounts;
|
|
63
|
+
const hasBaseLevelConfig = twitchRaw && (typeof twitchRaw.username === "string" || typeof twitchRaw.accessToken === "string" || typeof twitchRaw.channel === "string");
|
|
64
|
+
return listCombinedAccountIds({
|
|
65
|
+
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) => normalizeAccountId(accountId)),
|
|
66
|
+
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID$1 : void 0
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function resolveDefaultTwitchAccountId(cfg) {
|
|
70
|
+
const preferredRaw = typeof cfg.channels?.twitch?.defaultAccount === "string" ? cfg.channels.twitch.defaultAccount.trim() : "";
|
|
71
|
+
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
|
|
72
|
+
const ids = listAccountIds(cfg);
|
|
73
|
+
if (preferred && ids.includes(preferred)) return preferred;
|
|
74
|
+
if (ids.includes("default")) return DEFAULT_ACCOUNT_ID$1;
|
|
75
|
+
return ids[0] ?? "default";
|
|
76
|
+
}
|
|
77
|
+
function resolveTwitchAccountContext(cfg, accountId) {
|
|
78
|
+
const resolvedAccountId = accountId?.trim() ? normalizeAccountId(accountId) : resolveDefaultTwitchAccountId(cfg);
|
|
79
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
80
|
+
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
|
81
|
+
return {
|
|
82
|
+
accountId: resolvedAccountId,
|
|
83
|
+
account,
|
|
84
|
+
tokenResolution,
|
|
85
|
+
configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
|
|
86
|
+
availableAccountIds: listAccountIds(cfg)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function resolveTwitchSnapshotAccountId(cfg, account) {
|
|
90
|
+
const accountMap = (cfg.channels?.twitch)?.accounts ?? {};
|
|
91
|
+
return Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? "default";
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region extensions/twitch/src/setup-surface.ts
|
|
95
|
+
/**
|
|
96
|
+
* Twitch setup wizard surface for CLI setup.
|
|
97
|
+
*/
|
|
98
|
+
const channel = "twitch";
|
|
99
|
+
const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
|
|
100
|
+
function normalizeRequestedSetupAccountId(accountId) {
|
|
101
|
+
const normalized = normalizeOptionalAccountId(accountId);
|
|
102
|
+
if (!normalized) throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
function resolveSetupAccountId(cfg, requestedAccountId) {
|
|
106
|
+
const requested = requestedAccountId?.trim();
|
|
107
|
+
if (requested) return normalizeRequestedSetupAccountId(requested);
|
|
108
|
+
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
|
|
109
|
+
return preferred ? normalizeAccountId$1(preferred) : resolveDefaultTwitchAccountId(cfg);
|
|
110
|
+
}
|
|
111
|
+
function setTwitchAccount(cfg, account, accountId = resolveSetupAccountId(cfg)) {
|
|
112
|
+
const resolvedAccountId = accountId.trim() ? normalizeRequestedSetupAccountId(accountId) : resolveSetupAccountId(cfg);
|
|
113
|
+
const existing = getAccountConfig(cfg, resolvedAccountId);
|
|
114
|
+
const merged = {
|
|
115
|
+
username: account.username ?? existing?.username ?? "",
|
|
116
|
+
accessToken: account.accessToken ?? existing?.accessToken ?? "",
|
|
117
|
+
clientId: account.clientId ?? existing?.clientId ?? "",
|
|
118
|
+
channel: account.channel ?? existing?.channel ?? "",
|
|
119
|
+
enabled: account.enabled ?? existing?.enabled ?? true,
|
|
120
|
+
allowFrom: account.allowFrom ?? existing?.allowFrom,
|
|
121
|
+
allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
|
|
122
|
+
requireMention: account.requireMention ?? existing?.requireMention,
|
|
123
|
+
clientSecret: account.clientSecret ?? existing?.clientSecret,
|
|
124
|
+
refreshToken: account.refreshToken ?? existing?.refreshToken,
|
|
125
|
+
expiresIn: account.expiresIn ?? existing?.expiresIn,
|
|
126
|
+
obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
...cfg,
|
|
130
|
+
channels: {
|
|
131
|
+
...cfg.channels,
|
|
132
|
+
twitch: {
|
|
133
|
+
...cfg.channels?.twitch,
|
|
134
|
+
enabled: true,
|
|
135
|
+
accounts: {
|
|
136
|
+
...(cfg.channels?.twitch)?.accounts,
|
|
137
|
+
[resolvedAccountId]: merged
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function noteTwitchSetupHelp(prompter) {
|
|
144
|
+
await prompter.note([
|
|
145
|
+
"Twitch requires a bot account with OAuth token.",
|
|
146
|
+
"1. Create a Twitch application at https://dev.twitch.tv/console",
|
|
147
|
+
"2. Generate a token with scopes: chat:read and chat:write",
|
|
148
|
+
" Use https://twitchtokengenerator.com/ or https://twitchapps.com/tmi/",
|
|
149
|
+
"3. Copy the token (starts with 'oauth:') and Client ID",
|
|
150
|
+
"Env vars supported: OPENCLAW_TWITCH_ACCESS_TOKEN",
|
|
151
|
+
`Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`
|
|
152
|
+
].join("\n"), "Twitch setup");
|
|
153
|
+
}
|
|
154
|
+
async function promptToken(prompter, account, envToken) {
|
|
155
|
+
const existingToken = account?.accessToken ?? "";
|
|
156
|
+
if (existingToken && !envToken) {
|
|
157
|
+
if (await prompter.confirm({
|
|
158
|
+
message: "Access token already configured. Keep it?",
|
|
159
|
+
initialValue: true
|
|
160
|
+
})) return existingToken;
|
|
161
|
+
}
|
|
162
|
+
return (await prompter.text({
|
|
163
|
+
message: "Twitch OAuth token (oauth:...)",
|
|
164
|
+
initialValue: envToken ?? "",
|
|
165
|
+
validate: (value) => {
|
|
166
|
+
const raw = value?.trim() ?? "";
|
|
167
|
+
if (!raw) return "Required";
|
|
168
|
+
if (!raw.startsWith("oauth:")) return "Token should start with 'oauth:'";
|
|
169
|
+
}
|
|
170
|
+
})).trim();
|
|
171
|
+
}
|
|
172
|
+
async function promptUsername(prompter, account) {
|
|
173
|
+
return (await prompter.text({
|
|
174
|
+
message: "Twitch bot username",
|
|
175
|
+
initialValue: account?.username ?? "",
|
|
176
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
177
|
+
})).trim();
|
|
178
|
+
}
|
|
179
|
+
async function promptClientId(prompter, account) {
|
|
180
|
+
return (await prompter.text({
|
|
181
|
+
message: "Twitch Client ID",
|
|
182
|
+
initialValue: account?.clientId ?? "",
|
|
183
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
184
|
+
})).trim();
|
|
185
|
+
}
|
|
186
|
+
async function promptChannelName(prompter, account) {
|
|
187
|
+
return (await prompter.text({
|
|
188
|
+
message: "Channel to join",
|
|
189
|
+
initialValue: account?.channel ?? "",
|
|
190
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
191
|
+
})).trim();
|
|
192
|
+
}
|
|
193
|
+
async function promptRefreshTokenSetup(prompter, account) {
|
|
194
|
+
if (!await prompter.confirm({
|
|
195
|
+
message: "Enable automatic token refresh (requires client secret and refresh token)?",
|
|
196
|
+
initialValue: Boolean(account?.clientSecret && account?.refreshToken)
|
|
197
|
+
})) return {};
|
|
198
|
+
return {
|
|
199
|
+
clientSecret: (await prompter.text({
|
|
200
|
+
message: "Twitch Client Secret (for token refresh)",
|
|
201
|
+
initialValue: account?.clientSecret ?? "",
|
|
202
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
203
|
+
})).trim() || void 0,
|
|
204
|
+
refreshToken: (await prompter.text({
|
|
205
|
+
message: "Twitch Refresh Token",
|
|
206
|
+
initialValue: account?.refreshToken ?? "",
|
|
207
|
+
validate: (value) => value?.trim() ? void 0 : "Required"
|
|
208
|
+
})).trim() || void 0
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async function configureWithEnvToken(cfg, prompter, account, envToken, forceAllowFrom, dmPolicy, accountId = resolveSetupAccountId(cfg)) {
|
|
212
|
+
const resolvedAccountId = accountId.trim() ? normalizeRequestedSetupAccountId(accountId) : resolveSetupAccountId(cfg);
|
|
213
|
+
if (resolvedAccountId !== "default") return null;
|
|
214
|
+
if (!await prompter.confirm({
|
|
215
|
+
message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?",
|
|
216
|
+
initialValue: true
|
|
217
|
+
})) return null;
|
|
218
|
+
const cfgWithAccount = setTwitchAccount(cfg, {
|
|
219
|
+
username: await promptUsername(prompter, account),
|
|
220
|
+
clientId: await promptClientId(prompter, account),
|
|
221
|
+
accessToken: "",
|
|
222
|
+
enabled: true
|
|
223
|
+
}, resolvedAccountId);
|
|
224
|
+
if (forceAllowFrom && dmPolicy.promptAllowFrom) return { cfg: await dmPolicy.promptAllowFrom({
|
|
225
|
+
cfg: cfgWithAccount,
|
|
226
|
+
prompter,
|
|
227
|
+
accountId: resolvedAccountId
|
|
228
|
+
}) };
|
|
229
|
+
return { cfg: cfgWithAccount };
|
|
230
|
+
}
|
|
231
|
+
function setTwitchAccessControl(cfg, allowedRoles, requireMention, accountId) {
|
|
232
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
233
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
234
|
+
if (!account) return cfg;
|
|
235
|
+
return setTwitchAccount(cfg, {
|
|
236
|
+
...account,
|
|
237
|
+
allowedRoles,
|
|
238
|
+
requireMention
|
|
239
|
+
}, resolvedAccountId);
|
|
240
|
+
}
|
|
241
|
+
function resolveTwitchGroupPolicy(cfg, accountId) {
|
|
242
|
+
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
|
243
|
+
if (account?.allowedRoles?.includes("all")) return "open";
|
|
244
|
+
if (account?.allowedRoles?.includes("moderator")) return "allowlist";
|
|
245
|
+
return "disabled";
|
|
246
|
+
}
|
|
247
|
+
function setTwitchGroupPolicy(cfg, policy, accountId) {
|
|
248
|
+
return setTwitchAccessControl(cfg, policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [], true, accountId);
|
|
249
|
+
}
|
|
250
|
+
const twitchDmPolicy = {
|
|
251
|
+
label: "Twitch",
|
|
252
|
+
channel,
|
|
253
|
+
policyKey: "channels.twitch.accounts.default.allowedRoles",
|
|
254
|
+
allowFromKey: "channels.twitch.accounts.default.allowFrom",
|
|
255
|
+
resolveConfigKeys: (cfg, accountId) => {
|
|
256
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
257
|
+
return {
|
|
258
|
+
policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
|
|
259
|
+
allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
getCurrent: (cfg, accountId) => {
|
|
263
|
+
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
|
264
|
+
if (account?.allowedRoles?.includes("all")) return "open";
|
|
265
|
+
if (account?.allowFrom && account.allowFrom.length > 0) return "allowlist";
|
|
266
|
+
return "disabled";
|
|
267
|
+
},
|
|
268
|
+
setPolicy: (cfg, policy, accountId) => {
|
|
269
|
+
return setTwitchAccessControl(cfg, policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"], true, accountId);
|
|
270
|
+
},
|
|
271
|
+
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
|
272
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
273
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
274
|
+
const existingAllowFrom = account?.allowFrom ?? [];
|
|
275
|
+
const allowFrom = (await prompter.text({
|
|
276
|
+
message: "Twitch allowFrom (user IDs, one per line, recommended for security)",
|
|
277
|
+
placeholder: "123456789",
|
|
278
|
+
initialValue: existingAllowFrom[0] || void 0
|
|
279
|
+
}) ?? "").split(/[\n,;]+/g).map((s) => s.trim()).filter(Boolean);
|
|
280
|
+
return setTwitchAccount(cfg, {
|
|
281
|
+
...account ?? void 0,
|
|
282
|
+
allowFrom
|
|
283
|
+
}, resolvedAccountId);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const twitchGroupAccess = {
|
|
287
|
+
label: "Twitch chat",
|
|
288
|
+
placeholder: "",
|
|
289
|
+
skipAllowlistEntries: true,
|
|
290
|
+
currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
|
|
291
|
+
currentEntries: ({ cfg, accountId }) => {
|
|
292
|
+
return getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId))?.allowFrom ?? [];
|
|
293
|
+
},
|
|
294
|
+
updatePrompt: ({ cfg, accountId }) => {
|
|
295
|
+
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
|
296
|
+
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
|
|
297
|
+
},
|
|
298
|
+
setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
|
|
299
|
+
resolveAllowlist: async () => [],
|
|
300
|
+
applyAllowlist: ({ cfg }) => cfg
|
|
301
|
+
};
|
|
302
|
+
const twitchSetupAdapter = {
|
|
303
|
+
resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
|
|
304
|
+
applyAccountConfig: ({ cfg, accountId }) => setTwitchAccount(cfg, { enabled: true }, accountId)
|
|
305
|
+
};
|
|
306
|
+
const twitchSetupWizard = {
|
|
307
|
+
channel,
|
|
308
|
+
resolveAccountIdForConfigure: ({ cfg, accountOverride }) => resolveSetupAccountId(cfg, accountOverride),
|
|
309
|
+
resolveShouldPromptAccountIds: () => false,
|
|
310
|
+
status: {
|
|
311
|
+
configuredLabel: "configured",
|
|
312
|
+
unconfiguredLabel: "needs username, token, and clientId",
|
|
313
|
+
configuredHint: "configured",
|
|
314
|
+
unconfiguredHint: "needs setup",
|
|
315
|
+
resolveConfigured: ({ cfg, accountId }) => {
|
|
316
|
+
return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
|
|
317
|
+
},
|
|
318
|
+
resolveStatusLines: ({ cfg, accountId }) => {
|
|
319
|
+
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
|
320
|
+
const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
|
|
321
|
+
return [`Twitch${resolvedAccountId !== "default" ? ` (${resolvedAccountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`];
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
credentials: [],
|
|
325
|
+
finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
|
|
326
|
+
const accountId = resolveSetupAccountId(cfg, requestedAccountId);
|
|
327
|
+
const account = getAccountConfig(cfg, accountId);
|
|
328
|
+
if (!account || !isAccountConfigured(account)) await noteTwitchSetupHelp(prompter);
|
|
329
|
+
const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
|
|
330
|
+
if (accountId === "default" && envToken && !account?.accessToken) {
|
|
331
|
+
const envResult = await configureWithEnvToken(cfg, prompter, account, envToken, forceAllowFrom, twitchDmPolicy, accountId);
|
|
332
|
+
if (envResult) return envResult;
|
|
333
|
+
}
|
|
334
|
+
const username = await promptUsername(prompter, account);
|
|
335
|
+
const token = await promptToken(prompter, account, envToken);
|
|
336
|
+
const clientId = await promptClientId(prompter, account);
|
|
337
|
+
const channelName = await promptChannelName(prompter, account);
|
|
338
|
+
const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
|
|
339
|
+
const cfgWithAccount = setTwitchAccount(cfg, {
|
|
340
|
+
username,
|
|
341
|
+
accessToken: token,
|
|
342
|
+
clientId,
|
|
343
|
+
channel: channelName,
|
|
344
|
+
clientSecret,
|
|
345
|
+
refreshToken,
|
|
346
|
+
enabled: true
|
|
347
|
+
}, accountId);
|
|
348
|
+
return { cfg: forceAllowFrom && twitchDmPolicy.promptAllowFrom ? await twitchDmPolicy.promptAllowFrom({
|
|
349
|
+
cfg: cfgWithAccount,
|
|
350
|
+
prompter,
|
|
351
|
+
accountId
|
|
352
|
+
}) : cfgWithAccount };
|
|
353
|
+
},
|
|
354
|
+
dmPolicy: twitchDmPolicy,
|
|
355
|
+
groupAccess: twitchGroupAccess,
|
|
356
|
+
disable: (cfg) => {
|
|
357
|
+
const twitch = cfg.channels?.twitch;
|
|
358
|
+
return {
|
|
359
|
+
...cfg,
|
|
360
|
+
channels: {
|
|
361
|
+
...cfg.channels,
|
|
362
|
+
twitch: {
|
|
363
|
+
...twitch,
|
|
364
|
+
enabled: false
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const twitchSetupPlugin = {
|
|
371
|
+
id: channel,
|
|
372
|
+
meta: getChatChannelMeta(channel),
|
|
373
|
+
capabilities: { chatTypes: ["group"] },
|
|
374
|
+
config: {
|
|
375
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
376
|
+
resolveAccount: (cfg, accountId) => {
|
|
377
|
+
const resolvedAccountId = normalizeAccountId$1(accountId ?? resolveDefaultTwitchAccountId(cfg));
|
|
378
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
379
|
+
if (!account) return {
|
|
380
|
+
accountId: resolvedAccountId,
|
|
381
|
+
username: "",
|
|
382
|
+
accessToken: "",
|
|
383
|
+
clientId: "",
|
|
384
|
+
channel: "",
|
|
385
|
+
enabled: false
|
|
386
|
+
};
|
|
387
|
+
return {
|
|
388
|
+
accountId: resolvedAccountId,
|
|
389
|
+
...account
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
|
|
393
|
+
isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
|
|
394
|
+
isEnabled: (account) => account.enabled !== false
|
|
395
|
+
},
|
|
396
|
+
setup: twitchSetupAdapter,
|
|
397
|
+
setupWizard: twitchSetupWizard
|
|
398
|
+
};
|
|
399
|
+
//#endregion
|
|
400
|
+
export { getAccountConfig as a, resolveTwitchAccountContext as c, DEFAULT_ACCOUNT_ID$1 as i, resolveTwitchSnapshotAccountId as l, twitchSetupPlugin as n, listAccountIds as o, twitchSetupWizard as r, resolveDefaultTwitchAccountId as s, twitchSetupAdapter as t };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
//#region extensions/twitch/src/token.ts
|
|
4
|
+
/**
|
|
5
|
+
* Twitch access token resolution with environment variable support.
|
|
6
|
+
*
|
|
7
|
+
* Supports reading Twitch OAuth access tokens from config or environment variable.
|
|
8
|
+
* The OPENCLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
|
|
9
|
+
*
|
|
10
|
+
* Token resolution priority:
|
|
11
|
+
* 1. Account access token from merged config (accounts.{id} or base-level for default)
|
|
12
|
+
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a Twitch OAuth token - ensure it has the oauth: prefix
|
|
16
|
+
*/
|
|
17
|
+
function normalizeTwitchToken(raw) {
|
|
18
|
+
if (!raw) return;
|
|
19
|
+
const trimmed = raw.trim();
|
|
20
|
+
if (!trimmed) return;
|
|
21
|
+
return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve Twitch access token from config or environment variable.
|
|
25
|
+
*
|
|
26
|
+
* Priority:
|
|
27
|
+
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
|
|
28
|
+
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
|
29
|
+
*
|
|
30
|
+
* The getAccountConfig function handles merging base-level config with accounts.default,
|
|
31
|
+
* so this logic works for both simplified and multi-account patterns.
|
|
32
|
+
*
|
|
33
|
+
* @param cfg - OpenClaw config
|
|
34
|
+
* @param opts - Options including accountId and optional envToken override
|
|
35
|
+
* @returns Token resolution with source
|
|
36
|
+
*/
|
|
37
|
+
function resolveTwitchToken(cfg, opts = {}) {
|
|
38
|
+
const accountId = normalizeAccountId(opts.accountId);
|
|
39
|
+
const twitchCfg = cfg?.channels?.twitch;
|
|
40
|
+
const accounts = twitchCfg?.accounts;
|
|
41
|
+
const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
|
|
42
|
+
let token;
|
|
43
|
+
if (accountId === DEFAULT_ACCOUNT_ID) token = normalizeTwitchToken((typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : void 0) || accountCfg?.accessToken);
|
|
44
|
+
else token = normalizeTwitchToken(accountCfg?.accessToken);
|
|
45
|
+
if (token) return {
|
|
46
|
+
token,
|
|
47
|
+
source: "config"
|
|
48
|
+
};
|
|
49
|
+
const envToken = accountId === DEFAULT_ACCOUNT_ID ? normalizeTwitchToken(opts.envToken ?? process.env.OPENCLAW_TWITCH_ACCESS_TOKEN) : void 0;
|
|
50
|
+
if (envToken) return {
|
|
51
|
+
token: envToken,
|
|
52
|
+
source: "env"
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
token: "",
|
|
56
|
+
source: "none"
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region extensions/twitch/src/utils/twitch.ts
|
|
61
|
+
/**
|
|
62
|
+
* Twitch-specific utility functions
|
|
63
|
+
*/
|
|
64
|
+
function normalizeLowercaseStringOrEmpty(value) {
|
|
65
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Normalize Twitch channel names.
|
|
69
|
+
*
|
|
70
|
+
* Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
|
|
71
|
+
* Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
|
|
72
|
+
*
|
|
73
|
+
* @param channel - The channel name to normalize
|
|
74
|
+
* @returns Normalized channel name
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
|
|
78
|
+
* normalizeTwitchChannel("MyChannel") // "mychannel"
|
|
79
|
+
*/
|
|
80
|
+
function normalizeTwitchChannel(channel) {
|
|
81
|
+
const trimmed = normalizeLowercaseStringOrEmpty(channel);
|
|
82
|
+
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Create a standardized error message for missing target.
|
|
86
|
+
*
|
|
87
|
+
* @param provider - The provider name (e.g., "Twitch")
|
|
88
|
+
* @param hint - Optional hint for how to fix the issue
|
|
89
|
+
* @returns Error object with descriptive message
|
|
90
|
+
*/
|
|
91
|
+
function missingTargetError(provider, hint) {
|
|
92
|
+
return /* @__PURE__ */ new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Generate a unique message ID for Twitch messages.
|
|
96
|
+
*
|
|
97
|
+
* Twurple's say() doesn't return the message ID, so we generate one
|
|
98
|
+
* for tracking purposes.
|
|
99
|
+
*
|
|
100
|
+
* @returns A unique message ID
|
|
101
|
+
*/
|
|
102
|
+
function generateMessageId() {
|
|
103
|
+
return `${Date.now()}-${randomUUID()}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Normalize OAuth token by removing the "oauth:" prefix if present.
|
|
107
|
+
*
|
|
108
|
+
* Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
|
|
109
|
+
*
|
|
110
|
+
* @param token - The OAuth token to normalize
|
|
111
|
+
* @returns Normalized token without "oauth:" prefix
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* normalizeToken("oauth:abc123") // "abc123"
|
|
115
|
+
* normalizeToken("abc123") // "abc123"
|
|
116
|
+
*/
|
|
117
|
+
function normalizeToken(token) {
|
|
118
|
+
return token.startsWith("oauth:") ? token.slice(6) : token;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Check if an account is properly configured with required credentials.
|
|
122
|
+
*
|
|
123
|
+
* @param account - The Twitch account config to check
|
|
124
|
+
* @returns true if the account has required credentials
|
|
125
|
+
*/
|
|
126
|
+
function isAccountConfigured(account, resolvedToken) {
|
|
127
|
+
const token = resolvedToken ?? account?.accessToken;
|
|
128
|
+
return Boolean(account?.username && token && account?.clientId);
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { normalizeTwitchChannel as a, normalizeToken as i, isAccountConfigured as n, resolveTwitchToken as o, missingTargetError as r, generateMessageId as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/twitch",
|
|
3
|
-
"version": "2026.5.2",
|
|
3
|
+
"version": "2026.5.3-beta.2",
|
|
4
4
|
"description": "OpenClaw Twitch channel plugin",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"minHostVersion": ">=2026.4.10"
|
|
27
27
|
},
|
|
28
28
|
"compat": {
|
|
29
|
-
"pluginApi": ">=2026.5.2"
|
|
29
|
+
"pluginApi": ">=2026.5.3-beta.2"
|
|
30
30
|
},
|
|
31
31
|
"build": {
|
|
32
|
-
"openclawVersion": "2026.5.2"
|
|
32
|
+
"openclawVersion": "2026.5.3-beta.2"
|
|
33
33
|
},
|
|
34
34
|
"channel": {
|
|
35
35
|
"id": "twitch",
|
|
@@ -44,6 +44,23 @@
|
|
|
44
44
|
"release": {
|
|
45
45
|
"publishToClawHub": true,
|
|
46
46
|
"publishToNpm": true
|
|
47
|
+
},
|
|
48
|
+
"runtimeExtensions": [
|
|
49
|
+
"./dist/index.js"
|
|
50
|
+
],
|
|
51
|
+
"runtimeSetupEntry": "./dist/setup-entry.js"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"dist/**",
|
|
55
|
+
"openclaw.plugin.json",
|
|
56
|
+
"README.md"
|
|
57
|
+
],
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"openclaw": ">=2026.5.3-beta.2"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"openclaw": {
|
|
63
|
+
"optional": true
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
66
|
}
|
package/api.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
type ChannelAccountSnapshot,
|
|
3
|
-
type ChannelCapabilities,
|
|
4
|
-
type ChannelGatewayContext,
|
|
5
|
-
type ChannelLogSink,
|
|
6
|
-
type ChannelMessageActionAdapter,
|
|
7
|
-
type ChannelMessageActionContext,
|
|
8
|
-
type ChannelMeta,
|
|
9
|
-
type ChannelOutboundAdapter,
|
|
10
|
-
type ChannelOutboundContext,
|
|
11
|
-
type ChannelPlugin,
|
|
12
|
-
type ChannelResolveKind,
|
|
13
|
-
type ChannelResolveResult,
|
|
14
|
-
type ChannelStatusAdapter,
|
|
15
|
-
type OpenClawConfig,
|
|
16
|
-
type OutboundDeliveryResult,
|
|
17
|
-
type RuntimeEnv,
|
|
18
|
-
type WizardPrompter,
|
|
19
|
-
} from "./runtime-api.js";
|
|
20
|
-
export { twitchPlugin } from "./src/plugin.js";
|
|
21
|
-
export { setTwitchRuntime } from "./src/runtime.js";
|
package/channel-plugin-api.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { twitchPlugin } from "./src/plugin.js";
|
package/index.test.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { assertBundledChannelEntries } from "openclaw/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
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { defineBundledChannelEntry } from "openclaw/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
|
-
});
|
package/runtime-api.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// Private runtime barrel for the bundled Twitch extension.
|
|
2
|
-
// Keep this barrel thin and aligned with the local extension surface.
|
|
3
|
-
|
|
4
|
-
export type {
|
|
5
|
-
ChannelAccountSnapshot,
|
|
6
|
-
ChannelCapabilities,
|
|
7
|
-
ChannelGatewayContext,
|
|
8
|
-
ChannelLogSink,
|
|
9
|
-
ChannelMessageActionAdapter,
|
|
10
|
-
ChannelMessageActionContext,
|
|
11
|
-
ChannelMeta,
|
|
12
|
-
ChannelOutboundAdapter,
|
|
13
|
-
ChannelOutboundContext,
|
|
14
|
-
ChannelResolveKind,
|
|
15
|
-
ChannelResolveResult,
|
|
16
|
-
ChannelStatusAdapter,
|
|
17
|
-
} from "openclaw/plugin-sdk/channel-contract";
|
|
18
|
-
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
|
19
|
-
export type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result";
|
|
20
|
-
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
21
|
-
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
|
22
|
-
export type { WizardPrompter } from "openclaw/plugin-sdk/setup";
|