@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client manager registry for Twitch plugin.
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of TwitchClientManager instances across the plugin,
|
|
5
|
+
* ensuring proper cleanup when accounts are stopped or reconfigured.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TwitchClientManager } from "./twitch-client.js";
|
|
9
|
+
import type { ChannelLogSink } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Registry entry tracking a client manager and its associated account.
|
|
13
|
+
*/
|
|
14
|
+
type RegistryEntry = {
|
|
15
|
+
/** The client manager instance */
|
|
16
|
+
manager: TwitchClientManager;
|
|
17
|
+
/** The account ID this manager is for */
|
|
18
|
+
accountId: string;
|
|
19
|
+
/** Logger for this entry */
|
|
20
|
+
logger: ChannelLogSink;
|
|
21
|
+
/** When this entry was created */
|
|
22
|
+
createdAt: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Global registry of client managers.
|
|
27
|
+
* Keyed by account ID.
|
|
28
|
+
*/
|
|
29
|
+
const registry = new Map<string, RegistryEntry>();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get or create a client manager for an account.
|
|
33
|
+
*
|
|
34
|
+
* @param accountId - The account ID
|
|
35
|
+
* @param logger - Logger instance
|
|
36
|
+
* @returns The client manager
|
|
37
|
+
*/
|
|
38
|
+
export function getOrCreateClientManager(
|
|
39
|
+
accountId: string,
|
|
40
|
+
logger: ChannelLogSink,
|
|
41
|
+
): TwitchClientManager {
|
|
42
|
+
const existing = registry.get(accountId);
|
|
43
|
+
if (existing) {
|
|
44
|
+
return existing.manager;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const manager = new TwitchClientManager(logger);
|
|
48
|
+
registry.set(accountId, {
|
|
49
|
+
manager,
|
|
50
|
+
accountId,
|
|
51
|
+
logger,
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
logger.info(`Registered client manager for account: ${accountId}`);
|
|
56
|
+
return manager;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get an existing client manager for an account.
|
|
61
|
+
*
|
|
62
|
+
* @param accountId - The account ID
|
|
63
|
+
* @returns The client manager, or undefined if not registered
|
|
64
|
+
*/
|
|
65
|
+
export function getClientManager(accountId: string): TwitchClientManager | undefined {
|
|
66
|
+
return registry.get(accountId)?.manager;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Disconnect and remove a client manager from the registry.
|
|
71
|
+
*
|
|
72
|
+
* @param accountId - The account ID
|
|
73
|
+
* @returns Promise that resolves when cleanup is complete
|
|
74
|
+
*/
|
|
75
|
+
export async function removeClientManager(accountId: string): Promise<void> {
|
|
76
|
+
const entry = registry.get(accountId);
|
|
77
|
+
if (!entry) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Disconnect the client manager
|
|
82
|
+
await entry.manager.disconnectAll();
|
|
83
|
+
|
|
84
|
+
// Remove from registry
|
|
85
|
+
registry.delete(accountId);
|
|
86
|
+
entry.logger.info(`Unregistered client manager for account: ${accountId}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Test-only: clear the module-level registry of all client manager entries.
|
|
91
|
+
*
|
|
92
|
+
* Mirrors the `clearForTest` escape hatch on `TwitchClientManager`. Without
|
|
93
|
+
* this, the module-level `registry` Map survives across tests when vitest
|
|
94
|
+
* is run with `--isolate=false` (or any harness that does not tear the
|
|
95
|
+
* module graph down between cases), and a stale entry from one test will
|
|
96
|
+
* shadow `getOrCreateClientManager` calls in subsequent tests, silently
|
|
97
|
+
* handing back another test's mocked logger/manager. See #83887.
|
|
98
|
+
*
|
|
99
|
+
* Production code MUST NOT call this. It disconnects cached managers before
|
|
100
|
+
* clearing the registry so tests do not leave handlers or clients behind.
|
|
101
|
+
*/
|
|
102
|
+
export async function clearRegistryForTest(): Promise<void> {
|
|
103
|
+
const entries = [...registry.values()];
|
|
104
|
+
try {
|
|
105
|
+
await Promise.all(entries.map((entry) => entry.manager.disconnectAll()));
|
|
106
|
+
} finally {
|
|
107
|
+
registry.clear();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { MarkdownConfigSchema } from "autobot/plugin-sdk/channel-config-primitives";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Twitch user roles that can be allowed to interact with the bot
|
|
6
|
+
*/
|
|
7
|
+
const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
|
|
8
|
+
|
|
9
|
+
const TwitchAccountShape = {
|
|
10
|
+
/** Twitch username */
|
|
11
|
+
username: z.string(),
|
|
12
|
+
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
|
13
|
+
accessToken: z.string(),
|
|
14
|
+
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
|
|
15
|
+
clientId: z.string().optional(),
|
|
16
|
+
/** Channel name to join */
|
|
17
|
+
channel: z.string().min(1),
|
|
18
|
+
/** Enable this account */
|
|
19
|
+
enabled: z.boolean().optional(),
|
|
20
|
+
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
|
|
21
|
+
allowFrom: z.array(z.string()).optional(),
|
|
22
|
+
/** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
|
|
23
|
+
allowedRoles: z.array(TwitchRoleSchema).optional(),
|
|
24
|
+
/** Require @mention to trigger bot responses */
|
|
25
|
+
requireMention: z.boolean().optional(),
|
|
26
|
+
/** Outbound response prefix override for this channel/account. */
|
|
27
|
+
responsePrefix: z.string().optional(),
|
|
28
|
+
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
|
|
29
|
+
clientSecret: z.string().optional(),
|
|
30
|
+
/** Refresh token (required for automatic token refresh) */
|
|
31
|
+
refreshToken: z.string().optional(),
|
|
32
|
+
/** Token expiry time in seconds (optional, for token refresh tracking) */
|
|
33
|
+
expiresIn: z.number().nullable().optional(),
|
|
34
|
+
/** Timestamp when token was obtained (optional, for token refresh tracking) */
|
|
35
|
+
obtainmentTimestamp: z.number().optional(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Twitch account configuration schema
|
|
40
|
+
*/
|
|
41
|
+
const TwitchAccountSchema = z.object(TwitchAccountShape);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Base configuration properties shared by both single and multi-account modes
|
|
45
|
+
*/
|
|
46
|
+
const TwitchConfigBaseShape = {
|
|
47
|
+
name: z.string().optional(),
|
|
48
|
+
enabled: z.boolean().optional(),
|
|
49
|
+
markdown: MarkdownConfigSchema.optional(),
|
|
50
|
+
defaultAccount: z.string().optional(),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Simplified single-account configuration schema
|
|
55
|
+
*
|
|
56
|
+
* Use this for single-account setups. Properties are at the top level,
|
|
57
|
+
* creating an implicit "default" account.
|
|
58
|
+
*/
|
|
59
|
+
const SimplifiedSchema = z.object({
|
|
60
|
+
...TwitchConfigBaseShape,
|
|
61
|
+
...TwitchAccountShape,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Multi-account configuration schema
|
|
66
|
+
*
|
|
67
|
+
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
|
|
68
|
+
*/
|
|
69
|
+
const MultiAccountSchema = z
|
|
70
|
+
.object({
|
|
71
|
+
...TwitchConfigBaseShape,
|
|
72
|
+
/** Per-account configuration (for multi-account setups) */
|
|
73
|
+
accounts: z.record(z.string(), TwitchAccountSchema),
|
|
74
|
+
})
|
|
75
|
+
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
|
|
76
|
+
message: "accounts must contain at least one entry",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Twitch plugin configuration schema
|
|
81
|
+
*
|
|
82
|
+
* Supports two mutually exclusive patterns:
|
|
83
|
+
* 1. Simplified single-account: username, accessToken, clientId, channel at top level
|
|
84
|
+
* 2. Multi-account: accounts object with named account configs
|
|
85
|
+
*
|
|
86
|
+
* The union ensures clear discrimination between the two modes.
|
|
87
|
+
*/
|
|
88
|
+
export const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listCombinedAccountIds,
|
|
3
|
+
normalizeAccountId,
|
|
4
|
+
resolveNormalizedAccountEntry,
|
|
5
|
+
} from "autobot/plugin-sdk/account-resolution";
|
|
6
|
+
import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
7
|
+
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
|
|
8
|
+
import type { TwitchAccountConfig } from "./types.js";
|
|
9
|
+
import { isAccountConfigured } from "./utils/twitch.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default account ID for Twitch
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_ACCOUNT_ID = "default";
|
|
15
|
+
|
|
16
|
+
export type ResolvedTwitchAccountContext = {
|
|
17
|
+
accountId: string;
|
|
18
|
+
account: TwitchAccountConfig | null;
|
|
19
|
+
tokenResolution: TwitchTokenResolution;
|
|
20
|
+
configured: boolean;
|
|
21
|
+
availableAccountIds: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get account config from core config
|
|
26
|
+
*
|
|
27
|
+
* Handles two patterns:
|
|
28
|
+
* 1. Simplified single-account: base-level properties create implicit "default" account
|
|
29
|
+
* 2. Multi-account: explicit accounts object
|
|
30
|
+
*
|
|
31
|
+
* For "default" account, base-level properties take precedence over accounts.default
|
|
32
|
+
* For other accounts, only the accounts object is checked
|
|
33
|
+
*/
|
|
34
|
+
export function getAccountConfig(
|
|
35
|
+
coreConfig: unknown,
|
|
36
|
+
accountId: string,
|
|
37
|
+
): TwitchAccountConfig | null {
|
|
38
|
+
if (!coreConfig || typeof coreConfig !== "object") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cfg = coreConfig as AutoBotConfig;
|
|
43
|
+
const normalizedAccountId = normalizeAccountId(accountId);
|
|
44
|
+
const twitch = cfg.channels?.twitch;
|
|
45
|
+
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
|
46
|
+
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
|
47
|
+
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
|
|
48
|
+
|
|
49
|
+
// For default account, check base-level config first
|
|
50
|
+
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
51
|
+
const accountFromAccounts = resolveNormalizedAccountEntry(
|
|
52
|
+
accounts,
|
|
53
|
+
DEFAULT_ACCOUNT_ID,
|
|
54
|
+
normalizeAccountId,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Base-level properties that can form an implicit default account
|
|
58
|
+
const baseLevel = {
|
|
59
|
+
username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
|
|
60
|
+
accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
|
|
61
|
+
clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
|
|
62
|
+
channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
|
|
63
|
+
enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
|
|
64
|
+
allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
|
|
65
|
+
allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
|
|
66
|
+
requireMention:
|
|
67
|
+
typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
|
|
68
|
+
clientSecret:
|
|
69
|
+
typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
|
|
70
|
+
refreshToken:
|
|
71
|
+
typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
|
|
72
|
+
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
|
|
73
|
+
obtainmentTimestamp:
|
|
74
|
+
typeof twitchRaw?.obtainmentTimestamp === "number"
|
|
75
|
+
? twitchRaw.obtainmentTimestamp
|
|
76
|
+
: undefined,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Merge: base-level takes precedence over accounts.default
|
|
80
|
+
const merged: Partial<TwitchAccountConfig> = {
|
|
81
|
+
...accountFromAccounts,
|
|
82
|
+
...baseLevel,
|
|
83
|
+
} as Partial<TwitchAccountConfig>;
|
|
84
|
+
|
|
85
|
+
// Only return if we have at least username
|
|
86
|
+
if (merged.username) {
|
|
87
|
+
return merged as TwitchAccountConfig;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Fall through to accounts.default if no base-level username
|
|
91
|
+
if (accountFromAccounts) {
|
|
92
|
+
return accountFromAccounts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// For non-default accounts, only check accounts object
|
|
99
|
+
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
|
|
100
|
+
if (!account) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return account;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* List all configured account IDs
|
|
109
|
+
*
|
|
110
|
+
* Includes both explicit accounts and implicit "default" from base-level config
|
|
111
|
+
*/
|
|
112
|
+
export function listAccountIds(cfg: AutoBotConfig): string[] {
|
|
113
|
+
const twitch = cfg.channels?.twitch;
|
|
114
|
+
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
|
115
|
+
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
|
116
|
+
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
|
|
117
|
+
|
|
118
|
+
// Add implicit "default" if base-level config exists and "default" not already present
|
|
119
|
+
const hasBaseLevelConfig =
|
|
120
|
+
twitchRaw &&
|
|
121
|
+
(typeof twitchRaw.username === "string" ||
|
|
122
|
+
typeof twitchRaw.accessToken === "string" ||
|
|
123
|
+
typeof twitchRaw.channel === "string");
|
|
124
|
+
|
|
125
|
+
return listCombinedAccountIds({
|
|
126
|
+
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
|
|
127
|
+
normalizeAccountId(accountId),
|
|
128
|
+
),
|
|
129
|
+
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolveDefaultTwitchAccountId(cfg: AutoBotConfig): string {
|
|
134
|
+
const preferredRaw =
|
|
135
|
+
typeof cfg.channels?.twitch?.defaultAccount === "string"
|
|
136
|
+
? cfg.channels.twitch.defaultAccount.trim()
|
|
137
|
+
: "";
|
|
138
|
+
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
|
|
139
|
+
const ids = listAccountIds(cfg);
|
|
140
|
+
if (preferred && ids.includes(preferred)) {
|
|
141
|
+
return preferred;
|
|
142
|
+
}
|
|
143
|
+
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
144
|
+
return DEFAULT_ACCOUNT_ID;
|
|
145
|
+
}
|
|
146
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveTwitchAccountContext(
|
|
150
|
+
cfg: AutoBotConfig,
|
|
151
|
+
accountId?: string | null,
|
|
152
|
+
): ResolvedTwitchAccountContext {
|
|
153
|
+
const resolvedAccountId = accountId?.trim()
|
|
154
|
+
? normalizeAccountId(accountId)
|
|
155
|
+
: resolveDefaultTwitchAccountId(cfg);
|
|
156
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
157
|
+
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
|
158
|
+
return {
|
|
159
|
+
accountId: resolvedAccountId,
|
|
160
|
+
account,
|
|
161
|
+
tokenResolution,
|
|
162
|
+
configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
|
|
163
|
+
availableAccountIds: listAccountIds(cfg),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveTwitchSnapshotAccountId(
|
|
168
|
+
cfg: AutoBotConfig,
|
|
169
|
+
account: TwitchAccountConfig,
|
|
170
|
+
): string {
|
|
171
|
+
const twitch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
|
|
172
|
+
const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
|
|
173
|
+
const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
|
|
174
|
+
return (
|
|
175
|
+
Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? DEFAULT_ACCOUNT_ID
|
|
176
|
+
);
|
|
177
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch message monitor - processes incoming messages and routes to agents.
|
|
3
|
+
*
|
|
4
|
+
* This monitor connects to the Twitch client manager, processes incoming messages,
|
|
5
|
+
* resolves agent routes, and handles replies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MarkdownTableMode, AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
9
|
+
import { formatErrorMessage } from "autobot/plugin-sdk/error-runtime";
|
|
10
|
+
import type { ReplyPayload } from "autobot/plugin-sdk/reply-runtime";
|
|
11
|
+
import { normalizeLowercaseStringOrEmpty } from "autobot/plugin-sdk/string-coerce-runtime";
|
|
12
|
+
import { checkTwitchAccessControl } from "./access-control.js";
|
|
13
|
+
import { getOrCreateClientManager } from "./client-manager-registry.js";
|
|
14
|
+
import { getTwitchRuntime } from "./runtime.js";
|
|
15
|
+
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
16
|
+
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
|
17
|
+
|
|
18
|
+
export type TwitchRuntimeEnv = {
|
|
19
|
+
log?: (message: string) => void;
|
|
20
|
+
error?: (message: string) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TwitchMonitorOptions = {
|
|
24
|
+
account: TwitchAccountConfig;
|
|
25
|
+
accountId: string;
|
|
26
|
+
config: unknown; // AutoBotConfig
|
|
27
|
+
runtime: TwitchRuntimeEnv;
|
|
28
|
+
abortSignal: AbortSignal;
|
|
29
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TwitchMonitorResult = {
|
|
33
|
+
stop: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type TwitchCoreRuntime = ReturnType<typeof getTwitchRuntime>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Process an incoming Twitch message and dispatch to agent.
|
|
40
|
+
*/
|
|
41
|
+
async function processTwitchMessage(params: {
|
|
42
|
+
message: TwitchChatMessage;
|
|
43
|
+
account: TwitchAccountConfig;
|
|
44
|
+
accountId: string;
|
|
45
|
+
config: unknown;
|
|
46
|
+
runtime: TwitchRuntimeEnv;
|
|
47
|
+
core: TwitchCoreRuntime;
|
|
48
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
49
|
+
}): Promise<void> {
|
|
50
|
+
const { message, account, accountId, config, runtime, core, statusSink } = params;
|
|
51
|
+
const cfg = config as AutoBotConfig;
|
|
52
|
+
|
|
53
|
+
await core.channel.turn.run({
|
|
54
|
+
channel: "twitch",
|
|
55
|
+
accountId,
|
|
56
|
+
raw: message,
|
|
57
|
+
adapter: {
|
|
58
|
+
ingest: (incoming) => ({
|
|
59
|
+
id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
|
|
60
|
+
timestamp: incoming.timestamp?.getTime(),
|
|
61
|
+
rawText: incoming.message,
|
|
62
|
+
textForAgent: incoming.message,
|
|
63
|
+
textForCommands: incoming.message,
|
|
64
|
+
raw: incoming,
|
|
65
|
+
}),
|
|
66
|
+
resolveTurn: (input) => {
|
|
67
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
68
|
+
cfg,
|
|
69
|
+
channel: "twitch",
|
|
70
|
+
accountId,
|
|
71
|
+
peer: {
|
|
72
|
+
kind: "group",
|
|
73
|
+
id: message.channel,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const senderId = message.userId ?? message.username;
|
|
77
|
+
const fromLabel = message.displayName ?? message.username;
|
|
78
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
79
|
+
channel: "Twitch",
|
|
80
|
+
from: fromLabel,
|
|
81
|
+
timestamp: input.timestamp,
|
|
82
|
+
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
|
83
|
+
body: input.rawText,
|
|
84
|
+
});
|
|
85
|
+
const ctxPayload = core.channel.turn.buildContext({
|
|
86
|
+
channel: "twitch",
|
|
87
|
+
accountId,
|
|
88
|
+
messageId: input.id,
|
|
89
|
+
timestamp: input.timestamp,
|
|
90
|
+
from: `twitch:user:${senderId}`,
|
|
91
|
+
sender: {
|
|
92
|
+
id: senderId,
|
|
93
|
+
name: fromLabel,
|
|
94
|
+
username: message.username,
|
|
95
|
+
},
|
|
96
|
+
conversation: {
|
|
97
|
+
kind: "group",
|
|
98
|
+
id: message.channel,
|
|
99
|
+
label: message.channel,
|
|
100
|
+
routePeer: {
|
|
101
|
+
kind: "group",
|
|
102
|
+
id: message.channel,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
route: {
|
|
106
|
+
agentId: route.agentId,
|
|
107
|
+
accountId: route.accountId,
|
|
108
|
+
routeSessionKey: route.sessionKey,
|
|
109
|
+
},
|
|
110
|
+
reply: {
|
|
111
|
+
to: `twitch:channel:${message.channel}`,
|
|
112
|
+
originatingTo: `twitch:channel:${message.channel}`,
|
|
113
|
+
},
|
|
114
|
+
message: {
|
|
115
|
+
body,
|
|
116
|
+
rawBody: input.rawText,
|
|
117
|
+
bodyForAgent: input.textForAgent,
|
|
118
|
+
commandBody: input.textForCommands,
|
|
119
|
+
envelopeFrom: fromLabel,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
123
|
+
agentId: route.agentId,
|
|
124
|
+
});
|
|
125
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
126
|
+
cfg,
|
|
127
|
+
channel: "twitch",
|
|
128
|
+
accountId,
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
cfg,
|
|
132
|
+
channel: "twitch",
|
|
133
|
+
accountId,
|
|
134
|
+
agentId: route.agentId,
|
|
135
|
+
routeSessionKey: route.sessionKey,
|
|
136
|
+
storePath,
|
|
137
|
+
ctxPayload,
|
|
138
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
139
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
140
|
+
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
141
|
+
delivery: {
|
|
142
|
+
durable: () => ({
|
|
143
|
+
to: `twitch:channel:${message.channel}`,
|
|
144
|
+
}),
|
|
145
|
+
deliver: async (payload) => {
|
|
146
|
+
return await deliverTwitchReply({
|
|
147
|
+
payload,
|
|
148
|
+
channel: message.channel,
|
|
149
|
+
account,
|
|
150
|
+
accountId,
|
|
151
|
+
config,
|
|
152
|
+
tableMode,
|
|
153
|
+
runtime,
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
onDelivered: (_payload, _info, result) => {
|
|
157
|
+
if (result?.visibleReplySent !== false) {
|
|
158
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
onError: (err, info) => {
|
|
162
|
+
runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
replyPipeline: {},
|
|
166
|
+
record: {
|
|
167
|
+
onRecordError: (err) => {
|
|
168
|
+
runtime.error?.(`Failed updating session meta: ${String(err)}`);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Deliver a reply to Twitch chat.
|
|
179
|
+
*/
|
|
180
|
+
async function deliverTwitchReply(params: {
|
|
181
|
+
payload: ReplyPayload;
|
|
182
|
+
channel: string;
|
|
183
|
+
account: TwitchAccountConfig;
|
|
184
|
+
accountId: string;
|
|
185
|
+
config: unknown;
|
|
186
|
+
tableMode: MarkdownTableMode;
|
|
187
|
+
runtime: TwitchRuntimeEnv;
|
|
188
|
+
}): Promise<{ visibleReplySent: boolean }> {
|
|
189
|
+
const { payload, channel, account, accountId, config, runtime } = params;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const clientManager = getOrCreateClientManager(accountId, {
|
|
193
|
+
info: (msg) => runtime.log?.(msg),
|
|
194
|
+
warn: (msg) => runtime.log?.(msg),
|
|
195
|
+
error: (msg) => runtime.error?.(msg),
|
|
196
|
+
debug: (msg) => runtime.log?.(msg),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const client = await clientManager.getClient(
|
|
200
|
+
account,
|
|
201
|
+
config as Parameters<typeof clientManager.getClient>[1],
|
|
202
|
+
accountId,
|
|
203
|
+
);
|
|
204
|
+
if (!client) {
|
|
205
|
+
runtime.error?.(`No client available for sending reply`);
|
|
206
|
+
return { visibleReplySent: false };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Send the reply
|
|
210
|
+
if (!payload.text) {
|
|
211
|
+
runtime.error?.(`No text to send in reply payload`);
|
|
212
|
+
return { visibleReplySent: false };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const textToSend = stripMarkdownForTwitch(payload.text);
|
|
216
|
+
|
|
217
|
+
await client.say(channel, textToSend);
|
|
218
|
+
return { visibleReplySent: true };
|
|
219
|
+
} catch (err) {
|
|
220
|
+
runtime.error?.(`Failed to send reply: ${String(err)}`);
|
|
221
|
+
return { visibleReplySent: false };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Main monitor provider for Twitch.
|
|
227
|
+
*
|
|
228
|
+
* Sets up message handlers and processes incoming messages.
|
|
229
|
+
*/
|
|
230
|
+
export async function monitorTwitchProvider(
|
|
231
|
+
options: TwitchMonitorOptions,
|
|
232
|
+
): Promise<TwitchMonitorResult> {
|
|
233
|
+
const { account, accountId, config, runtime, abortSignal, statusSink } = options;
|
|
234
|
+
|
|
235
|
+
const core = getTwitchRuntime();
|
|
236
|
+
let stopped = false;
|
|
237
|
+
|
|
238
|
+
const coreLogger = core.logging.getChildLogger({ module: "twitch" });
|
|
239
|
+
const logVerboseMessage = (message: string) => {
|
|
240
|
+
if (!core.logging.shouldLogVerbose()) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
coreLogger.debug?.(message);
|
|
244
|
+
};
|
|
245
|
+
const logger = {
|
|
246
|
+
info: (msg: string) => coreLogger.info(msg),
|
|
247
|
+
warn: (msg: string) => coreLogger.warn(msg),
|
|
248
|
+
error: (msg: string) => coreLogger.error(msg),
|
|
249
|
+
debug: logVerboseMessage,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const clientManager = getOrCreateClientManager(accountId, logger);
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
await clientManager.getClient(
|
|
256
|
+
account,
|
|
257
|
+
config as Parameters<typeof clientManager.getClient>[1],
|
|
258
|
+
accountId,
|
|
259
|
+
);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const errorMsg = formatErrorMessage(error);
|
|
262
|
+
runtime.error?.(`Failed to connect: ${errorMsg}`);
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const unregisterHandler = clientManager.onMessage(account, (message) => {
|
|
267
|
+
if (stopped) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
void (async () => {
|
|
272
|
+
const botUsername = normalizeLowercaseStringOrEmpty(account.username);
|
|
273
|
+
if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const access = await checkTwitchAccessControl({
|
|
278
|
+
message,
|
|
279
|
+
account,
|
|
280
|
+
botUsername,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (stopped || !access.allowed) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
288
|
+
|
|
289
|
+
await processTwitchMessage({
|
|
290
|
+
message,
|
|
291
|
+
account,
|
|
292
|
+
accountId,
|
|
293
|
+
config,
|
|
294
|
+
runtime,
|
|
295
|
+
core,
|
|
296
|
+
statusSink,
|
|
297
|
+
});
|
|
298
|
+
})().catch((err) => {
|
|
299
|
+
runtime.error?.(`Message processing failed: ${String(err)}`);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const stop = () => {
|
|
304
|
+
stopped = true;
|
|
305
|
+
unregisterHandler();
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
abortSignal.addEventListener("abort", stop, { once: true });
|
|
309
|
+
|
|
310
|
+
return { stop };
|
|
311
|
+
}
|