@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.
Files changed (62) hide show
  1. package/README.md +89 -0
  2. package/api.ts +21 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/dist/api.js +3 -0
  5. package/dist/channel-plugin-api.js +2 -0
  6. package/dist/index.js +18 -0
  7. package/dist/monitor-j1GtQVBd.js +337 -0
  8. package/dist/plugin-BMzrFFQR.js +1285 -0
  9. package/dist/runtime-CwXHrWo3.js +8 -0
  10. package/dist/runtime-api.js +1 -0
  11. package/dist/setup-entry.js +11 -0
  12. package/dist/setup-plugin-api.js +2 -0
  13. package/dist/setup-surface-CovnRl9R.js +527 -0
  14. package/index.test.ts +13 -0
  15. package/index.ts +16 -0
  16. package/klaw.plugin.json +2 -219
  17. package/package.json +3 -3
  18. package/runtime-api.ts +22 -0
  19. package/setup-entry.ts +9 -0
  20. package/setup-plugin-api.ts +3 -0
  21. package/src/access-control.test.ts +373 -0
  22. package/src/access-control.ts +195 -0
  23. package/src/actions.test.ts +75 -0
  24. package/src/actions.ts +175 -0
  25. package/src/client-manager-registry.ts +87 -0
  26. package/src/config-schema.test.ts +46 -0
  27. package/src/config-schema.ts +88 -0
  28. package/src/config.test.ts +233 -0
  29. package/src/config.ts +177 -0
  30. package/src/monitor.ts +311 -0
  31. package/src/outbound.test.ts +572 -0
  32. package/src/outbound.ts +242 -0
  33. package/src/plugin.lifecycle.test.ts +86 -0
  34. package/src/plugin.live.test.ts +120 -0
  35. package/src/plugin.test.ts +77 -0
  36. package/src/plugin.ts +220 -0
  37. package/src/probe.test.ts +196 -0
  38. package/src/probe.ts +130 -0
  39. package/src/resolver.ts +139 -0
  40. package/src/runtime.ts +9 -0
  41. package/src/send.test.ts +342 -0
  42. package/src/send.ts +191 -0
  43. package/src/setup-surface.test.ts +529 -0
  44. package/src/setup-surface.ts +526 -0
  45. package/src/status.test.ts +298 -0
  46. package/src/status.ts +179 -0
  47. package/src/test-fixtures.ts +30 -0
  48. package/src/token.test.ts +198 -0
  49. package/src/token.ts +93 -0
  50. package/src/twitch-client.test.ts +574 -0
  51. package/src/twitch-client.ts +276 -0
  52. package/src/types.ts +104 -0
  53. package/src/utils/markdown.ts +98 -0
  54. package/src/utils/twitch.ts +81 -0
  55. package/test/setup.ts +7 -0
  56. package/tsconfig.json +16 -0
  57. package/api.js +0 -7
  58. package/channel-plugin-api.js +0 -7
  59. package/index.js +0 -7
  60. package/runtime-api.js +0 -7
  61. package/setup-entry.js +0 -7
  62. package/setup-plugin-api.js +0 -7
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, KlawConfig } from "klaw/plugin-sdk/config-contracts";
9
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
10
+ import type { ReplyPayload } from "klaw/plugin-sdk/reply-runtime";
11
+ import { normalizeLowercaseStringOrEmpty } from "klaw/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; // KlawConfig
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 KlawConfig;
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
+ }