@openclaw/twitch 2026.2.21 → 2026.5.1-beta.1
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 +2 -2
- package/api.ts +21 -0
- package/channel-plugin-api.ts +1 -0
- package/index.test.ts +13 -0
- package/index.ts +12 -16
- package/openclaw.plugin.json +6 -0
- package/package.json +29 -7
- 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 +136 -239
- package/src/access-control.ts +11 -4
- package/src/actions.test.ts +74 -0
- package/src/actions.ts +7 -6
- package/src/client-manager-registry.ts +0 -28
- package/src/config-schema.ts +2 -2
- package/src/config.test.ts +147 -1
- package/src/config.ts +76 -15
- package/src/monitor.ts +126 -85
- package/src/outbound.test.ts +140 -75
- package/src/outbound.ts +4 -5
- package/src/plugin.test.ts +39 -1
- package/src/plugin.ts +176 -242
- package/src/probe.test.ts +1 -1
- package/src/probe.ts +16 -5
- package/src/resolver.ts +5 -3
- package/src/runtime.ts +8 -13
- package/src/send.test.ts +92 -59
- package/src/send.ts +18 -15
- package/src/setup-surface.test.ts +511 -0
- package/src/setup-surface.ts +520 -0
- package/src/status.test.ts +120 -153
- package/src/status.ts +1 -1
- package/src/test-fixtures.ts +1 -1
- package/src/token.test.ts +22 -1
- package/src/token.ts +8 -6
- package/src/twitch-client.test.ts +18 -25
- package/src/twitch-client.ts +5 -6
- package/src/types.ts +7 -46
- package/src/utils/twitch.ts +8 -2
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -21
- package/src/onboarding.test.ts +0 -316
- package/src/onboarding.ts +0 -417
package/src/access-control.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
1
2
|
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Result of checking access control for a Twitch message
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
+
type TwitchAccessControlResult = {
|
|
7
8
|
allowed: boolean;
|
|
8
9
|
reason?: string;
|
|
9
10
|
matchKey?: string;
|
|
@@ -40,7 +41,7 @@ export function checkTwitchAccessControl(params: {
|
|
|
40
41
|
|
|
41
42
|
if (account.requireMention ?? true) {
|
|
42
43
|
const mentions = extractMentions(message.message);
|
|
43
|
-
if (!mentions.includes(botUsername
|
|
44
|
+
if (!mentions.includes(normalizeLowercaseStringOrEmpty(botUsername))) {
|
|
44
45
|
return {
|
|
45
46
|
allowed: false,
|
|
46
47
|
reason: "message does not mention the bot (requireMention is enabled)",
|
|
@@ -48,8 +49,14 @@ export function checkTwitchAccessControl(params: {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
if (account.allowFrom
|
|
52
|
+
if (account.allowFrom !== undefined) {
|
|
52
53
|
const allowFrom = account.allowFrom;
|
|
54
|
+
if (allowFrom.length === 0) {
|
|
55
|
+
return {
|
|
56
|
+
allowed: false,
|
|
57
|
+
reason: "sender is not in allowFrom allowlist",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
53
60
|
const senderId = message.userId;
|
|
54
61
|
|
|
55
62
|
if (!senderId) {
|
|
@@ -158,7 +165,7 @@ export function extractMentions(message: string): string[] {
|
|
|
158
165
|
while ((match = mentionRegex.exec(message)) !== null) {
|
|
159
166
|
const username = match[1];
|
|
160
167
|
if (username) {
|
|
161
|
-
mentions.push(username
|
|
168
|
+
mentions.push(normalizeLowercaseStringOrEmpty(username));
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
|
|
55
|
+
await twitchMessageActions.handleAction!({
|
|
56
|
+
action: "send",
|
|
57
|
+
params: { message: "Hello!" },
|
|
58
|
+
cfg: {
|
|
59
|
+
channels: {
|
|
60
|
+
twitch: {
|
|
61
|
+
defaultAccount: "secondary",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
} as never);
|
|
66
|
+
|
|
67
|
+
expect(twitchOutbound.sendText).toHaveBeenCalledWith(
|
|
68
|
+
expect.objectContaining({
|
|
69
|
+
accountId: "secondary",
|
|
70
|
+
to: "secondary-channel",
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/actions.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Handles tool-based actions for Twitch, such as sending messages.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
8
|
+
import { resolveTwitchAccountContext } from "./config.js";
|
|
8
9
|
import { twitchOutbound } from "./outbound.js";
|
|
9
10
|
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
|
|
10
11
|
|
|
@@ -68,7 +69,7 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
|
|
|
68
69
|
/**
|
|
69
70
|
* List available actions for this channel.
|
|
70
71
|
*/
|
|
71
|
-
|
|
72
|
+
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
75
|
* Check if an action is supported.
|
|
@@ -130,12 +131,12 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
|
|
|
130
131
|
|
|
131
132
|
const message = readStringParam(ctx.params, "message", { required: true });
|
|
132
133
|
const to = readStringParam(ctx.params, "to", { required: false });
|
|
133
|
-
const accountId = ctx.accountId ??
|
|
134
|
+
const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
|
|
134
135
|
|
|
135
|
-
const account =
|
|
136
|
+
const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
|
|
136
137
|
if (!account) {
|
|
137
138
|
return errorResponse(
|
|
138
|
-
`Account not found: ${accountId}. Available accounts: ${
|
|
139
|
+
`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
|
139
140
|
);
|
|
140
141
|
}
|
|
141
142
|
|
|
@@ -167,7 +168,7 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
|
|
|
167
168
|
details: { ok: true },
|
|
168
169
|
};
|
|
169
170
|
} catch (error) {
|
|
170
|
-
const errorMsg =
|
|
171
|
+
const errorMsg = formatErrorMessage(error);
|
|
171
172
|
return errorResponse(errorMsg);
|
|
172
173
|
}
|
|
173
174
|
},
|
|
@@ -85,31 +85,3 @@ export async function removeClientManager(accountId: string): Promise<void> {
|
|
|
85
85
|
registry.delete(accountId);
|
|
86
86
|
entry.logger.info(`Unregistered client manager for account: ${accountId}`);
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Disconnect and remove all client managers from the registry.
|
|
91
|
-
*
|
|
92
|
-
* @returns Promise that resolves when all cleanup is complete
|
|
93
|
-
*/
|
|
94
|
-
export async function removeAllClientManagers(): Promise<void> {
|
|
95
|
-
const promises = [...registry.keys()].map((accountId) => removeClientManager(accountId));
|
|
96
|
-
await Promise.all(promises);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Get the number of registered client managers.
|
|
101
|
-
*
|
|
102
|
-
* @returns The count of registered managers
|
|
103
|
-
*/
|
|
104
|
-
export function getRegisteredClientManagerCount(): number {
|
|
105
|
-
return registry.size;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Clear all client managers without disconnecting.
|
|
110
|
-
*
|
|
111
|
-
* This is primarily for testing purposes.
|
|
112
|
-
*/
|
|
113
|
-
export function _clearAllClientManagersForTest(): void {
|
|
114
|
-
registry.clear();
|
|
115
|
-
}
|
package/src/config-schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
-
import { z } from "zod";
|
|
1
|
+
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
|
2
|
+
import { z } from "openclaw/plugin-sdk/zod";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Twitch user roles that can be allowed to interact with the bot
|
package/src/config.test.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getAccountConfig,
|
|
4
|
+
listAccountIds,
|
|
5
|
+
resolveDefaultTwitchAccountId,
|
|
6
|
+
resolveTwitchAccountContext,
|
|
7
|
+
} from "./config.js";
|
|
3
8
|
|
|
4
9
|
describe("getAccountConfig", () => {
|
|
5
10
|
const mockMultiAccountConfig = {
|
|
@@ -49,6 +54,30 @@ describe("getAccountConfig", () => {
|
|
|
49
54
|
expect(result?.username).toBe("secondbot");
|
|
50
55
|
});
|
|
51
56
|
|
|
57
|
+
it("normalizes account ids without reading inherited account properties", () => {
|
|
58
|
+
const accounts = Object.create({
|
|
59
|
+
inherited: {
|
|
60
|
+
username: "inherited-bot",
|
|
61
|
+
accessToken: "oauth:inherited",
|
|
62
|
+
},
|
|
63
|
+
}) as Record<string, unknown>;
|
|
64
|
+
accounts.Secondary = {
|
|
65
|
+
username: "secondbot",
|
|
66
|
+
accessToken: "oauth:secondary",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const cfg = {
|
|
70
|
+
channels: {
|
|
71
|
+
twitch: {
|
|
72
|
+
accounts,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
expect(getAccountConfig(cfg, "SECONDARY\r\n")).toMatchObject({ username: "secondbot" });
|
|
78
|
+
expect(getAccountConfig(cfg, "inherited")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
52
81
|
it("returns null for non-existent account ID", () => {
|
|
53
82
|
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
|
|
54
83
|
|
|
@@ -85,3 +114,120 @@ describe("getAccountConfig", () => {
|
|
|
85
114
|
expect(result).toBeNull();
|
|
86
115
|
});
|
|
87
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
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
listCombinedAccountIds,
|
|
3
|
+
normalizeAccountId,
|
|
4
|
+
resolveNormalizedAccountEntry,
|
|
5
|
+
} from "openclaw/plugin-sdk/account-resolution";
|
|
6
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
7
|
+
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
|
|
2
8
|
import type { TwitchAccountConfig } from "./types.js";
|
|
9
|
+
import { isAccountConfigured } from "./utils/twitch.js";
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
12
|
* Default account ID for Twitch
|
|
6
13
|
*/
|
|
7
14
|
export const DEFAULT_ACCOUNT_ID = "default";
|
|
8
15
|
|
|
16
|
+
export type ResolvedTwitchAccountContext = {
|
|
17
|
+
accountId: string;
|
|
18
|
+
account: TwitchAccountConfig | null;
|
|
19
|
+
tokenResolution: TwitchTokenResolution;
|
|
20
|
+
configured: boolean;
|
|
21
|
+
availableAccountIds: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
9
24
|
/**
|
|
10
25
|
* Get account config from core config
|
|
11
26
|
*
|
|
@@ -25,14 +40,19 @@ export function getAccountConfig(
|
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
const cfg = coreConfig as OpenClawConfig;
|
|
43
|
+
const normalizedAccountId = normalizeAccountId(accountId);
|
|
28
44
|
const twitch = cfg.channels?.twitch;
|
|
29
45
|
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
|
30
46
|
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
|
31
47
|
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
|
|
32
48
|
|
|
33
49
|
// For default account, check base-level config first
|
|
34
|
-
if (
|
|
35
|
-
const accountFromAccounts =
|
|
50
|
+
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
51
|
+
const accountFromAccounts = resolveNormalizedAccountEntry(
|
|
52
|
+
accounts,
|
|
53
|
+
DEFAULT_ACCOUNT_ID,
|
|
54
|
+
normalizeAccountId,
|
|
55
|
+
);
|
|
36
56
|
|
|
37
57
|
// Base-level properties that can form an implicit default account
|
|
38
58
|
const baseLevel = {
|
|
@@ -76,11 +96,12 @@ export function getAccountConfig(
|
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
// For non-default accounts, only check accounts object
|
|
79
|
-
|
|
99
|
+
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
|
|
100
|
+
if (!account) {
|
|
80
101
|
return null;
|
|
81
102
|
}
|
|
82
103
|
|
|
83
|
-
return
|
|
104
|
+
return account;
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
/**
|
|
@@ -94,13 +115,6 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
|
|
|
94
115
|
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
|
95
116
|
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
|
|
96
117
|
|
|
97
|
-
const ids: string[] = [];
|
|
98
|
-
|
|
99
|
-
// Add explicit accounts
|
|
100
|
-
if (accountMap) {
|
|
101
|
-
ids.push(...Object.keys(accountMap));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
118
|
// Add implicit "default" if base-level config exists and "default" not already present
|
|
105
119
|
const hasBaseLevelConfig =
|
|
106
120
|
twitchRaw &&
|
|
@@ -108,9 +122,56 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
|
|
|
108
122
|
typeof twitchRaw.accessToken === "string" ||
|
|
109
123
|
typeof twitchRaw.channel === "string");
|
|
110
124
|
|
|
111
|
-
|
|
112
|
-
|
|
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: OpenClawConfig): 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;
|
|
113
145
|
}
|
|
146
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveTwitchAccountContext(
|
|
150
|
+
cfg: OpenClawConfig,
|
|
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
|
+
}
|
|
114
166
|
|
|
115
|
-
|
|
167
|
+
export function resolveTwitchSnapshotAccountId(
|
|
168
|
+
cfg: OpenClawConfig,
|
|
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
|
+
);
|
|
116
177
|
}
|