@openclaw/twitch 2026.2.21 → 2026.5.2-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 +224 -1
- package/package.json +36 -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.test.ts +46 -0
- package/src/config-schema.ts +25 -21
- 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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import AjvPkg from "ajv";
|
|
2
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { TwitchConfigSchema } from "./config-schema.js";
|
|
5
|
+
|
|
6
|
+
function validateTwitchConfig(value: unknown): boolean {
|
|
7
|
+
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
|
|
8
|
+
const schema = buildChannelConfigSchema(TwitchConfigSchema).schema;
|
|
9
|
+
const validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
|
|
10
|
+
const ok = validate(value);
|
|
11
|
+
if (!ok) {
|
|
12
|
+
throw new Error(`expected valid Twitch config: ${JSON.stringify(validate.errors)}`);
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("TwitchConfigSchema JSON schema", () => {
|
|
18
|
+
it("accepts single-account channel config with base fields", () => {
|
|
19
|
+
expect(
|
|
20
|
+
validateTwitchConfig({
|
|
21
|
+
enabled: false,
|
|
22
|
+
username: "openclaw",
|
|
23
|
+
accessToken: "oauth:test",
|
|
24
|
+
clientId: "test-client-id",
|
|
25
|
+
channel: "openclaw-test",
|
|
26
|
+
}),
|
|
27
|
+
).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("accepts multi-account channel config with defaultAccount", () => {
|
|
31
|
+
expect(
|
|
32
|
+
validateTwitchConfig({
|
|
33
|
+
enabled: true,
|
|
34
|
+
defaultAccount: "stream",
|
|
35
|
+
accounts: {
|
|
36
|
+
stream: {
|
|
37
|
+
username: "openclaw",
|
|
38
|
+
accessToken: "oauth:test",
|
|
39
|
+
clientId: "test-client-id",
|
|
40
|
+
channel: "openclaw-test",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/config-schema.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
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
|
|
6
6
|
*/
|
|
7
7
|
const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
* Twitch account configuration schema
|
|
11
|
-
*/
|
|
12
|
-
const TwitchAccountSchema = z.object({
|
|
9
|
+
const TwitchAccountShape = {
|
|
13
10
|
/** Twitch username */
|
|
14
11
|
username: z.string(),
|
|
15
12
|
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
|
@@ -36,16 +33,22 @@ const TwitchAccountSchema = z.object({
|
|
|
36
33
|
expiresIn: z.number().nullable().optional(),
|
|
37
34
|
/** Timestamp when token was obtained (optional, for token refresh tracking) */
|
|
38
35
|
obtainmentTimestamp: z.number().optional(),
|
|
39
|
-
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Twitch account configuration schema
|
|
40
|
+
*/
|
|
41
|
+
const TwitchAccountSchema = z.object(TwitchAccountShape);
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* Base configuration properties shared by both single and multi-account modes
|
|
43
45
|
*/
|
|
44
|
-
const
|
|
46
|
+
const TwitchConfigBaseShape = {
|
|
45
47
|
name: z.string().optional(),
|
|
46
48
|
enabled: z.boolean().optional(),
|
|
47
49
|
markdown: MarkdownConfigSchema.optional(),
|
|
48
|
-
|
|
50
|
+
defaultAccount: z.string().optional(),
|
|
51
|
+
};
|
|
49
52
|
|
|
50
53
|
/**
|
|
51
54
|
* Simplified single-account configuration schema
|
|
@@ -53,24 +56,25 @@ const TwitchConfigBaseSchema = z.object({
|
|
|
53
56
|
* Use this for single-account setups. Properties are at the top level,
|
|
54
57
|
* creating an implicit "default" account.
|
|
55
58
|
*/
|
|
56
|
-
const SimplifiedSchema = z.
|
|
59
|
+
const SimplifiedSchema = z.object({
|
|
60
|
+
...TwitchConfigBaseShape,
|
|
61
|
+
...TwitchAccountShape,
|
|
62
|
+
});
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
65
|
* Multi-account configuration schema
|
|
60
66
|
*
|
|
61
67
|
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
|
|
62
68
|
*/
|
|
63
|
-
const MultiAccountSchema = z
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}),
|
|
73
|
-
);
|
|
69
|
+
const MultiAccountSchema = z
|
|
70
|
+
.object({
|
|
71
|
+
...TwitchConfigBaseShape,
|
|
72
|
+
/** Per-account configuration (for multi-account setups) */
|
|
73
|
+
accounts: z.record(z.string(), TwitchAccountSchema),
|
|
74
|
+
})
|
|
75
|
+
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
|
|
76
|
+
message: "accounts must contain at least one entry",
|
|
77
|
+
});
|
|
74
78
|
|
|
75
79
|
/**
|
|
76
80
|
* Twitch plugin configuration schema
|
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
|
}
|
package/src/monitor.ts
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* resolves agent routes, and handles replies.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
8
|
+
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
|
9
|
+
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
10
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
11
|
+
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
|
12
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
10
13
|
import { checkTwitchAccessControl } from "./access-control.js";
|
|
11
14
|
import { getOrCreateClientManager } from "./client-manager-registry.js";
|
|
12
15
|
import { getTwitchRuntime } from "./runtime.js";
|
|
@@ -48,91 +51,129 @@ async function processTwitchMessage(params: {
|
|
|
48
51
|
const { message, account, accountId, config, runtime, core, statusSink } = params;
|
|
49
52
|
const cfg = config as OpenClawConfig;
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
cfg,
|
|
54
|
+
await core.channel.turn.run({
|
|
53
55
|
channel: "twitch",
|
|
54
56
|
accountId,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
MessageSid: message.id,
|
|
87
|
-
OriginatingChannel: "twitch",
|
|
88
|
-
OriginatingTo: `twitch:channel:${message.channel}`,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
92
|
-
agentId: route.agentId,
|
|
93
|
-
});
|
|
94
|
-
await core.channel.session.recordInboundSession({
|
|
95
|
-
storePath,
|
|
96
|
-
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
97
|
-
ctx: ctxPayload,
|
|
98
|
-
onRecordError: (err) => {
|
|
99
|
-
runtime.error?.(`Failed updating session meta: ${String(err)}`);
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
104
|
-
cfg,
|
|
105
|
-
channel: "twitch",
|
|
106
|
-
accountId,
|
|
107
|
-
});
|
|
108
|
-
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
109
|
-
cfg,
|
|
110
|
-
agentId: route.agentId,
|
|
111
|
-
channel: "twitch",
|
|
112
|
-
accountId,
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
116
|
-
ctx: ctxPayload,
|
|
117
|
-
cfg,
|
|
118
|
-
dispatcherOptions: {
|
|
119
|
-
...prefixOptions,
|
|
120
|
-
deliver: async (payload) => {
|
|
121
|
-
await deliverTwitchReply({
|
|
122
|
-
payload,
|
|
123
|
-
channel: message.channel,
|
|
124
|
-
account,
|
|
57
|
+
raw: message,
|
|
58
|
+
adapter: {
|
|
59
|
+
ingest: (incoming) => ({
|
|
60
|
+
id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
|
|
61
|
+
timestamp: incoming.timestamp?.getTime(),
|
|
62
|
+
rawText: incoming.message,
|
|
63
|
+
textForAgent: incoming.message,
|
|
64
|
+
textForCommands: incoming.message,
|
|
65
|
+
raw: incoming,
|
|
66
|
+
}),
|
|
67
|
+
resolveTurn: (input) => {
|
|
68
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
69
|
+
cfg,
|
|
70
|
+
channel: "twitch",
|
|
71
|
+
accountId,
|
|
72
|
+
peer: {
|
|
73
|
+
kind: "group",
|
|
74
|
+
id: message.channel,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const senderId = message.userId ?? message.username;
|
|
78
|
+
const fromLabel = message.displayName ?? message.username;
|
|
79
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
80
|
+
channel: "Twitch",
|
|
81
|
+
from: fromLabel,
|
|
82
|
+
timestamp: input.timestamp,
|
|
83
|
+
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
|
84
|
+
body: input.rawText,
|
|
85
|
+
});
|
|
86
|
+
const ctxPayload = core.channel.turn.buildContext({
|
|
87
|
+
channel: "twitch",
|
|
125
88
|
accountId,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
89
|
+
messageId: input.id,
|
|
90
|
+
timestamp: input.timestamp,
|
|
91
|
+
from: `twitch:user:${senderId}`,
|
|
92
|
+
sender: {
|
|
93
|
+
id: senderId,
|
|
94
|
+
name: fromLabel,
|
|
95
|
+
username: message.username,
|
|
96
|
+
},
|
|
97
|
+
conversation: {
|
|
98
|
+
kind: "group",
|
|
99
|
+
id: message.channel,
|
|
100
|
+
label: message.channel,
|
|
101
|
+
routePeer: {
|
|
102
|
+
kind: "group",
|
|
103
|
+
id: message.channel,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
route: {
|
|
107
|
+
agentId: route.agentId,
|
|
108
|
+
accountId: route.accountId,
|
|
109
|
+
routeSessionKey: route.sessionKey,
|
|
110
|
+
},
|
|
111
|
+
reply: {
|
|
112
|
+
to: `twitch:channel:${message.channel}`,
|
|
113
|
+
originatingTo: `twitch:channel:${message.channel}`,
|
|
114
|
+
},
|
|
115
|
+
message: {
|
|
116
|
+
body,
|
|
117
|
+
rawBody: input.rawText,
|
|
118
|
+
bodyForAgent: input.textForAgent,
|
|
119
|
+
commandBody: input.textForCommands,
|
|
120
|
+
envelopeFrom: fromLabel,
|
|
121
|
+
},
|
|
130
122
|
});
|
|
123
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
124
|
+
agentId: route.agentId,
|
|
125
|
+
});
|
|
126
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
127
|
+
cfg,
|
|
128
|
+
channel: "twitch",
|
|
129
|
+
accountId,
|
|
130
|
+
});
|
|
131
|
+
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
|
132
|
+
cfg,
|
|
133
|
+
agentId: route.agentId,
|
|
134
|
+
channel: "twitch",
|
|
135
|
+
accountId,
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
cfg,
|
|
139
|
+
channel: "twitch",
|
|
140
|
+
accountId,
|
|
141
|
+
agentId: route.agentId,
|
|
142
|
+
routeSessionKey: route.sessionKey,
|
|
143
|
+
storePath,
|
|
144
|
+
ctxPayload,
|
|
145
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
146
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
147
|
+
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
148
|
+
delivery: {
|
|
149
|
+
deliver: async (payload) => {
|
|
150
|
+
await deliverTwitchReply({
|
|
151
|
+
payload,
|
|
152
|
+
channel: message.channel,
|
|
153
|
+
account,
|
|
154
|
+
accountId,
|
|
155
|
+
config,
|
|
156
|
+
tableMode,
|
|
157
|
+
runtime,
|
|
158
|
+
statusSink,
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
onError: (err, info) => {
|
|
162
|
+
runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
dispatcherOptions: replyPipeline,
|
|
166
|
+
replyOptions: {
|
|
167
|
+
onModelSelected,
|
|
168
|
+
},
|
|
169
|
+
record: {
|
|
170
|
+
onRecordError: (err) => {
|
|
171
|
+
runtime.error?.(`Failed updating session meta: ${String(err)}`);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
131
175
|
},
|
|
132
176
|
},
|
|
133
|
-
replyOptions: {
|
|
134
|
-
onModelSelected,
|
|
135
|
-
},
|
|
136
177
|
});
|
|
137
178
|
}
|
|
138
179
|
|
|
@@ -145,7 +186,7 @@ async function deliverTwitchReply(params: {
|
|
|
145
186
|
account: TwitchAccountConfig;
|
|
146
187
|
accountId: string;
|
|
147
188
|
config: unknown;
|
|
148
|
-
tableMode:
|
|
189
|
+
tableMode: MarkdownTableMode;
|
|
149
190
|
runtime: TwitchRuntimeEnv;
|
|
150
191
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
151
192
|
}): Promise<void> {
|
|
@@ -220,7 +261,7 @@ export async function monitorTwitchProvider(
|
|
|
220
261
|
accountId,
|
|
221
262
|
);
|
|
222
263
|
} catch (error) {
|
|
223
|
-
const errorMsg =
|
|
264
|
+
const errorMsg = formatErrorMessage(error);
|
|
224
265
|
runtime.error?.(`Failed to connect: ${errorMsg}`);
|
|
225
266
|
throw error;
|
|
226
267
|
}
|
|
@@ -231,8 +272,8 @@ export async function monitorTwitchProvider(
|
|
|
231
272
|
}
|
|
232
273
|
|
|
233
274
|
// Access control check
|
|
234
|
-
const botUsername = account.username
|
|
235
|
-
if (message.username
|
|
275
|
+
const botUsername = normalizeLowercaseStringOrEmpty(account.username);
|
|
276
|
+
if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) {
|
|
236
277
|
return; // Ignore own messages
|
|
237
278
|
}
|
|
238
279
|
|