@kodelyth/twitch 2026.5.39 → 2026.5.42
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 +89 -0
- package/api.ts +21 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/api.js +3 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/index.js +18 -0
- package/dist/monitor-j1GtQVBd.js +337 -0
- package/dist/plugin-BMzrFFQR.js +1285 -0
- package/dist/runtime-CwXHrWo3.js +8 -0
- package/dist/runtime-api.js +1 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-plugin-api.js +2 -0
- package/dist/setup-surface-CovnRl9R.js +527 -0
- package/index.test.ts +13 -0
- package/index.ts +16 -0
- package/klaw.plugin.json +2 -219
- package/package.json +3 -3
- 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 +373 -0
- package/src/access-control.ts +195 -0
- package/src/actions.test.ts +75 -0
- package/src/actions.ts +175 -0
- package/src/client-manager-registry.ts +87 -0
- package/src/config-schema.test.ts +46 -0
- package/src/config-schema.ts +88 -0
- package/src/config.test.ts +233 -0
- package/src/config.ts +177 -0
- package/src/monitor.ts +311 -0
- package/src/outbound.test.ts +572 -0
- package/src/outbound.ts +242 -0
- package/src/plugin.lifecycle.test.ts +86 -0
- package/src/plugin.live.test.ts +120 -0
- package/src/plugin.test.ts +77 -0
- package/src/plugin.ts +220 -0
- package/src/probe.test.ts +196 -0
- package/src/probe.ts +130 -0
- package/src/resolver.ts +139 -0
- package/src/runtime.ts +9 -0
- package/src/send.test.ts +342 -0
- package/src/send.ts +191 -0
- package/src/setup-surface.test.ts +529 -0
- package/src/setup-surface.ts +526 -0
- package/src/status.test.ts +298 -0
- package/src/status.ts +179 -0
- package/src/test-fixtures.ts +30 -0
- package/src/token.test.ts +198 -0
- package/src/token.ts +93 -0
- package/src/twitch-client.test.ts +574 -0
- package/src/twitch-client.ts +276 -0
- package/src/types.ts +104 -0
- package/src/utils/markdown.ts +98 -0
- package/src/utils/twitch.ts +81 -0
- package/test/setup.ts +7 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/setup-entry.js +0 -7
- package/setup-plugin-api.js +0 -7
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch channel plugin for Klaw.
|
|
3
|
+
*
|
|
4
|
+
* Main plugin export combining all adapters (outbound, actions, status, gateway).
|
|
5
|
+
* This is the primary entry point for the Twitch channel integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describeAccountSnapshot } from "klaw/plugin-sdk/account-helpers";
|
|
9
|
+
import { buildChannelConfigSchema } from "klaw/plugin-sdk/channel-config-schema";
|
|
10
|
+
import { createChatChannelPlugin } from "klaw/plugin-sdk/channel-core";
|
|
11
|
+
import {
|
|
12
|
+
createLoggedPairingApprovalNotifier,
|
|
13
|
+
createPairingPrefixStripper,
|
|
14
|
+
} from "klaw/plugin-sdk/channel-pairing";
|
|
15
|
+
import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
|
|
16
|
+
import {
|
|
17
|
+
buildPassiveProbedChannelStatusSummary,
|
|
18
|
+
runStoppablePassiveMonitor,
|
|
19
|
+
} from "klaw/plugin-sdk/extension-shared";
|
|
20
|
+
import {
|
|
21
|
+
createComputedAccountStatusAdapter,
|
|
22
|
+
createDefaultChannelRuntimeState,
|
|
23
|
+
} from "klaw/plugin-sdk/status-helpers";
|
|
24
|
+
import { twitchMessageActions } from "./actions.js";
|
|
25
|
+
import { removeClientManager } from "./client-manager-registry.js";
|
|
26
|
+
import { TwitchConfigSchema } from "./config-schema.js";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_ACCOUNT_ID,
|
|
29
|
+
getAccountConfig,
|
|
30
|
+
listAccountIds,
|
|
31
|
+
resolveDefaultTwitchAccountId,
|
|
32
|
+
resolveTwitchAccountContext,
|
|
33
|
+
resolveTwitchSnapshotAccountId,
|
|
34
|
+
} from "./config.js";
|
|
35
|
+
import { twitchMessageAdapter, twitchOutbound } from "./outbound.js";
|
|
36
|
+
import { probeTwitch } from "./probe.js";
|
|
37
|
+
import { resolveTwitchTargets } from "./resolver.js";
|
|
38
|
+
import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js";
|
|
39
|
+
import { collectTwitchStatusIssues } from "./status.js";
|
|
40
|
+
import type {
|
|
41
|
+
ChannelLogSink,
|
|
42
|
+
ChannelPlugin,
|
|
43
|
+
ChannelResolveKind,
|
|
44
|
+
ChannelResolveResult,
|
|
45
|
+
TwitchAccountConfig,
|
|
46
|
+
} from "./types.js";
|
|
47
|
+
import { isAccountConfigured } from "./utils/twitch.js";
|
|
48
|
+
|
|
49
|
+
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Twitch channel plugin.
|
|
53
|
+
*
|
|
54
|
+
* Implements the ChannelPlugin interface to provide Twitch chat integration
|
|
55
|
+
* for Klaw. Supports message sending, receiving, access control, and
|
|
56
|
+
* status monitoring.
|
|
57
|
+
*/
|
|
58
|
+
export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
|
59
|
+
createChatChannelPlugin<ResolvedTwitchAccount>({
|
|
60
|
+
pairing: {
|
|
61
|
+
idLabel: "twitchUserId",
|
|
62
|
+
normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
|
|
63
|
+
notifyApproval: createLoggedPairingApprovalNotifier(
|
|
64
|
+
({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`,
|
|
65
|
+
console.warn,
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
outbound: twitchOutbound,
|
|
69
|
+
base: {
|
|
70
|
+
id: "twitch",
|
|
71
|
+
meta: {
|
|
72
|
+
id: "twitch",
|
|
73
|
+
label: "Twitch",
|
|
74
|
+
selectionLabel: "Twitch (Chat)",
|
|
75
|
+
docsPath: "/channels/twitch",
|
|
76
|
+
blurb: "Twitch chat integration",
|
|
77
|
+
aliases: ["twitch-chat"],
|
|
78
|
+
},
|
|
79
|
+
setup: twitchSetupAdapter,
|
|
80
|
+
setupWizard: twitchSetupWizard,
|
|
81
|
+
capabilities: {
|
|
82
|
+
chatTypes: ["group"],
|
|
83
|
+
},
|
|
84
|
+
message: twitchMessageAdapter,
|
|
85
|
+
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
|
|
86
|
+
config: {
|
|
87
|
+
listAccountIds: (cfg: KlawConfig): string[] => listAccountIds(cfg),
|
|
88
|
+
resolveAccount: (cfg: KlawConfig, accountId?: string | null): ResolvedTwitchAccount => {
|
|
89
|
+
const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
|
|
90
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
91
|
+
if (!account) {
|
|
92
|
+
return {
|
|
93
|
+
accountId: resolvedAccountId,
|
|
94
|
+
channel: "",
|
|
95
|
+
username: "",
|
|
96
|
+
accessToken: "",
|
|
97
|
+
clientId: "",
|
|
98
|
+
enabled: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
accountId: resolvedAccountId,
|
|
103
|
+
...account,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
defaultAccountId: (cfg: KlawConfig): string => resolveDefaultTwitchAccountId(cfg),
|
|
107
|
+
isConfigured: (_account: unknown, cfg: KlawConfig): boolean =>
|
|
108
|
+
resolveTwitchAccountContext(cfg).configured,
|
|
109
|
+
isEnabled: (account: ResolvedTwitchAccount | undefined): boolean =>
|
|
110
|
+
account?.enabled !== false,
|
|
111
|
+
describeAccount: (account: TwitchAccountConfig | undefined) =>
|
|
112
|
+
account
|
|
113
|
+
? describeAccountSnapshot({
|
|
114
|
+
account,
|
|
115
|
+
configured: isAccountConfigured(account, account.accessToken),
|
|
116
|
+
})
|
|
117
|
+
: {
|
|
118
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
119
|
+
enabled: false,
|
|
120
|
+
configured: false,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
actions: twitchMessageActions,
|
|
124
|
+
resolver: {
|
|
125
|
+
resolveTargets: async ({
|
|
126
|
+
cfg,
|
|
127
|
+
accountId,
|
|
128
|
+
inputs,
|
|
129
|
+
kind,
|
|
130
|
+
runtime,
|
|
131
|
+
}: {
|
|
132
|
+
cfg: KlawConfig;
|
|
133
|
+
accountId?: string | null;
|
|
134
|
+
inputs: string[];
|
|
135
|
+
kind: ChannelResolveKind;
|
|
136
|
+
runtime: import("klaw/plugin-sdk/runtime-env").RuntimeEnv;
|
|
137
|
+
}): Promise<ChannelResolveResult[]> => {
|
|
138
|
+
const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
|
|
139
|
+
if (!account) {
|
|
140
|
+
return inputs.map((input) => ({
|
|
141
|
+
input,
|
|
142
|
+
resolved: false,
|
|
143
|
+
note: "account not configured",
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const log: ChannelLogSink = {
|
|
148
|
+
info: (msg) => runtime.log(msg),
|
|
149
|
+
warn: (msg) => runtime.log(msg),
|
|
150
|
+
error: (msg) => runtime.error(msg),
|
|
151
|
+
debug: (msg) => runtime.log(msg),
|
|
152
|
+
};
|
|
153
|
+
return await resolveTwitchTargets(inputs, account, kind, log);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
status: createComputedAccountStatusAdapter<ResolvedTwitchAccount>({
|
|
157
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
158
|
+
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
|
|
159
|
+
probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
|
|
160
|
+
collectStatusIssues: collectTwitchStatusIssues,
|
|
161
|
+
resolveAccountSnapshot: ({ account, cfg }) => {
|
|
162
|
+
const resolvedAccountId =
|
|
163
|
+
account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
|
|
164
|
+
const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
|
165
|
+
return {
|
|
166
|
+
accountId: resolvedAccountId,
|
|
167
|
+
enabled: account.enabled !== false,
|
|
168
|
+
configured,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
gateway: {
|
|
173
|
+
startAccount: async (ctx): Promise<void> => {
|
|
174
|
+
const account = ctx.account;
|
|
175
|
+
const accountId = ctx.accountId;
|
|
176
|
+
|
|
177
|
+
ctx.setStatus?.({
|
|
178
|
+
accountId,
|
|
179
|
+
running: true,
|
|
180
|
+
lastStartAt: Date.now(),
|
|
181
|
+
lastError: null,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
ctx.log?.info(`Starting Twitch connection for ${account.username}`);
|
|
185
|
+
|
|
186
|
+
// Keep startAccount pending until abort fires; otherwise the channel
|
|
187
|
+
// supervisor reads the settled task as `channel exited without an
|
|
188
|
+
// error` and triggers a restart loop. See #60071.
|
|
189
|
+
await runStoppablePassiveMonitor({
|
|
190
|
+
abortSignal: ctx.abortSignal,
|
|
191
|
+
start: async () => {
|
|
192
|
+
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
|
193
|
+
const { monitorTwitchProvider } = await import("./monitor.js");
|
|
194
|
+
return monitorTwitchProvider({
|
|
195
|
+
account,
|
|
196
|
+
accountId,
|
|
197
|
+
config: ctx.cfg,
|
|
198
|
+
runtime: ctx.runtime,
|
|
199
|
+
abortSignal: ctx.abortSignal,
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
stopAccount: async (ctx): Promise<void> => {
|
|
205
|
+
const account = ctx.account;
|
|
206
|
+
const accountId = ctx.accountId;
|
|
207
|
+
|
|
208
|
+
await removeClientManager(accountId);
|
|
209
|
+
|
|
210
|
+
ctx.setStatus?.({
|
|
211
|
+
accountId,
|
|
212
|
+
running: false,
|
|
213
|
+
lastStopAt: Date.now(),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { probeTwitch } from "./probe.js";
|
|
3
|
+
import type { TwitchAccountConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// Mock Twurple modules - Vitest v4 compatible mocking
|
|
6
|
+
const mockUnbind = vi.fn();
|
|
7
|
+
|
|
8
|
+
// Event handler storage
|
|
9
|
+
let connectHandler: (() => void) | null = null;
|
|
10
|
+
let disconnectHandler: ((manually: boolean, reason?: Error) => void) | null = null;
|
|
11
|
+
|
|
12
|
+
// Event listener mocks that store handlers and return unbind function
|
|
13
|
+
const mockOnConnect = vi.fn((handler: () => void) => {
|
|
14
|
+
connectHandler = handler;
|
|
15
|
+
return { unbind: mockUnbind };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const mockOnDisconnect = vi.fn((handler: (manually: boolean, reason?: Error) => void) => {
|
|
19
|
+
disconnectHandler = handler;
|
|
20
|
+
return { unbind: mockUnbind };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const mockOnAuthenticationFailure = vi.fn((_handler: () => void) => {
|
|
24
|
+
return { unbind: mockUnbind };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Connect mock that triggers the registered handler
|
|
28
|
+
const defaultConnectImpl = async () => {
|
|
29
|
+
// Simulate successful connection by calling the handler immediately.
|
|
30
|
+
if (connectHandler) {
|
|
31
|
+
connectHandler();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mockConnect = vi.fn().mockImplementation(defaultConnectImpl);
|
|
36
|
+
|
|
37
|
+
const mockQuit = vi.fn().mockResolvedValue(undefined);
|
|
38
|
+
|
|
39
|
+
vi.mock("@twurple/chat", () => ({
|
|
40
|
+
ChatClient: class {
|
|
41
|
+
connect = mockConnect;
|
|
42
|
+
quit = mockQuit;
|
|
43
|
+
onConnect = mockOnConnect;
|
|
44
|
+
onDisconnect = mockOnDisconnect;
|
|
45
|
+
onAuthenticationFailure = mockOnAuthenticationFailure;
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock("@twurple/auth", () => ({
|
|
50
|
+
StaticAuthProvider: function StaticAuthProvider() {},
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
describe("probeTwitch", () => {
|
|
54
|
+
const mockAccount: TwitchAccountConfig = {
|
|
55
|
+
username: "testbot",
|
|
56
|
+
accessToken: "oauth:test123456789",
|
|
57
|
+
clientId: "test-client-id",
|
|
58
|
+
channel: "testchannel",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
// Reset handlers
|
|
64
|
+
connectHandler = null;
|
|
65
|
+
disconnectHandler = null;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns error when username is missing", async () => {
|
|
69
|
+
const account = { ...mockAccount, username: "" };
|
|
70
|
+
const result = await probeTwitch(account, 5000);
|
|
71
|
+
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
expect(result.error).toContain("missing credentials");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns error when token is missing", async () => {
|
|
77
|
+
const account = { ...mockAccount, accessToken: "" };
|
|
78
|
+
const result = await probeTwitch(account, 5000);
|
|
79
|
+
|
|
80
|
+
expect(result.ok).toBe(false);
|
|
81
|
+
expect(result.error).toContain("missing credentials");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("attempts connection regardless of token prefix", async () => {
|
|
85
|
+
// Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided
|
|
86
|
+
// The actual connection would fail in production with an invalid token
|
|
87
|
+
const account = { ...mockAccount, accessToken: "raw_token_no_prefix" };
|
|
88
|
+
const result = await probeTwitch(account, 5000);
|
|
89
|
+
|
|
90
|
+
// With mock, connection succeeds even without oauth: prefix
|
|
91
|
+
expect(result.ok).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("successfully connects with valid credentials", async () => {
|
|
95
|
+
const result = await probeTwitch(mockAccount, 5000);
|
|
96
|
+
|
|
97
|
+
expect(result.ok).toBe(true);
|
|
98
|
+
expect(result.connected).toBe(true);
|
|
99
|
+
expect(result.username).toBe("testbot");
|
|
100
|
+
expect(result.channel).toBe("testchannel"); // uses account's configured channel
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("uses custom channel when specified", async () => {
|
|
104
|
+
const account: TwitchAccountConfig = {
|
|
105
|
+
...mockAccount,
|
|
106
|
+
channel: "customchannel",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const result = await probeTwitch(account, 5000);
|
|
110
|
+
|
|
111
|
+
expect(result.ok).toBe(true);
|
|
112
|
+
expect(result.channel).toBe("customchannel");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("times out when connection takes too long", async () => {
|
|
116
|
+
vi.useFakeTimers();
|
|
117
|
+
try {
|
|
118
|
+
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
|
|
119
|
+
const resultPromise = probeTwitch(mockAccount, 100);
|
|
120
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
121
|
+
const result = await resultPromise;
|
|
122
|
+
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
expect(result.error).toContain("timeout");
|
|
125
|
+
} finally {
|
|
126
|
+
vi.useRealTimers();
|
|
127
|
+
mockConnect.mockImplementation(defaultConnectImpl);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("cleans up client even on failure", async () => {
|
|
132
|
+
mockConnect.mockImplementationOnce(async () => {
|
|
133
|
+
// Simulate connection failure by calling disconnect handler
|
|
134
|
+
// onDisconnect signature: (manually: boolean, reason?: Error) => void
|
|
135
|
+
if (disconnectHandler) {
|
|
136
|
+
disconnectHandler(false, new Error("Connection failed"));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const result = await probeTwitch(mockAccount, 5000);
|
|
141
|
+
|
|
142
|
+
expect(result.ok).toBe(false);
|
|
143
|
+
expect(result.error).toContain("Connection failed");
|
|
144
|
+
expect(mockQuit).toHaveBeenCalled();
|
|
145
|
+
|
|
146
|
+
// Reset mocks
|
|
147
|
+
mockConnect.mockImplementation(defaultConnectImpl);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("handles connection errors gracefully", async () => {
|
|
151
|
+
mockConnect.mockImplementationOnce(async () => {
|
|
152
|
+
// Simulate connection failure by calling disconnect handler
|
|
153
|
+
// onDisconnect signature: (manually: boolean, reason?: Error) => void
|
|
154
|
+
if (disconnectHandler) {
|
|
155
|
+
disconnectHandler(false, new Error("Network error"));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const result = await probeTwitch(mockAccount, 5000);
|
|
160
|
+
|
|
161
|
+
expect(result.ok).toBe(false);
|
|
162
|
+
expect(result.error).toContain("Network error");
|
|
163
|
+
|
|
164
|
+
// Reset mock
|
|
165
|
+
mockConnect.mockImplementation(defaultConnectImpl);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("trims token before validation", async () => {
|
|
169
|
+
const account: TwitchAccountConfig = {
|
|
170
|
+
...mockAccount,
|
|
171
|
+
accessToken: " oauth:test123456789 ",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const result = await probeTwitch(account, 5000);
|
|
175
|
+
|
|
176
|
+
expect(result.ok).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("handles non-Error objects in catch block", async () => {
|
|
180
|
+
mockConnect.mockImplementationOnce(async () => {
|
|
181
|
+
// Simulate connection failure by calling disconnect handler
|
|
182
|
+
// onDisconnect signature: (manually: boolean, reason?: Error) => void
|
|
183
|
+
if (disconnectHandler) {
|
|
184
|
+
disconnectHandler(false, "String error" as unknown as Error);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result = await probeTwitch(mockAccount, 5000);
|
|
189
|
+
|
|
190
|
+
expect(result.ok).toBe(false);
|
|
191
|
+
expect(result.error).toBe("String error");
|
|
192
|
+
|
|
193
|
+
// Reset mock
|
|
194
|
+
mockConnect.mockImplementation(defaultConnectImpl);
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { StaticAuthProvider } from "@twurple/auth";
|
|
2
|
+
import { ChatClient } from "@twurple/chat";
|
|
3
|
+
import type { BaseProbeResult } from "klaw/plugin-sdk/channel-contract";
|
|
4
|
+
import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
|
|
5
|
+
import type { TwitchAccountConfig } from "./types.js";
|
|
6
|
+
import { normalizeToken } from "./utils/twitch.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Result of probing a Twitch account
|
|
10
|
+
*/
|
|
11
|
+
type ProbeTwitchResult = BaseProbeResult<string> & {
|
|
12
|
+
username?: string;
|
|
13
|
+
elapsedMs: number;
|
|
14
|
+
connected?: boolean;
|
|
15
|
+
channel?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Probe a Twitch account to verify the connection is working
|
|
20
|
+
*
|
|
21
|
+
* This tests the Twitch OAuth token by attempting to connect
|
|
22
|
+
* to the chat server and verify the bot's username.
|
|
23
|
+
*/
|
|
24
|
+
export async function probeTwitch(
|
|
25
|
+
account: TwitchAccountConfig,
|
|
26
|
+
timeoutMs: number,
|
|
27
|
+
): Promise<ProbeTwitchResult> {
|
|
28
|
+
const started = Date.now();
|
|
29
|
+
|
|
30
|
+
if (!account.accessToken || !account.username) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: "missing credentials (accessToken, username)",
|
|
34
|
+
username: account.username,
|
|
35
|
+
elapsedMs: Date.now() - started,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rawToken = normalizeToken(account.accessToken.trim());
|
|
40
|
+
|
|
41
|
+
let client: ChatClient | undefined;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
|
|
45
|
+
|
|
46
|
+
client = new ChatClient({
|
|
47
|
+
authProvider,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Create a promise that resolves when connected
|
|
51
|
+
const connectionPromise = new Promise<void>((resolve, reject) => {
|
|
52
|
+
let settled = false;
|
|
53
|
+
let connectListener: ReturnType<ChatClient["onConnect"]> | undefined;
|
|
54
|
+
let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined;
|
|
55
|
+
let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined;
|
|
56
|
+
|
|
57
|
+
const cleanup = () => {
|
|
58
|
+
if (settled) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
settled = true;
|
|
62
|
+
connectListener?.unbind();
|
|
63
|
+
disconnectListener?.unbind();
|
|
64
|
+
authFailListener?.unbind();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Success: connection established
|
|
68
|
+
connectListener = client?.onConnect(() => {
|
|
69
|
+
cleanup();
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Failure: disconnected (e.g., auth failed)
|
|
74
|
+
disconnectListener = client?.onDisconnect((_manually, reason) => {
|
|
75
|
+
cleanup();
|
|
76
|
+
reject(reason || new Error("Disconnected"));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Failure: authentication failed
|
|
80
|
+
authFailListener = client?.onAuthenticationFailure(() => {
|
|
81
|
+
cleanup();
|
|
82
|
+
reject(new Error("Authentication failed"));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
87
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
88
|
+
timeoutHandle = setTimeout(
|
|
89
|
+
() => reject(new Error(`timeout after ${timeoutMs}ms`)),
|
|
90
|
+
timeoutMs,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
client.connect();
|
|
95
|
+
try {
|
|
96
|
+
await Promise.race([connectionPromise, timeout]);
|
|
97
|
+
} finally {
|
|
98
|
+
if (timeoutHandle) {
|
|
99
|
+
clearTimeout(timeoutHandle);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
client.quit();
|
|
104
|
+
client = undefined;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
connected: true,
|
|
109
|
+
username: account.username,
|
|
110
|
+
channel: account.channel,
|
|
111
|
+
elapsedMs: Date.now() - started,
|
|
112
|
+
};
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: formatErrorMessage(error),
|
|
117
|
+
username: account.username,
|
|
118
|
+
channel: account.channel,
|
|
119
|
+
elapsedMs: Date.now() - started,
|
|
120
|
+
};
|
|
121
|
+
} finally {
|
|
122
|
+
if (client) {
|
|
123
|
+
try {
|
|
124
|
+
client.quit();
|
|
125
|
+
} catch {
|
|
126
|
+
// Ignore cleanup errors
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/resolver.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch resolver adapter for channel/user name resolution.
|
|
3
|
+
*
|
|
4
|
+
* This module implements the ChannelResolverAdapter interface to resolve
|
|
5
|
+
* Twitch usernames to user IDs via the Twitch Helix API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ApiClient } from "@twurple/api";
|
|
9
|
+
import { StaticAuthProvider } from "@twurple/auth";
|
|
10
|
+
import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
|
|
11
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
12
|
+
import type { ChannelResolveKind, ChannelResolveResult } from "./types.js";
|
|
13
|
+
import type { ChannelLogSink, TwitchAccountConfig } from "./types.js";
|
|
14
|
+
import { normalizeToken } from "./utils/twitch.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize a Twitch username - strip @ prefix and convert to lowercase
|
|
18
|
+
*/
|
|
19
|
+
function normalizeUsername(input: string): string {
|
|
20
|
+
const trimmed = input.trim();
|
|
21
|
+
if (trimmed.startsWith("@")) {
|
|
22
|
+
return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
|
|
23
|
+
}
|
|
24
|
+
return normalizeLowercaseStringOrEmpty(trimmed);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a logger that includes the Twitch prefix
|
|
29
|
+
*/
|
|
30
|
+
function createLogger(logger?: ChannelLogSink): ChannelLogSink {
|
|
31
|
+
return {
|
|
32
|
+
info: (msg: string) => logger?.info(msg),
|
|
33
|
+
warn: (msg: string) => logger?.warn(msg),
|
|
34
|
+
error: (msg: string) => logger?.error(msg),
|
|
35
|
+
debug: (msg: string) => logger?.debug?.(msg) ?? (() => {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve Twitch usernames to user IDs via the Helix API
|
|
41
|
+
*
|
|
42
|
+
* @param inputs - Array of usernames or user IDs to resolve
|
|
43
|
+
* @param account - Twitch account configuration with auth credentials
|
|
44
|
+
* @param kind - Type of target to resolve ("user" or "group")
|
|
45
|
+
* @param logger - Optional logger
|
|
46
|
+
* @returns Promise resolving to array of ChannelResolveResult
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveTwitchTargets(
|
|
49
|
+
inputs: string[],
|
|
50
|
+
account: TwitchAccountConfig,
|
|
51
|
+
_kind: ChannelResolveKind,
|
|
52
|
+
logger?: ChannelLogSink,
|
|
53
|
+
): Promise<ChannelResolveResult[]> {
|
|
54
|
+
const log = createLogger(logger);
|
|
55
|
+
|
|
56
|
+
if (!account.clientId || !account.accessToken) {
|
|
57
|
+
log.error("Missing Twitch client ID or accessToken");
|
|
58
|
+
return inputs.map((input) => ({
|
|
59
|
+
input,
|
|
60
|
+
resolved: false,
|
|
61
|
+
note: "missing Twitch credentials",
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const normalizedToken = normalizeToken(account.accessToken);
|
|
66
|
+
|
|
67
|
+
const authProvider = new StaticAuthProvider(account.clientId, normalizedToken);
|
|
68
|
+
const apiClient = new ApiClient({ authProvider });
|
|
69
|
+
|
|
70
|
+
const results: ChannelResolveResult[] = [];
|
|
71
|
+
|
|
72
|
+
for (const input of inputs) {
|
|
73
|
+
const normalized = normalizeUsername(input);
|
|
74
|
+
|
|
75
|
+
if (!normalized) {
|
|
76
|
+
results.push({
|
|
77
|
+
input,
|
|
78
|
+
resolved: false,
|
|
79
|
+
note: "empty input",
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const looksLikeUserId = /^\d+$/.test(normalized);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (looksLikeUserId) {
|
|
88
|
+
const user = await apiClient.users.getUserById(normalized);
|
|
89
|
+
|
|
90
|
+
if (user) {
|
|
91
|
+
results.push({
|
|
92
|
+
input,
|
|
93
|
+
resolved: true,
|
|
94
|
+
id: user.id,
|
|
95
|
+
name: user.name,
|
|
96
|
+
});
|
|
97
|
+
log.debug?.(`Resolved user ID ${normalized} -> ${user.name}`);
|
|
98
|
+
} else {
|
|
99
|
+
results.push({
|
|
100
|
+
input,
|
|
101
|
+
resolved: false,
|
|
102
|
+
note: "user ID not found",
|
|
103
|
+
});
|
|
104
|
+
log.warn(`User ID ${normalized} not found`);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
const user = await apiClient.users.getUserByName(normalized);
|
|
108
|
+
|
|
109
|
+
if (user) {
|
|
110
|
+
results.push({
|
|
111
|
+
input,
|
|
112
|
+
resolved: true,
|
|
113
|
+
id: user.id,
|
|
114
|
+
name: user.name,
|
|
115
|
+
note: user.displayName !== user.name ? `display: ${user.displayName}` : undefined,
|
|
116
|
+
});
|
|
117
|
+
log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
|
|
118
|
+
} else {
|
|
119
|
+
results.push({
|
|
120
|
+
input,
|
|
121
|
+
resolved: false,
|
|
122
|
+
note: "username not found",
|
|
123
|
+
});
|
|
124
|
+
log.warn(`Username ${normalized} not found`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const errorMessage = formatErrorMessage(error);
|
|
129
|
+
results.push({
|
|
130
|
+
input,
|
|
131
|
+
resolved: false,
|
|
132
|
+
note: `API error: ${errorMessage}`,
|
|
133
|
+
});
|
|
134
|
+
log.error(`Failed to resolve ${input}: ${errorMessage}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PluginRuntime } from "klaw/plugin-sdk/core";
|
|
2
|
+
import { createPluginRuntimeStore } from "klaw/plugin-sdk/runtime-store";
|
|
3
|
+
|
|
4
|
+
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>({
|
|
6
|
+
pluginId: "twitch",
|
|
7
|
+
errorMessage: "Twitch runtime not initialized",
|
|
8
|
+
});
|
|
9
|
+
export { getTwitchRuntime, setTwitchRuntime };
|