@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.
Files changed (45) hide show
  1. package/klaw.plugin.json +219 -2
  2. package/package.json +19 -2
  3. package/api.ts +0 -21
  4. package/channel-plugin-api.ts +0 -1
  5. package/index.test.ts +0 -13
  6. package/index.ts +0 -16
  7. package/runtime-api.ts +0 -22
  8. package/setup-entry.ts +0 -9
  9. package/setup-plugin-api.ts +0 -3
  10. package/src/access-control.test.ts +0 -373
  11. package/src/access-control.ts +0 -195
  12. package/src/actions.test.ts +0 -75
  13. package/src/actions.ts +0 -175
  14. package/src/client-manager-registry.ts +0 -87
  15. package/src/config-schema.test.ts +0 -46
  16. package/src/config-schema.ts +0 -88
  17. package/src/config.test.ts +0 -233
  18. package/src/config.ts +0 -177
  19. package/src/monitor.ts +0 -311
  20. package/src/outbound.test.ts +0 -572
  21. package/src/outbound.ts +0 -242
  22. package/src/plugin.lifecycle.test.ts +0 -86
  23. package/src/plugin.live.test.ts +0 -120
  24. package/src/plugin.test.ts +0 -77
  25. package/src/plugin.ts +0 -220
  26. package/src/probe.test.ts +0 -196
  27. package/src/probe.ts +0 -130
  28. package/src/resolver.ts +0 -139
  29. package/src/runtime.ts +0 -9
  30. package/src/send.test.ts +0 -342
  31. package/src/send.ts +0 -191
  32. package/src/setup-surface.test.ts +0 -529
  33. package/src/setup-surface.ts +0 -526
  34. package/src/status.test.ts +0 -298
  35. package/src/status.ts +0 -179
  36. package/src/test-fixtures.ts +0 -30
  37. package/src/token.test.ts +0 -198
  38. package/src/token.ts +0 -93
  39. package/src/twitch-client.test.ts +0 -574
  40. package/src/twitch-client.ts +0 -276
  41. package/src/types.ts +0 -104
  42. package/src/utils/markdown.ts +0 -98
  43. package/src/utils/twitch.ts +0 -81
  44. package/test/setup.ts +0 -7
  45. package/tsconfig.json +0 -16
package/src/outbound.ts DELETED
@@ -1,242 +0,0 @@
1
- /**
2
- * Twitch outbound adapter for sending messages.
3
- *
4
- * Implements the ChannelOutboundAdapter interface for Twitch chat.
5
- * Supports text and media (URL) sending with markdown stripping and chunking.
6
- */
7
-
8
- import {
9
- createMessageReceiptFromOutboundResults,
10
- defineChannelMessageAdapter,
11
- type ChannelMessageSendResult,
12
- type MessageReceiptPartKind,
13
- } from "klaw/plugin-sdk/channel-message";
14
- import { resolveTwitchAccountContext } from "./config.js";
15
- import { sendMessageTwitchInternal } from "./send.js";
16
- import type {
17
- ChannelOutboundAdapter,
18
- ChannelOutboundContext,
19
- OutboundDeliveryResult,
20
- } from "./types.js";
21
- import { chunkTextForTwitch } from "./utils/markdown.js";
22
- import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
23
-
24
- /**
25
- * Twitch outbound adapter.
26
- *
27
- * Handles sending text and media to Twitch channels with automatic
28
- * markdown stripping and message chunking.
29
- */
30
- export const twitchOutbound: ChannelOutboundAdapter = {
31
- /** Direct delivery mode - messages are sent immediately */
32
- deliveryMode: "direct",
33
-
34
- deliveryCapabilities: {
35
- durableFinal: {
36
- text: true,
37
- media: true,
38
- messageSendingHooks: true,
39
- },
40
- },
41
-
42
- /** Twitch chat message limit is 500 characters */
43
- textChunkLimit: 500,
44
-
45
- /** Word-boundary chunker with markdown stripping */
46
- chunker: chunkTextForTwitch,
47
-
48
- /**
49
- * Resolve target from context.
50
- *
51
- * Handles target resolution with allowlist support for implicit/heartbeat modes.
52
- * For explicit mode, accepts any valid channel name.
53
- *
54
- * @param params - Resolution parameters
55
- * @returns Resolved target or error
56
- */
57
- resolveTarget: ({ to, allowFrom, mode }) => {
58
- const trimmed = to?.trim() ?? "";
59
- const allowListRaw = (allowFrom ?? [])
60
- .map((entry: unknown) => String(entry).trim())
61
- .filter(Boolean);
62
- const hasWildcard = allowListRaw.includes("*");
63
- const allowList = allowListRaw
64
- .filter((entry: string) => entry !== "*")
65
- .map((entry: string) => normalizeTwitchChannel(entry))
66
- .filter((entry): entry is string => entry.length > 0);
67
-
68
- // If target is provided, normalize and validate it
69
- if (trimmed) {
70
- const normalizedTo = normalizeTwitchChannel(trimmed);
71
- if (!normalizedTo) {
72
- return {
73
- ok: false,
74
- error: missingTargetError("Twitch", "<channel-name>"),
75
- };
76
- }
77
-
78
- // For implicit/heartbeat modes with allowList, check against allowlist
79
- if (mode === "implicit" || mode === "heartbeat") {
80
- if (hasWildcard || allowList.length === 0) {
81
- return { ok: true, to: normalizedTo };
82
- }
83
- if (allowList.includes(normalizedTo)) {
84
- return { ok: true, to: normalizedTo };
85
- }
86
- return {
87
- ok: false,
88
- error: missingTargetError("Twitch", "<channel-name>"),
89
- };
90
- }
91
-
92
- // For explicit mode, accept any valid channel name
93
- return { ok: true, to: normalizedTo };
94
- }
95
-
96
- // No target provided - error
97
-
98
- // No target and no allowFrom - error
99
- return {
100
- ok: false,
101
- error: missingTargetError("Twitch", "<channel-name>"),
102
- };
103
- },
104
-
105
- /**
106
- * Send a text message to a Twitch channel.
107
- *
108
- * Strips markdown if enabled, validates account configuration,
109
- * and sends the message via the Twitch client.
110
- *
111
- * @param params - Send parameters including target, text, and config
112
- * @returns Delivery result with message ID and status
113
- *
114
- * @example
115
- * const result = await twitchOutbound.sendText({
116
- * cfg: klawConfig,
117
- * to: "#mychannel",
118
- * text: "Hello Twitch!",
119
- * accountId: "default",
120
- * });
121
- */
122
- sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
123
- const { cfg, to, text, accountId } = params;
124
- const signal = (params as { signal?: AbortSignal }).signal;
125
-
126
- if (signal?.aborted) {
127
- throw new Error("Outbound delivery aborted");
128
- }
129
-
130
- const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
131
- const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
132
- if (!account) {
133
- throw new Error(
134
- `Twitch account not found: ${resolvedAccountId}. ` +
135
- `Available accounts: ${availableAccountIds.join(", ") || "none"}`,
136
- );
137
- }
138
-
139
- const channel = to || account.channel;
140
- if (!channel) {
141
- throw new Error("No channel specified and no default channel in account config");
142
- }
143
-
144
- const result = await sendMessageTwitchInternal(
145
- normalizeTwitchChannel(channel),
146
- text,
147
- cfg,
148
- resolvedAccountId,
149
- true, // stripMarkdown
150
- console,
151
- );
152
-
153
- if (!result.ok) {
154
- throw new Error(result.error ?? "Send failed");
155
- }
156
-
157
- return {
158
- channel: "twitch",
159
- messageId: result.messageId,
160
- receipt: result.receipt,
161
- timestamp: Date.now(),
162
- };
163
- },
164
-
165
- /**
166
- * Send media to a Twitch channel.
167
- *
168
- * Note: Twitch chat doesn't support direct media uploads.
169
- * This sends the media URL as text instead.
170
- *
171
- * @param params - Send parameters including media URL
172
- * @returns Delivery result with message ID and status
173
- *
174
- * @example
175
- * const result = await twitchOutbound.sendMedia({
176
- * cfg: klawConfig,
177
- * to: "#mychannel",
178
- * text: "Check this out!",
179
- * mediaUrl: "https://example.com/image.png",
180
- * accountId: "default",
181
- * });
182
- */
183
- sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
184
- const { text, mediaUrl } = params;
185
- const signal = (params as { signal?: AbortSignal }).signal;
186
-
187
- if (signal?.aborted) {
188
- throw new Error("Outbound delivery aborted");
189
- }
190
-
191
- const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
192
-
193
- if (!twitchOutbound.sendText) {
194
- throw new Error("sendText not implemented");
195
- }
196
- return twitchOutbound.sendText({
197
- ...params,
198
- text: message,
199
- });
200
- },
201
- };
202
-
203
- function toTwitchMessageSendResult(
204
- result: OutboundDeliveryResult,
205
- kind: MessageReceiptPartKind,
206
- ): ChannelMessageSendResult {
207
- const receipt =
208
- result.receipt ??
209
- createMessageReceiptFromOutboundResults({
210
- results: result.messageId ? [{ channel: "twitch", messageId: result.messageId }] : [],
211
- kind,
212
- });
213
- return {
214
- messageId: result.messageId || receipt.primaryPlatformMessageId,
215
- receipt,
216
- };
217
- }
218
-
219
- export const twitchMessageAdapter = defineChannelMessageAdapter({
220
- id: "twitch",
221
- durableFinal: {
222
- capabilities: {
223
- text: true,
224
- media: true,
225
- messageSendingHooks: true,
226
- },
227
- },
228
- send: {
229
- text: async (ctx) => {
230
- if (!twitchOutbound.sendText) {
231
- throw new Error("Twitch text sending is not available.");
232
- }
233
- return toTwitchMessageSendResult(await twitchOutbound.sendText(ctx), "text");
234
- },
235
- media: async (ctx) => {
236
- if (!twitchOutbound.sendMedia) {
237
- throw new Error("Twitch media sending is not available.");
238
- }
239
- return toTwitchMessageSendResult(await twitchOutbound.sendMedia(ctx), "media");
240
- },
241
- },
242
- });
@@ -1,86 +0,0 @@
1
- import {
2
- createStartAccountContext,
3
- expectStopPendingUntilAbort,
4
- startAccountAndTrackLifecycle,
5
- waitForStartedMocks,
6
- } from "klaw/plugin-sdk/channel-test-helpers";
7
- import { afterEach, describe, expect, it, vi } from "vitest";
8
- import type { TwitchAccountConfig } from "./types.js";
9
-
10
- const hoisted = vi.hoisted(() => ({
11
- monitorTwitchProvider: vi.fn(),
12
- }));
13
-
14
- vi.mock("./monitor.js", () => ({
15
- monitorTwitchProvider: hoisted.monitorTwitchProvider,
16
- }));
17
-
18
- const { twitchPlugin } = await import("./plugin.js");
19
-
20
- type TwitchStartAccount = NonNullable<NonNullable<typeof twitchPlugin.gateway>["startAccount"]>;
21
-
22
- function requireStartAccount(): TwitchStartAccount {
23
- const startAccount = twitchPlugin.gateway?.startAccount;
24
- if (!startAccount) {
25
- throw new Error("Expected Twitch gateway startAccount");
26
- }
27
- return startAccount;
28
- }
29
-
30
- function buildAccount(): TwitchAccountConfig & { accountId: string } {
31
- return {
32
- accountId: "default",
33
- username: "testbot",
34
- accessToken: "oauth:test-token",
35
- clientId: "test-client-id",
36
- channel: "#testchannel",
37
- enabled: true,
38
- };
39
- }
40
-
41
- function mockStartedMonitor() {
42
- const stop = vi.fn();
43
- hoisted.monitorTwitchProvider.mockResolvedValue({ stop });
44
- return stop;
45
- }
46
-
47
- function startTwitchAccount(abortSignal?: AbortSignal) {
48
- return requireStartAccount()(
49
- createStartAccountContext({
50
- account: buildAccount(),
51
- abortSignal,
52
- }),
53
- );
54
- }
55
-
56
- describe("twitch startAccount lifecycle", () => {
57
- afterEach(() => {
58
- vi.clearAllMocks();
59
- });
60
-
61
- it("keeps startAccount pending until abort, then stops the monitor", async () => {
62
- const stop = mockStartedMonitor();
63
- const { abort, task, isSettled } = startAccountAndTrackLifecycle({
64
- startAccount: requireStartAccount(),
65
- account: buildAccount(),
66
- });
67
- await expectStopPendingUntilAbort({
68
- waitForStarted: waitForStartedMocks(hoisted.monitorTwitchProvider),
69
- isSettled,
70
- abort,
71
- task,
72
- stop,
73
- });
74
- });
75
-
76
- it("stops immediately when startAccount receives an already-aborted signal", async () => {
77
- const stop = mockStartedMonitor();
78
- const abort = new AbortController();
79
- abort.abort();
80
-
81
- await startTwitchAccount(abort.signal);
82
-
83
- expect(hoisted.monitorTwitchProvider).toHaveBeenCalledOnce();
84
- expect(stop).toHaveBeenCalledOnce();
85
- });
86
- });
@@ -1,120 +0,0 @@
1
- /**
2
- * Live Twitch IRC verification for the runStoppablePassiveMonitor lifecycle
3
- * pattern used by the Twitch gateway.
4
- *
5
- * This test connects to irc.chat.twitch.tv using the same twurple stack the
6
- * Twitch plugin uses, then drives that connection through the helper this PR
7
- * wires into twitchPlugin.gateway.startAccount. It asserts the post-fix
8
- * invariant — startAccount-shaped task stays pending after a successful
9
- * connection and only resolves when the abort signal fires — using real
10
- * network rather than mocks.
11
- *
12
- * Skipped by default. Enable with:
13
- * TWITCH_LIVE_TEST=1
14
- * TWITCH_USERNAME=<bot username>
15
- * TWITCH_ACCESS_TOKEN=<oauth:token without the "oauth:" prefix>
16
- * TWITCH_CLIENT_ID=<client id>
17
- * TWITCH_CHANNEL=<channel name to join>
18
- */
19
-
20
- import { StaticAuthProvider } from "@twurple/auth";
21
- import { ChatClient } from "@twurple/chat";
22
- import { runStoppablePassiveMonitor } from "klaw/plugin-sdk/extension-shared";
23
- import { describe, expect, it } from "vitest";
24
-
25
- const LIVE = process.env.TWITCH_LIVE_TEST === "1";
26
- const HAS_CREDS = Boolean(
27
- process.env.TWITCH_USERNAME &&
28
- process.env.TWITCH_ACCESS_TOKEN &&
29
- process.env.TWITCH_CLIENT_ID &&
30
- process.env.TWITCH_CHANNEL,
31
- );
32
-
33
- const maybeDescribe = LIVE && HAS_CREDS ? describe : describe.skip;
34
-
35
- maybeDescribe("twitch live IRC lifecycle (skipped unless TWITCH_LIVE_TEST=1)", () => {
36
- it("real twurple connection + runStoppablePassiveMonitor stays pending until abort, then stops cleanly", async () => {
37
- const accessTokenRaw = process.env.TWITCH_ACCESS_TOKEN!.replace(/^oauth:/, "");
38
- const clientId = process.env.TWITCH_CLIENT_ID!;
39
- const channel = process.env.TWITCH_CHANNEL!;
40
- const username = process.env.TWITCH_USERNAME!;
41
-
42
- const start = Date.now();
43
- const log = (msg: string) => {
44
- console.log(`[T+${Date.now() - start}ms] ${msg}`);
45
- };
46
-
47
- log(`username=${username} channel=#${channel}`);
48
-
49
- const authProvider = new StaticAuthProvider(clientId, accessTokenRaw, [
50
- "chat:read",
51
- "chat:edit",
52
- ]);
53
-
54
- const abort = new AbortController();
55
- let connectedAt: number | null = null;
56
- let settled = false;
57
- let stopCalled = false;
58
-
59
- const task = runStoppablePassiveMonitor({
60
- abortSignal: abort.signal,
61
- start: async () => {
62
- const chat = new ChatClient({
63
- authProvider,
64
- channels: [channel],
65
- authIntents: ["chat"],
66
- });
67
-
68
- chat.onConnect(() => {
69
- connectedAt = Date.now() - start;
70
- log(`Connected to Twitch as ${username}`);
71
- });
72
- chat.onJoin((joinedChannel: string, joinedUser: string) => {
73
- log(`Joined #${joinedChannel} as ${joinedUser}`);
74
- });
75
- chat.onDisconnect((manually: boolean, reason?: Error) => {
76
- log(`Disconnected (manual=${manually}, reason=${reason?.message ?? "n/a"})`);
77
- });
78
-
79
- chat.connect();
80
-
81
- return {
82
- stop: () => {
83
- stopCalled = true;
84
- log(`stop() invoked`);
85
- chat.quit();
86
- },
87
- };
88
- },
89
- })
90
- .then(() => {
91
- settled = true;
92
- log(`task RESOLVED`);
93
- })
94
- .catch((err: unknown) => {
95
- settled = true;
96
- log(`task REJECTED: ${err instanceof Error ? err.message : String(err)}`);
97
- throw err;
98
- });
99
-
100
- // Wait long enough that the original bug would have manifested.
101
- // The reported time-to-restart in #60071 is ~2ms after connect.
102
- const WATCH_MS = 15_000;
103
- await new Promise((resolve) => setTimeout(resolve, WATCH_MS));
104
-
105
- expect(connectedAt, "expected onConnect within the watch window").not.toBeNull();
106
- expect(settled, "task must not have settled before abort").toBe(false);
107
- log(
108
- `--- t+${WATCH_MS}ms checkpoint: connected=${connectedAt}ms, settled=${settled}, stopCalled=${stopCalled}`,
109
- );
110
-
111
- abort.abort();
112
- log(`abort() called`);
113
-
114
- await task;
115
-
116
- expect(settled).toBe(true);
117
- expect(stopCalled, "stop hook must run on abort").toBe(true);
118
- log(`PASS — promise pending for ${WATCH_MS}ms after connect, then stopped on abort`);
119
- }, 60_000);
120
- });
@@ -1,77 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { KlawConfig } from "../api.js";
3
- import { twitchPlugin } from "./plugin.js";
4
-
5
- describe("twitchPlugin pairing", () => {
6
- it("normalizes trimmed twitch user prefixes in allow entries", () => {
7
- expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" twitch:user:123456 ")).toBe("123456");
8
- expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" user789012 ")).toBe("789012");
9
- });
10
- });
11
-
12
- describe("twitchPlugin.status.buildAccountSnapshot", () => {
13
- it("uses the resolved account ID for multi-account configs", async () => {
14
- const secondary = {
15
- channel: "secondary-channel",
16
- username: "secondary",
17
- accessToken: "oauth:secondary-token",
18
- clientId: "secondary-client",
19
- enabled: true,
20
- };
21
-
22
- const cfg = {
23
- channels: {
24
- twitch: {
25
- accounts: {
26
- default: {
27
- channel: "default-channel",
28
- username: "default",
29
- accessToken: "oauth:default-token",
30
- clientId: "default-client",
31
- enabled: true,
32
- },
33
- secondary,
34
- },
35
- },
36
- },
37
- } as KlawConfig;
38
-
39
- const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
40
- account: secondary,
41
- cfg,
42
- });
43
-
44
- expect(snapshot?.accountId).toBe("secondary");
45
- });
46
- });
47
-
48
- describe("twitchPlugin.config", () => {
49
- it("uses configured defaultAccount for omitted-account plugin resolution", () => {
50
- const cfg = {
51
- channels: {
52
- twitch: {
53
- defaultAccount: "secondary",
54
- accounts: {
55
- default: {
56
- channel: "default-channel",
57
- username: "default",
58
- accessToken: "oauth:default-token",
59
- clientId: "default-client",
60
- enabled: true,
61
- },
62
- secondary: {
63
- channel: "secondary-channel",
64
- username: "secondary",
65
- accessToken: "oauth:secondary-token",
66
- clientId: "secondary-client",
67
- enabled: true,
68
- },
69
- },
70
- },
71
- },
72
- } as KlawConfig;
73
-
74
- expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
75
- expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary");
76
- });
77
- });