@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.
- package/README.md +89 -0
- package/api.ts +21 -0
- package/autobot.plugin.json +15 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +16 -0
- package/package.json +50 -0
- package/runtime-api.ts +22 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/access-control.ts +195 -0
- package/src/actions.ts +175 -0
- package/src/client-manager-registry.ts +109 -0
- package/src/config-schema.ts +88 -0
- package/src/config.ts +177 -0
- package/src/monitor.ts +311 -0
- package/src/outbound.ts +242 -0
- package/src/plugin.ts +220 -0
- package/src/probe.ts +130 -0
- package/src/resolver.ts +139 -0
- package/src/runtime.ts +9 -0
- package/src/send.ts +191 -0
- package/src/setup-surface.ts +526 -0
- package/src/status.ts +179 -0
- package/src/token.ts +93 -0
- package/src/twitch-client.ts +281 -0
- package/src/types.ts +104 -0
- package/src/utils/markdown.ts +98 -0
- package/src/utils/twitch.ts +81 -0
- package/tsconfig.json +16 -0
package/src/outbound.ts
ADDED
|
@@ -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
|
+
}
|