@kodelyth/twitch 2026.5.42 → 2026.6.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/klaw.plugin.json +219 -2
- package/package.json +19 -2
- 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 -373
- package/src/access-control.ts +0 -195
- package/src/actions.test.ts +0 -75
- 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 -311
- package/src/outbound.test.ts +0 -572
- package/src/outbound.ts +0 -242
- package/src/plugin.lifecycle.test.ts +0 -86
- package/src/plugin.live.test.ts +0 -120
- package/src/plugin.test.ts +0 -77
- package/src/plugin.ts +0 -220
- 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 -342
- package/src/send.ts +0 -191
- package/src/setup-surface.test.ts +0 -529
- package/src/setup-surface.ts +0 -526
- package/src/status.test.ts +0 -298
- package/src/status.ts +0 -179
- package/src/test-fixtures.ts +0 -30
- package/src/token.test.ts +0 -198
- package/src/token.ts +0 -93
- package/src/twitch-client.test.ts +0 -574
- 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 -81
- package/test/setup.ts +0 -7
- package/tsconfig.json +0 -16
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { checkTwitchAccessControl } from "./access-control.js";
|
|
3
|
-
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
4
|
-
|
|
5
|
-
describe("checkTwitchAccessControl", () => {
|
|
6
|
-
const mockAccount: TwitchAccountConfig = {
|
|
7
|
-
username: "testbot",
|
|
8
|
-
accessToken: "test",
|
|
9
|
-
clientId: "test-client-id",
|
|
10
|
-
channel: "testchannel",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const mockMessage: TwitchChatMessage = {
|
|
14
|
-
username: "testuser",
|
|
15
|
-
userId: "123456",
|
|
16
|
-
message: "hello bot",
|
|
17
|
-
channel: "testchannel",
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function runAccessCheck(params: {
|
|
21
|
-
account?: Partial<TwitchAccountConfig>;
|
|
22
|
-
message?: Partial<TwitchChatMessage>;
|
|
23
|
-
}) {
|
|
24
|
-
return checkTwitchAccessControl({
|
|
25
|
-
message: {
|
|
26
|
-
...mockMessage,
|
|
27
|
-
...params.message,
|
|
28
|
-
},
|
|
29
|
-
account: {
|
|
30
|
-
...mockAccount,
|
|
31
|
-
...params.account,
|
|
32
|
-
},
|
|
33
|
-
botUsername: "testbot",
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function expectSingleRoleAllowed(params: {
|
|
38
|
-
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
|
|
39
|
-
message: Partial<TwitchChatMessage>;
|
|
40
|
-
}) {
|
|
41
|
-
const result = await runAccessCheck({
|
|
42
|
-
account: { allowedRoles: [params.role] },
|
|
43
|
-
message: {
|
|
44
|
-
message: "@testbot hello",
|
|
45
|
-
...params.message,
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
expect(result.allowed).toBe(true);
|
|
49
|
-
return result;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function expectAllowedAccessCheck(params: {
|
|
53
|
-
account?: Partial<TwitchAccountConfig>;
|
|
54
|
-
message?: Partial<TwitchChatMessage>;
|
|
55
|
-
}) {
|
|
56
|
-
const result = await runAccessCheck({
|
|
57
|
-
account: params.account,
|
|
58
|
-
message: {
|
|
59
|
-
message: "@testbot hello",
|
|
60
|
-
...params.message,
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
expect(result.allowed).toBe(true);
|
|
64
|
-
return result;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function expectAllowFromBlocked(params: {
|
|
68
|
-
allowFrom: string[];
|
|
69
|
-
allowedRoles?: NonNullable<TwitchAccountConfig["allowedRoles"]>;
|
|
70
|
-
message?: Partial<TwitchChatMessage>;
|
|
71
|
-
reason: string;
|
|
72
|
-
}) {
|
|
73
|
-
const result = await runAccessCheck({
|
|
74
|
-
account: {
|
|
75
|
-
allowFrom: params.allowFrom,
|
|
76
|
-
allowedRoles: params.allowedRoles,
|
|
77
|
-
},
|
|
78
|
-
message: {
|
|
79
|
-
message: "@testbot hello",
|
|
80
|
-
...params.message,
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
expect(result.allowed).toBe(false);
|
|
84
|
-
expect(result.reason).toContain(params.reason);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
describe("when no restrictions are configured", () => {
|
|
88
|
-
it("allows messages that mention the bot (default requireMention)", async () => {
|
|
89
|
-
const result = await runAccessCheck({
|
|
90
|
-
message: {
|
|
91
|
-
message: "@testbot hello",
|
|
92
|
-
},
|
|
93
|
-
});
|
|
94
|
-
expect(result.allowed).toBe(true);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe("requireMention default", () => {
|
|
99
|
-
it("defaults to true when undefined", async () => {
|
|
100
|
-
const result = await runAccessCheck({
|
|
101
|
-
message: {
|
|
102
|
-
message: "hello bot",
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
expect(result.allowed).toBe(false);
|
|
106
|
-
expect(result.reason).toContain("does not mention the bot");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("allows mention when requireMention is undefined", async () => {
|
|
110
|
-
const result = await runAccessCheck({
|
|
111
|
-
message: {
|
|
112
|
-
message: "@testbot hello",
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
expect(result.allowed).toBe(true);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe("requireMention", () => {
|
|
120
|
-
it("allows messages that mention the bot", async () => {
|
|
121
|
-
const result = await runAccessCheck({
|
|
122
|
-
account: { requireMention: true },
|
|
123
|
-
message: { message: "@testbot hello" },
|
|
124
|
-
});
|
|
125
|
-
expect(result.allowed).toBe(true);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("blocks messages that don't mention the bot", async () => {
|
|
129
|
-
const result = await runAccessCheck({
|
|
130
|
-
account: { requireMention: true },
|
|
131
|
-
});
|
|
132
|
-
expect(result.allowed).toBe(false);
|
|
133
|
-
expect(result.reason).toContain("does not mention the bot");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("is case-insensitive for bot username", async () => {
|
|
137
|
-
const result = await runAccessCheck({
|
|
138
|
-
account: { requireMention: true },
|
|
139
|
-
message: { message: "@TestBot hello" },
|
|
140
|
-
});
|
|
141
|
-
expect(result.allowed).toBe(true);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe("allowFrom allowlist", () => {
|
|
146
|
-
it("allows users in the allowlist", async () => {
|
|
147
|
-
const result = await expectAllowedAccessCheck({
|
|
148
|
-
account: {
|
|
149
|
-
allowFrom: ["123456", "789012"],
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
expect(result.matchKey).toBe("123456");
|
|
153
|
-
expect(result.matchSource).toBe("allowlist");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("blocks users not in allowlist when allowFrom is set", async () => {
|
|
157
|
-
await expectAllowFromBlocked({
|
|
158
|
-
allowFrom: ["789012"],
|
|
159
|
-
reason: "allowFrom",
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("blocks everyone when allowFrom is explicitly empty", async () => {
|
|
164
|
-
await expectAllowFromBlocked({
|
|
165
|
-
allowFrom: [],
|
|
166
|
-
reason: "allowFrom",
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("blocks messages without userId", async () => {
|
|
171
|
-
await expectAllowFromBlocked({
|
|
172
|
-
allowFrom: ["123456"],
|
|
173
|
-
message: { userId: undefined },
|
|
174
|
-
reason: "user ID not available",
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("bypasses role checks when user is in allowlist", async () => {
|
|
179
|
-
const account: TwitchAccountConfig = {
|
|
180
|
-
...mockAccount,
|
|
181
|
-
allowFrom: ["123456"],
|
|
182
|
-
allowedRoles: ["owner"],
|
|
183
|
-
};
|
|
184
|
-
const message: TwitchChatMessage = {
|
|
185
|
-
...mockMessage,
|
|
186
|
-
message: "@testbot hello",
|
|
187
|
-
isOwner: false,
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const result = await checkTwitchAccessControl({
|
|
191
|
-
message,
|
|
192
|
-
account,
|
|
193
|
-
botUsername: "testbot",
|
|
194
|
-
});
|
|
195
|
-
expect(result.allowed).toBe(true);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("blocks user with role when not in allowlist", async () => {
|
|
199
|
-
await expectAllowFromBlocked({
|
|
200
|
-
allowFrom: ["789012"],
|
|
201
|
-
allowedRoles: ["moderator"],
|
|
202
|
-
message: { userId: "123456", isMod: true },
|
|
203
|
-
reason: "allowFrom",
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("blocks user not in allowlist even when roles configured", async () => {
|
|
208
|
-
await expectAllowFromBlocked({
|
|
209
|
-
allowFrom: ["789012"],
|
|
210
|
-
allowedRoles: ["moderator"],
|
|
211
|
-
message: { userId: "123456", isMod: false },
|
|
212
|
-
reason: "allowFrom",
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe("allowedRoles", () => {
|
|
218
|
-
it("allows users with matching role", async () => {
|
|
219
|
-
const result = await expectSingleRoleAllowed({
|
|
220
|
-
role: "moderator",
|
|
221
|
-
message: { isMod: true },
|
|
222
|
-
});
|
|
223
|
-
expect(result.matchSource).toBe("role");
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it("allows users with any of multiple roles", async () => {
|
|
227
|
-
const account: TwitchAccountConfig = {
|
|
228
|
-
...mockAccount,
|
|
229
|
-
allowedRoles: ["moderator", "vip", "subscriber"],
|
|
230
|
-
};
|
|
231
|
-
const message: TwitchChatMessage = {
|
|
232
|
-
...mockMessage,
|
|
233
|
-
message: "@testbot hello",
|
|
234
|
-
isVip: true,
|
|
235
|
-
isMod: false,
|
|
236
|
-
isSub: false,
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const result = await checkTwitchAccessControl({
|
|
240
|
-
message,
|
|
241
|
-
account,
|
|
242
|
-
botUsername: "testbot",
|
|
243
|
-
});
|
|
244
|
-
expect(result.allowed).toBe(true);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("blocks users without matching role", async () => {
|
|
248
|
-
const account: TwitchAccountConfig = {
|
|
249
|
-
...mockAccount,
|
|
250
|
-
allowedRoles: ["moderator"],
|
|
251
|
-
};
|
|
252
|
-
const message: TwitchChatMessage = {
|
|
253
|
-
...mockMessage,
|
|
254
|
-
message: "@testbot hello",
|
|
255
|
-
isMod: false,
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
const result = await checkTwitchAccessControl({
|
|
259
|
-
message,
|
|
260
|
-
account,
|
|
261
|
-
botUsername: "testbot",
|
|
262
|
-
});
|
|
263
|
-
expect(result.allowed).toBe(false);
|
|
264
|
-
expect(result.reason).toContain("does not have any of the required roles");
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it("allows all users when role is 'all'", async () => {
|
|
268
|
-
const result = await expectAllowedAccessCheck({
|
|
269
|
-
account: {
|
|
270
|
-
allowedRoles: ["all"],
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
expect(result.matchKey).toBe("all");
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("handles moderator role", async () => {
|
|
277
|
-
await expectSingleRoleAllowed({
|
|
278
|
-
role: "moderator",
|
|
279
|
-
message: { isMod: true },
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("handles subscriber role", async () => {
|
|
284
|
-
await expectSingleRoleAllowed({
|
|
285
|
-
role: "subscriber",
|
|
286
|
-
message: { isSub: true },
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("handles owner role", async () => {
|
|
291
|
-
await expectSingleRoleAllowed({
|
|
292
|
-
role: "owner",
|
|
293
|
-
message: { isOwner: true },
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("handles vip role", async () => {
|
|
298
|
-
await expectSingleRoleAllowed({
|
|
299
|
-
role: "vip",
|
|
300
|
-
message: { isVip: true },
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
describe("combined restrictions", () => {
|
|
306
|
-
it("checks requireMention before allowlist", async () => {
|
|
307
|
-
const account: TwitchAccountConfig = {
|
|
308
|
-
...mockAccount,
|
|
309
|
-
requireMention: true,
|
|
310
|
-
allowFrom: ["123456"],
|
|
311
|
-
};
|
|
312
|
-
const message: TwitchChatMessage = {
|
|
313
|
-
...mockMessage,
|
|
314
|
-
message: "hello", // No mention
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
const result = await checkTwitchAccessControl({
|
|
318
|
-
message,
|
|
319
|
-
account,
|
|
320
|
-
botUsername: "testbot",
|
|
321
|
-
});
|
|
322
|
-
expect(result.allowed).toBe(false);
|
|
323
|
-
expect(result.reason).toContain("does not mention the bot");
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it("checks requireMention before sender allowlists for unauthorized chat", async () => {
|
|
327
|
-
const result = await runAccessCheck({
|
|
328
|
-
account: {
|
|
329
|
-
requireMention: true,
|
|
330
|
-
allowFrom: ["789012"],
|
|
331
|
-
},
|
|
332
|
-
message: {
|
|
333
|
-
message: "ordinary chat",
|
|
334
|
-
userId: "123456",
|
|
335
|
-
},
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
expect(result.allowed).toBe(false);
|
|
339
|
-
expect(result.reason).toContain("does not mention the bot");
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("checks requireMention before role gates for unauthorized chat", async () => {
|
|
343
|
-
const result = await runAccessCheck({
|
|
344
|
-
account: {
|
|
345
|
-
requireMention: true,
|
|
346
|
-
allowedRoles: ["moderator"],
|
|
347
|
-
},
|
|
348
|
-
message: {
|
|
349
|
-
message: "ordinary chat",
|
|
350
|
-
isMod: false,
|
|
351
|
-
},
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
expect(result.allowed).toBe(false);
|
|
355
|
-
expect(result.reason).toContain("does not mention the bot");
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it("checks allowlist before allowedRoles", async () => {
|
|
359
|
-
const result = await runAccessCheck({
|
|
360
|
-
account: {
|
|
361
|
-
allowFrom: ["123456"],
|
|
362
|
-
allowedRoles: ["owner"],
|
|
363
|
-
},
|
|
364
|
-
message: {
|
|
365
|
-
message: "@testbot hello",
|
|
366
|
-
isOwner: false,
|
|
367
|
-
},
|
|
368
|
-
});
|
|
369
|
-
expect(result.allowed).toBe(true);
|
|
370
|
-
expect(result.matchSource).toBe("allowlist");
|
|
371
|
-
});
|
|
372
|
-
});
|
|
373
|
-
});
|
package/src/access-control.ts
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createChannelIngressResolver,
|
|
3
|
-
defineStableChannelIngressIdentity,
|
|
4
|
-
type ChannelIngressIdentitySubjectInput,
|
|
5
|
-
type IngressReasonCode,
|
|
6
|
-
} from "klaw/plugin-sdk/channel-ingress-runtime";
|
|
7
|
-
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
8
|
-
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
9
|
-
|
|
10
|
-
type TwitchAccessControlResult = {
|
|
11
|
-
allowed: boolean;
|
|
12
|
-
reason?: string;
|
|
13
|
-
matchKey?: string;
|
|
14
|
-
matchSource?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
type TwitchPolicyKind = "open" | "allowFrom" | "role";
|
|
18
|
-
|
|
19
|
-
const twitchUserIdentity = defineStableChannelIngressIdentity({
|
|
20
|
-
key: "sender-id",
|
|
21
|
-
entryIdPrefix: "twitch-user-entry",
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const twitchRoleIdentity = defineStableChannelIngressIdentity({
|
|
25
|
-
key: "role-moderator",
|
|
26
|
-
kind: "role",
|
|
27
|
-
normalizeEntry: normalizeTwitchRole,
|
|
28
|
-
normalizeSubject: normalizeTwitchRole,
|
|
29
|
-
aliases: ["owner", "vip", "subscriber"].map((role) => ({
|
|
30
|
-
key: `role-${role}`,
|
|
31
|
-
kind: "role",
|
|
32
|
-
normalizeEntry: () => null,
|
|
33
|
-
normalizeSubject: normalizeTwitchRole,
|
|
34
|
-
})),
|
|
35
|
-
isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
|
|
36
|
-
resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`,
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
export async function checkTwitchAccessControl(params: {
|
|
40
|
-
message: TwitchChatMessage;
|
|
41
|
-
account: TwitchAccountConfig;
|
|
42
|
-
botUsername: string;
|
|
43
|
-
}): Promise<TwitchAccessControlResult> {
|
|
44
|
-
const { message, account, botUsername } = params;
|
|
45
|
-
const policyKind = resolveTwitchPolicyKind(account);
|
|
46
|
-
const resolved = await createChannelIngressResolver({
|
|
47
|
-
channelId: "twitch",
|
|
48
|
-
accountId: "default",
|
|
49
|
-
identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity,
|
|
50
|
-
}).message({
|
|
51
|
-
subject:
|
|
52
|
-
policyKind === "role"
|
|
53
|
-
? twitchRoleSubject(message)
|
|
54
|
-
: ({ stableId: message.userId } satisfies ChannelIngressIdentitySubjectInput),
|
|
55
|
-
conversation: {
|
|
56
|
-
kind: "group",
|
|
57
|
-
id: message.channel,
|
|
58
|
-
},
|
|
59
|
-
event: { mayPair: false },
|
|
60
|
-
mentionFacts: {
|
|
61
|
-
canDetectMention: true,
|
|
62
|
-
wasMentioned: mentionsBot(message.message, botUsername),
|
|
63
|
-
},
|
|
64
|
-
dmPolicy: "open",
|
|
65
|
-
groupPolicy: policyKind === "open" ? "open" : "allowlist",
|
|
66
|
-
policy: {
|
|
67
|
-
activation: {
|
|
68
|
-
requireMention: account.requireMention ?? true,
|
|
69
|
-
allowTextCommands: false,
|
|
70
|
-
order: "before-sender",
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
groupAllowFrom:
|
|
74
|
-
policyKind === "allowFrom"
|
|
75
|
-
? account.allowFrom
|
|
76
|
-
: policyKind === "role"
|
|
77
|
-
? account.allowedRoles
|
|
78
|
-
: undefined,
|
|
79
|
-
});
|
|
80
|
-
const decision = resolved.ingress;
|
|
81
|
-
|
|
82
|
-
if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") {
|
|
83
|
-
return {
|
|
84
|
-
allowed: false,
|
|
85
|
-
reason: "message does not mention the bot (requireMention is enabled)",
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (decision.admission === "dispatch") {
|
|
90
|
-
if (policyKind === "allowFrom") {
|
|
91
|
-
return {
|
|
92
|
-
allowed: true,
|
|
93
|
-
matchKey: params.message.userId,
|
|
94
|
-
matchSource: "allowlist",
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
if (policyKind === "role") {
|
|
98
|
-
return {
|
|
99
|
-
allowed: true,
|
|
100
|
-
matchKey: params.account.allowedRoles?.join(","),
|
|
101
|
-
matchSource: "role",
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
allowed: true,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (policyKind === "allowFrom") {
|
|
110
|
-
if (!params.message.userId) {
|
|
111
|
-
return {
|
|
112
|
-
allowed: false,
|
|
113
|
-
reason: "sender user ID not available for allowlist check",
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
return {
|
|
117
|
-
allowed: false,
|
|
118
|
-
reason: "sender is not in allowFrom allowlist",
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (policyKind === "role") {
|
|
123
|
-
return {
|
|
124
|
-
allowed: false,
|
|
125
|
-
reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
allowed: false,
|
|
131
|
-
reason: reasonForTwitchIngressDecision(decision),
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function resolveTwitchPolicyKind(account: TwitchAccountConfig): TwitchPolicyKind {
|
|
136
|
-
if (account.allowFrom !== undefined) {
|
|
137
|
-
return "allowFrom";
|
|
138
|
-
}
|
|
139
|
-
if (account.allowedRoles && account.allowedRoles.length > 0) {
|
|
140
|
-
return "role";
|
|
141
|
-
}
|
|
142
|
-
return "open";
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function twitchRoleSubject(message: TwitchChatMessage): ChannelIngressIdentitySubjectInput {
|
|
146
|
-
return {
|
|
147
|
-
stableId: message.isMod ? "moderator" : undefined,
|
|
148
|
-
aliases: {
|
|
149
|
-
"role-owner": message.isOwner ? "owner" : undefined,
|
|
150
|
-
"role-vip": message.isVip ? "vip" : undefined,
|
|
151
|
-
"role-subscriber": message.isSub ? "subscriber" : undefined,
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function normalizeTwitchRole(value: string): string | null {
|
|
157
|
-
const role = normalizeLowercaseStringOrEmpty(value);
|
|
158
|
-
if (role === "*") {
|
|
159
|
-
return "all";
|
|
160
|
-
}
|
|
161
|
-
return role === "moderator" ||
|
|
162
|
-
role === "owner" ||
|
|
163
|
-
role === "vip" ||
|
|
164
|
-
role === "subscriber" ||
|
|
165
|
-
role === "all"
|
|
166
|
-
? role
|
|
167
|
-
: null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function reasonForTwitchIngressDecision(decision: { reasonCode: IngressReasonCode }): string {
|
|
171
|
-
switch (decision.reasonCode) {
|
|
172
|
-
case "activation_skipped":
|
|
173
|
-
return "message does not mention the bot (requireMention is enabled)";
|
|
174
|
-
case "group_policy_empty_allowlist":
|
|
175
|
-
case "group_policy_not_allowlisted":
|
|
176
|
-
return "sender is not in allowFrom allowlist";
|
|
177
|
-
default:
|
|
178
|
-
return decision.reasonCode;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function mentionsBot(message: string, botUsername: string): boolean {
|
|
183
|
-
const expected = normalizeLowercaseStringOrEmpty(botUsername);
|
|
184
|
-
const mentionRegex = /@(\w+)/g;
|
|
185
|
-
let match: RegExpExecArray | null;
|
|
186
|
-
|
|
187
|
-
while ((match = mentionRegex.exec(message)) !== null) {
|
|
188
|
-
const username = match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "";
|
|
189
|
-
if (username === expected) {
|
|
190
|
-
return true;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return false;
|
|
195
|
-
}
|
package/src/actions.test.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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
|
-
const cfg = {
|
|
55
|
-
channels: {
|
|
56
|
-
twitch: {
|
|
57
|
-
defaultAccount: "secondary",
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
await twitchMessageActions.handleAction!({
|
|
63
|
-
action: "send",
|
|
64
|
-
params: { message: "Hello!" },
|
|
65
|
-
cfg,
|
|
66
|
-
} as never);
|
|
67
|
-
|
|
68
|
-
expect(twitchOutbound.sendText).toHaveBeenCalledWith({
|
|
69
|
-
cfg,
|
|
70
|
-
to: "secondary-channel",
|
|
71
|
-
text: "Hello!",
|
|
72
|
-
accountId: "secondary",
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
});
|