@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.
@@ -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
- export type TwitchAccessControlResult = {
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.toLowerCase())) {
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 && account.allowFrom.length > 0) {
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.toLowerCase());
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 { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
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
- listActions: () => [...TWITCH_ACTIONS],
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 ?? DEFAULT_ACCOUNT_ID;
134
+ const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
134
135
 
135
- const account = getAccountConfig(ctx.cfg, accountId);
136
+ const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
136
137
  if (!account) {
137
138
  return errorResponse(
138
- `Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`,
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 = error instanceof Error ? error.message : String(error);
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
- }
@@ -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
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { getAccountConfig } from "./config.js";
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 type { OpenClawConfig } from "openclaw/plugin-sdk";
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 (accountId === DEFAULT_ACCOUNT_ID) {
35
- const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
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
- if (!accounts || !accounts[accountId]) {
99
+ const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
100
+ if (!account) {
80
101
  return null;
81
102
  }
82
103
 
83
- return accounts[accountId] as TwitchAccountConfig | null;
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
- if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
112
- ids.push(DEFAULT_ACCOUNT_ID);
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
- return ids;
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
  }