@gakr-gakr/twitch 0.1.0

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.
@@ -0,0 +1,242 @@
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 "autobot/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: autobotConfig,
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: autobotConfig,
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
+ });
package/src/plugin.ts ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Twitch channel plugin for AutoBot.
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 "autobot/plugin-sdk/account-helpers";
9
+ import { buildChannelConfigSchema } from "autobot/plugin-sdk/channel-config-schema";
10
+ import { createChatChannelPlugin } from "autobot/plugin-sdk/channel-core";
11
+ import {
12
+ createLoggedPairingApprovalNotifier,
13
+ createPairingPrefixStripper,
14
+ } from "autobot/plugin-sdk/channel-pairing";
15
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
16
+ import {
17
+ buildPassiveProbedChannelStatusSummary,
18
+ runStoppablePassiveMonitor,
19
+ } from "autobot/plugin-sdk/extension-shared";
20
+ import {
21
+ createComputedAccountStatusAdapter,
22
+ createDefaultChannelRuntimeState,
23
+ } from "autobot/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 AutoBot. 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: AutoBotConfig): string[] => listAccountIds(cfg),
88
+ resolveAccount: (cfg: AutoBotConfig, 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: AutoBotConfig): string => resolveDefaultTwitchAccountId(cfg),
107
+ isConfigured: (_account: unknown, cfg: AutoBotConfig): 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: AutoBotConfig;
133
+ accountId?: string | null;
134
+ inputs: string[];
135
+ kind: ChannelResolveKind;
136
+ runtime: import("autobot/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
+ });
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 "autobot/plugin-sdk/channel-contract";
4
+ import { formatErrorMessage } from "autobot/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
+ }