@kernel.chat/kbot 3.87.0 → 3.93.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,789 @@
1
+ // kbot Stream Control Tools — Manage Twitch, Kick, and Rumble dashboards
2
+ //
3
+ // Tools: stream_title, stream_category, stream_info, stream_viewers,
4
+ // stream_chat_settings, stream_clip, stream_marker, stream_followers,
5
+ // stream_chat_send, stream_ban, stream_announce, stream_dashboard,
6
+ // stream_setup_oauth
7
+ //
8
+ // These tools manage the streaming platform *dashboards* — titles, categories,
9
+ // chat moderation, clips, markers, followers — not the video feed itself
10
+ // (that's in streaming.ts).
11
+ //
12
+ // Env: TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, TWITCH_BROADCASTER_ID
13
+ // RUMBLE_API_KEY
14
+ // KICK_CHANNEL_SLUG (for browser-based fallback)
15
+ import { registerTool } from './index.js';
16
+ // ─── Constants ─────────────────────────────────────────────────
17
+ const TWITCH_API = 'https://api.twitch.tv/helix';
18
+ const KICK_API = 'https://kick.com/api/v2';
19
+ const RUMBLE_API = 'https://rumble.com/-livestream-api/get-data';
20
+ // ─── Twitch Helpers ────────────────────────────────────────────
21
+ function twitchHeaders() {
22
+ const token = process.env.TWITCH_OAUTH_TOKEN;
23
+ const clientId = process.env.TWITCH_CLIENT_ID;
24
+ if (!token || !clientId) {
25
+ throw new Error('Twitch OAuth not configured. Set TWITCH_OAUTH_TOKEN and TWITCH_CLIENT_ID.\n' +
26
+ 'Run stream_setup_oauth for instructions.');
27
+ }
28
+ return {
29
+ 'Authorization': `Bearer ${token}`,
30
+ 'Client-Id': clientId,
31
+ 'Content-Type': 'application/json',
32
+ };
33
+ }
34
+ function getBroadcasterId() {
35
+ const id = process.env.TWITCH_BROADCASTER_ID;
36
+ if (!id) {
37
+ throw new Error('TWITCH_BROADCASTER_ID not set. Find yours at: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/');
38
+ }
39
+ return id;
40
+ }
41
+ async function twitchGet(endpoint) {
42
+ const res = await fetch(`${TWITCH_API}${endpoint}`, { headers: twitchHeaders() });
43
+ if (!res.ok) {
44
+ const body = await res.text();
45
+ throw new Error(`Twitch API ${res.status}: ${body}`);
46
+ }
47
+ return res.json();
48
+ }
49
+ async function twitchPatch(endpoint, body) {
50
+ const res = await fetch(`${TWITCH_API}${endpoint}`, {
51
+ method: 'PATCH',
52
+ headers: twitchHeaders(),
53
+ body: JSON.stringify(body),
54
+ });
55
+ if (!res.ok) {
56
+ const text = await res.text();
57
+ throw new Error(`Twitch API ${res.status}: ${text}`);
58
+ }
59
+ // 204 No Content is a success response for PATCH
60
+ if (res.status === 204)
61
+ return { ok: true };
62
+ const ct = res.headers.get('content-type') || '';
63
+ if (ct.includes('application/json'))
64
+ return res.json();
65
+ return { ok: true };
66
+ }
67
+ async function twitchPost(endpoint, body) {
68
+ const res = await fetch(`${TWITCH_API}${endpoint}`, {
69
+ method: 'POST',
70
+ headers: twitchHeaders(),
71
+ body: JSON.stringify(body),
72
+ });
73
+ if (!res.ok) {
74
+ const text = await res.text();
75
+ throw new Error(`Twitch API ${res.status}: ${text}`);
76
+ }
77
+ if (res.status === 204)
78
+ return { ok: true };
79
+ return res.json();
80
+ }
81
+ // ─── Rumble Helpers ────────────────────────────────────────────
82
+ async function rumbleGetData() {
83
+ const key = process.env.RUMBLE_API_KEY;
84
+ if (!key)
85
+ throw new Error('RUMBLE_API_KEY not set.');
86
+ const res = await fetch(`${RUMBLE_API}?key=${encodeURIComponent(key)}`);
87
+ if (!res.ok)
88
+ throw new Error(`Rumble API ${res.status}: ${await res.text()}`);
89
+ return res.json();
90
+ }
91
+ // ─── Kick Helpers ──────────────────────────────────────────────
92
+ async function kickGetChannel() {
93
+ const slug = process.env.KICK_CHANNEL_SLUG;
94
+ if (!slug)
95
+ throw new Error('KICK_CHANNEL_SLUG not set.');
96
+ const res = await fetch(`${KICK_API}/channels/${encodeURIComponent(slug)}`, {
97
+ headers: { 'Accept': 'application/json' },
98
+ });
99
+ if (!res.ok)
100
+ throw new Error(`Kick API ${res.status}: ${await res.text()}`);
101
+ return res.json();
102
+ }
103
+ // ─── Utility ───────────────────────────────────────────────────
104
+ function hasTwitch() {
105
+ return !!(process.env.TWITCH_OAUTH_TOKEN && process.env.TWITCH_CLIENT_ID);
106
+ }
107
+ function hasRumble() {
108
+ return !!process.env.RUMBLE_API_KEY;
109
+ }
110
+ function hasKick() {
111
+ return !!process.env.KICK_CHANNEL_SLUG;
112
+ }
113
+ function formatUptime(startedAt) {
114
+ const start = new Date(startedAt);
115
+ const now = new Date();
116
+ const diff = now.getTime() - start.getTime();
117
+ const hours = Math.floor(diff / 3_600_000);
118
+ const minutes = Math.floor((diff % 3_600_000) / 60_000);
119
+ return `${hours}h ${minutes}m`;
120
+ }
121
+ function noPlatforms() {
122
+ return ('No streaming platforms configured. Set at least one:\n' +
123
+ ' TWITCH_CLIENT_ID + TWITCH_OAUTH_TOKEN + TWITCH_BROADCASTER_ID (Twitch)\n' +
124
+ ' RUMBLE_API_KEY (Rumble)\n' +
125
+ ' KICK_CHANNEL_SLUG (Kick)\n\n' +
126
+ 'Run stream_setup_oauth for detailed instructions.');
127
+ }
128
+ // ─── Registration ──────────────────────────────────────────────
129
+ export function registerStreamControlTools() {
130
+ // ── stream_title ──
131
+ registerTool({
132
+ name: 'stream_title',
133
+ description: 'Update the stream title on Twitch. Optionally update the category/game at the same time. Kick dashboard changes require browser (noted in output).',
134
+ parameters: {
135
+ title: { type: 'string', description: 'New stream title (max 140 chars for Twitch)', required: true },
136
+ category: { type: 'string', description: 'Optional: category/game name to set alongside the title' },
137
+ },
138
+ tier: 'free',
139
+ execute: async (args) => {
140
+ const title = String(args.title || '').slice(0, 140);
141
+ if (!title)
142
+ return 'Error: title is required.';
143
+ const results = [];
144
+ // Twitch
145
+ if (hasTwitch()) {
146
+ try {
147
+ const broadcasterId = getBroadcasterId();
148
+ const body = { title };
149
+ // If category provided, search for game_id first
150
+ if (args.category) {
151
+ const search = await twitchGet(`/search/categories?query=${encodeURIComponent(String(args.category))}&first=1`);
152
+ if (search.data?.length > 0) {
153
+ body.game_id = search.data[0].id;
154
+ results.push(`Twitch: title set to "${title}", category set to "${search.data[0].name}" (id: ${search.data[0].id})`);
155
+ }
156
+ else {
157
+ results.push(`Twitch: title set to "${title}" (category "${args.category}" not found, skipped)`);
158
+ }
159
+ }
160
+ else {
161
+ results.push(`Twitch: title set to "${title}"`);
162
+ }
163
+ await twitchPatch(`/channels?broadcaster_id=${broadcasterId}`, body);
164
+ }
165
+ catch (e) {
166
+ results.push(`Twitch: ${e.message}`);
167
+ }
168
+ }
169
+ // Kick — no direct API for title changes
170
+ if (hasKick()) {
171
+ results.push('Kick: title changes require the dashboard (dashboard.kick.com/Stream). Use computer-use tools or kbot_browse to navigate there.');
172
+ }
173
+ // Rumble — read-only API
174
+ if (hasRumble()) {
175
+ results.push('Rumble: title changes not available via API. Use the Rumble Studio dashboard.');
176
+ }
177
+ if (results.length === 0)
178
+ return noPlatforms();
179
+ return results.join('\n');
180
+ },
181
+ });
182
+ // ── stream_category ──
183
+ registerTool({
184
+ name: 'stream_category',
185
+ description: 'Change the stream category/game on Twitch. Searches Twitch categories by name and applies the best match.',
186
+ parameters: {
187
+ category: { type: 'string', description: 'Category/game name to search for and set', required: true },
188
+ },
189
+ tier: 'free',
190
+ execute: async (args) => {
191
+ const query = String(args.category || '');
192
+ if (!query)
193
+ return 'Error: category is required.';
194
+ if (!hasTwitch())
195
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
196
+ try {
197
+ const broadcasterId = getBroadcasterId();
198
+ const search = await twitchGet(`/search/categories?query=${encodeURIComponent(query)}&first=5`);
199
+ if (!search.data?.length) {
200
+ return `No categories found for "${query}". Try a different search term.`;
201
+ }
202
+ // Use the first match
203
+ const cat = search.data[0];
204
+ await twitchPatch(`/channels?broadcaster_id=${broadcasterId}`, { game_id: cat.id });
205
+ const alternatives = search.data.slice(1, 5).map((c) => ` - ${c.name} (id: ${c.id})`).join('\n');
206
+ let result = `Category set to: ${cat.name} (id: ${cat.id})`;
207
+ if (alternatives)
208
+ result += `\n\nOther matches:\n${alternatives}`;
209
+ return result;
210
+ }
211
+ catch (e) {
212
+ return `Error: ${e.message}`;
213
+ }
214
+ },
215
+ });
216
+ // ── stream_info ──
217
+ registerTool({
218
+ name: 'stream_info',
219
+ description: 'Get current stream info from Twitch (viewers, uptime, title, category) and Rumble (viewers, status). Shows whether you are live on each platform.',
220
+ parameters: {
221
+ platform: { type: 'string', description: 'Specific platform: "twitch", "kick", "rumble". Default: all configured' },
222
+ },
223
+ tier: 'free',
224
+ execute: async (args) => {
225
+ const platform = args.platform ? String(args.platform).toLowerCase() : 'all';
226
+ const results = [];
227
+ // Twitch
228
+ if ((platform === 'all' || platform === 'twitch') && hasTwitch()) {
229
+ try {
230
+ const broadcasterId = getBroadcasterId();
231
+ const streams = await twitchGet(`/streams?user_id=${broadcasterId}`);
232
+ const channelInfo = await twitchGet(`/channels?broadcaster_id=${broadcasterId}`);
233
+ const channel = channelInfo.data?.[0];
234
+ if (streams.data?.length > 0) {
235
+ const s = streams.data[0];
236
+ results.push(`TWITCH [LIVE]\n` +
237
+ ` Title: ${s.title}\n` +
238
+ ` Category: ${s.game_name}\n` +
239
+ ` Viewers: ${s.viewer_count.toLocaleString()}\n` +
240
+ ` Uptime: ${formatUptime(s.started_at)}\n` +
241
+ ` Language: ${s.language}`);
242
+ }
243
+ else {
244
+ results.push(`TWITCH [OFFLINE]\n` +
245
+ ` Title: ${channel?.title || 'N/A'}\n` +
246
+ ` Category: ${channel?.game_name || 'N/A'}`);
247
+ }
248
+ }
249
+ catch (e) {
250
+ results.push(`TWITCH: Error — ${e.message}`);
251
+ }
252
+ }
253
+ // Rumble
254
+ if ((platform === 'all' || platform === 'rumble') && hasRumble()) {
255
+ try {
256
+ const data = await rumbleGetData();
257
+ if (data.is_live) {
258
+ results.push(`RUMBLE [LIVE]\n` +
259
+ ` Viewers: ${(data.viewers || 0).toLocaleString()}\n` +
260
+ ` Chat count: ${(data.chat_messages || 0).toLocaleString()}`);
261
+ }
262
+ else {
263
+ results.push('RUMBLE [OFFLINE]');
264
+ }
265
+ }
266
+ catch (e) {
267
+ results.push(`RUMBLE: Error — ${e.message}`);
268
+ }
269
+ }
270
+ // Kick
271
+ if ((platform === 'all' || platform === 'kick') && hasKick()) {
272
+ try {
273
+ const data = await kickGetChannel();
274
+ const livestream = data.livestream;
275
+ if (livestream && livestream.is_live) {
276
+ results.push(`KICK [LIVE]\n` +
277
+ ` Title: ${livestream.session_title || 'N/A'}\n` +
278
+ ` Category: ${livestream.categories?.[0]?.name || 'N/A'}\n` +
279
+ ` Viewers: ${(livestream.viewer_count || 0).toLocaleString()}`);
280
+ }
281
+ else {
282
+ results.push(`KICK [OFFLINE]\n` +
283
+ ` Channel: ${data.slug || process.env.KICK_CHANNEL_SLUG}`);
284
+ }
285
+ }
286
+ catch (e) {
287
+ results.push(`KICK: Error — ${e.message}`);
288
+ }
289
+ }
290
+ if (results.length === 0)
291
+ return noPlatforms();
292
+ return results.join('\n\n');
293
+ },
294
+ });
295
+ // ── stream_viewers ──
296
+ registerTool({
297
+ name: 'stream_viewers',
298
+ description: 'Get viewer count across all configured streaming platforms (Twitch, Kick, Rumble). Returns per-platform counts and a combined total.',
299
+ parameters: {},
300
+ tier: 'free',
301
+ execute: async () => {
302
+ const counts = [];
303
+ // Twitch
304
+ if (hasTwitch()) {
305
+ try {
306
+ const broadcasterId = getBroadcasterId();
307
+ const streams = await twitchGet(`/streams?user_id=${broadcasterId}`);
308
+ if (streams.data?.length > 0) {
309
+ counts.push({ platform: 'Twitch', viewers: streams.data[0].viewer_count, live: true });
310
+ }
311
+ else {
312
+ counts.push({ platform: 'Twitch', viewers: 0, live: false });
313
+ }
314
+ }
315
+ catch (e) {
316
+ counts.push({ platform: 'Twitch', viewers: 0, live: false });
317
+ }
318
+ }
319
+ // Rumble
320
+ if (hasRumble()) {
321
+ try {
322
+ const data = await rumbleGetData();
323
+ counts.push({ platform: 'Rumble', viewers: data.viewers || 0, live: !!data.is_live });
324
+ }
325
+ catch {
326
+ counts.push({ platform: 'Rumble', viewers: 0, live: false });
327
+ }
328
+ }
329
+ // Kick
330
+ if (hasKick()) {
331
+ try {
332
+ const data = await kickGetChannel();
333
+ const live = data.livestream?.is_live || false;
334
+ counts.push({ platform: 'Kick', viewers: live ? (data.livestream?.viewer_count || 0) : 0, live });
335
+ }
336
+ catch {
337
+ counts.push({ platform: 'Kick', viewers: 0, live: false });
338
+ }
339
+ }
340
+ if (counts.length === 0)
341
+ return noPlatforms();
342
+ const total = counts.reduce((sum, c) => sum + c.viewers, 0);
343
+ const lines = counts.map(c => ` ${c.platform.padEnd(8)} ${c.live ? 'LIVE' : 'OFF '} ${c.viewers.toLocaleString().padStart(8)} viewers`);
344
+ lines.push(` ${'TOTAL'.padEnd(8)} ${total.toLocaleString().padStart(8)} viewers`);
345
+ return `Viewer counts:\n${lines.join('\n')}`;
346
+ },
347
+ });
348
+ // ── stream_chat_settings ──
349
+ registerTool({
350
+ name: 'stream_chat_settings',
351
+ description: 'Manage Twitch chat settings: slow mode, subscriber-only mode, emote-only mode, follower-only mode. Pass the settings you want to change.',
352
+ parameters: {
353
+ slow_mode: { type: 'string', description: '"on" or "off" — enable/disable slow mode' },
354
+ slow_mode_wait: { type: 'string', description: 'Seconds between messages in slow mode (3-120). Default: 10' },
355
+ sub_only: { type: 'string', description: '"on" or "off" — subscriber-only mode' },
356
+ emote_only: { type: 'string', description: '"on" or "off" — emote-only mode' },
357
+ follower_only: { type: 'string', description: '"on" or "off" — follower-only mode' },
358
+ follower_only_minutes: { type: 'string', description: 'Minutes a user must follow before chatting (0-129600). Default: 10' },
359
+ },
360
+ tier: 'free',
361
+ execute: async (args) => {
362
+ if (!hasTwitch())
363
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
364
+ try {
365
+ const broadcasterId = getBroadcasterId();
366
+ const body = {};
367
+ if (args.slow_mode !== undefined) {
368
+ body.slow_mode = args.slow_mode === 'on';
369
+ if (body.slow_mode && args.slow_mode_wait) {
370
+ body.slow_mode_wait_time = Math.max(3, Math.min(120, parseInt(String(args.slow_mode_wait), 10) || 10));
371
+ }
372
+ }
373
+ if (args.sub_only !== undefined)
374
+ body.subscriber_mode = args.sub_only === 'on';
375
+ if (args.emote_only !== undefined)
376
+ body.emote_mode = args.emote_only === 'on';
377
+ if (args.follower_only !== undefined) {
378
+ body.follower_mode = args.follower_only === 'on';
379
+ if (body.follower_mode && args.follower_only_minutes) {
380
+ body.follower_mode_duration = Math.max(0, Math.min(129600, parseInt(String(args.follower_only_minutes), 10) || 10));
381
+ }
382
+ }
383
+ if (Object.keys(body).length === 0) {
384
+ return 'No settings provided. Available: slow_mode, sub_only, emote_only, follower_only';
385
+ }
386
+ // moderator_id = broadcaster_id when the broadcaster is the moderator
387
+ await twitchPatch(`/chat/settings?broadcaster_id=${broadcasterId}&moderator_id=${broadcasterId}`, body);
388
+ const applied = Object.entries(body)
389
+ .map(([k, v]) => ` ${k}: ${v}`)
390
+ .join('\n');
391
+ return `Chat settings updated:\n${applied}`;
392
+ }
393
+ catch (e) {
394
+ return `Error: ${e.message}`;
395
+ }
396
+ },
397
+ });
398
+ // ── stream_clip ──
399
+ registerTool({
400
+ name: 'stream_clip',
401
+ description: 'Create a clip of the current Twitch stream. The stream must be live. Returns the clip edit URL.',
402
+ parameters: {
403
+ has_delay: { type: 'string', description: '"true" if the stream has a delay (captures from the delay buffer). Default: false' },
404
+ },
405
+ tier: 'free',
406
+ execute: async (args) => {
407
+ if (!hasTwitch())
408
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
409
+ try {
410
+ const broadcasterId = getBroadcasterId();
411
+ // Verify stream is live
412
+ const streams = await twitchGet(`/streams?user_id=${broadcasterId}`);
413
+ if (!streams.data?.length) {
414
+ return 'Cannot create clip — stream is not live.';
415
+ }
416
+ const body = { broadcaster_id: broadcasterId };
417
+ if (args.has_delay === 'true')
418
+ body.has_delay = true;
419
+ const result = await twitchPost('/clips', body);
420
+ const clip = result.data?.[0];
421
+ if (clip) {
422
+ return `Clip created!\n Edit URL: ${clip.edit_url}\n ID: ${clip.id}\n\nNote: It takes ~15 seconds for the clip to be processed.`;
423
+ }
424
+ return 'Clip creation returned no data. The stream may not be clippable.';
425
+ }
426
+ catch (e) {
427
+ return `Error: ${e.message}`;
428
+ }
429
+ },
430
+ });
431
+ // ── stream_marker ──
432
+ registerTool({
433
+ name: 'stream_marker',
434
+ description: 'Add a stream marker (bookmark) on the current Twitch stream. Useful for marking interesting moments to highlight later. Stream must be live.',
435
+ parameters: {
436
+ description: { type: 'string', description: 'Description of the marker (max 140 chars). Default: "Marked by kbot"' },
437
+ },
438
+ tier: 'free',
439
+ execute: async (args) => {
440
+ if (!hasTwitch())
441
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
442
+ try {
443
+ const broadcasterId = getBroadcasterId();
444
+ const description = String(args.description || 'Marked by kbot').slice(0, 140);
445
+ const result = await twitchPost('/streams/markers', {
446
+ user_id: broadcasterId,
447
+ description,
448
+ });
449
+ const marker = result.data?.[0];
450
+ if (marker) {
451
+ return `Stream marker added at ${marker.position_seconds}s: "${description}"\n ID: ${marker.id}\n Created: ${marker.created_at}`;
452
+ }
453
+ return 'Marker created (no position data returned). Stream must be live for markers to work.';
454
+ }
455
+ catch (e) {
456
+ if (e.message.includes('404'))
457
+ return 'Cannot add marker — stream is not live.';
458
+ return `Error: ${e.message}`;
459
+ }
460
+ },
461
+ });
462
+ // ── stream_followers ──
463
+ registerTool({
464
+ name: 'stream_followers',
465
+ description: 'Get follower count and recent followers from Twitch. Shows total count and the latest followers with follow dates.',
466
+ parameters: {
467
+ count: { type: 'string', description: 'Number of recent followers to show (1-100). Default: 10' },
468
+ },
469
+ tier: 'free',
470
+ execute: async (args) => {
471
+ if (!hasTwitch())
472
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
473
+ try {
474
+ const broadcasterId = getBroadcasterId();
475
+ const first = Math.max(1, Math.min(100, parseInt(String(args.count || '10'), 10) || 10));
476
+ const result = await twitchGet(`/channels/followers?broadcaster_id=${broadcasterId}&first=${first}`);
477
+ const total = result.total || 0;
478
+ const followers = (result.data || []).map((f) => {
479
+ const date = new Date(f.followed_at).toLocaleDateString();
480
+ return ` ${f.user_name.padEnd(25)} followed ${date}`;
481
+ });
482
+ let output = `Total followers: ${total.toLocaleString()}\n`;
483
+ if (followers.length > 0) {
484
+ output += `\nRecent followers:\n${followers.join('\n')}`;
485
+ }
486
+ else {
487
+ output += '\nNo recent followers found.';
488
+ }
489
+ return output;
490
+ }
491
+ catch (e) {
492
+ return `Error: ${e.message}`;
493
+ }
494
+ },
495
+ });
496
+ // ── stream_chat_send ──
497
+ registerTool({
498
+ name: 'stream_chat_send',
499
+ description: 'Send a message to Twitch chat as the authenticated user/bot. Requires the chat:edit scope on the OAuth token.',
500
+ parameters: {
501
+ message: { type: 'string', description: 'Message to send to chat (max 500 chars)', required: true },
502
+ },
503
+ tier: 'free',
504
+ execute: async (args) => {
505
+ const message = String(args.message || '').slice(0, 500);
506
+ if (!message)
507
+ return 'Error: message is required.';
508
+ if (!hasTwitch())
509
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
510
+ try {
511
+ const broadcasterId = getBroadcasterId();
512
+ await twitchPost('/chat/messages', {
513
+ broadcaster_id: broadcasterId,
514
+ sender_id: broadcasterId,
515
+ message,
516
+ });
517
+ return `Message sent to Twitch chat: "${message}"`;
518
+ }
519
+ catch (e) {
520
+ return `Error: ${e.message}`;
521
+ }
522
+ },
523
+ });
524
+ // ── stream_ban ──
525
+ registerTool({
526
+ name: 'stream_ban',
527
+ description: 'Ban or timeout a user from Twitch chat. Omit duration for a permanent ban. Set duration in seconds for a timeout.',
528
+ parameters: {
529
+ user_id: { type: 'string', description: 'Twitch user ID to ban. Use stream_followers or Twitch API to look up by username.', required: true },
530
+ duration: { type: 'string', description: 'Timeout duration in seconds (1-1209600). Omit for permanent ban.' },
531
+ reason: { type: 'string', description: 'Reason for the ban/timeout. Default: "Banned by kbot"' },
532
+ },
533
+ tier: 'free',
534
+ execute: async (args) => {
535
+ const userId = String(args.user_id || '');
536
+ if (!userId)
537
+ return 'Error: user_id is required.';
538
+ if (!hasTwitch())
539
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
540
+ try {
541
+ const broadcasterId = getBroadcasterId();
542
+ const banData = {
543
+ user_id: userId,
544
+ reason: String(args.reason || 'Banned by kbot'),
545
+ };
546
+ if (args.duration) {
547
+ const dur = Math.max(1, Math.min(1_209_600, parseInt(String(args.duration), 10) || 600));
548
+ banData.duration = dur;
549
+ }
550
+ await twitchPost(`/moderation/bans?broadcaster_id=${broadcasterId}&moderator_id=${broadcasterId}`, {
551
+ data: banData,
552
+ });
553
+ const action = args.duration ? `timed out for ${args.duration}s` : 'permanently banned';
554
+ return `User ${userId} ${action}. Reason: ${banData.reason}`;
555
+ }
556
+ catch (e) {
557
+ return `Error: ${e.message}`;
558
+ }
559
+ },
560
+ });
561
+ // ── stream_announce ──
562
+ registerTool({
563
+ name: 'stream_announce',
564
+ description: 'Send an announcement to Twitch chat. Announcements appear highlighted in chat. Requires moderator:manage:chat_settings scope.',
565
+ parameters: {
566
+ message: { type: 'string', description: 'Announcement message', required: true },
567
+ color: { type: 'string', description: 'Announcement color: "primary" (default), "blue", "green", "orange", "purple"' },
568
+ },
569
+ tier: 'free',
570
+ execute: async (args) => {
571
+ const message = String(args.message || '');
572
+ if (!message)
573
+ return 'Error: message is required.';
574
+ if (!hasTwitch())
575
+ return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
576
+ try {
577
+ const broadcasterId = getBroadcasterId();
578
+ const validColors = ['primary', 'blue', 'green', 'orange', 'purple'];
579
+ const color = validColors.includes(String(args.color || '')) ? String(args.color) : 'primary';
580
+ await twitchPost(`/chat/announcements?broadcaster_id=${broadcasterId}&moderator_id=${broadcasterId}`, { message, color });
581
+ return `Announcement sent (${color}): "${message}"`;
582
+ }
583
+ catch (e) {
584
+ return `Error: ${e.message}`;
585
+ }
586
+ },
587
+ });
588
+ // ── stream_dashboard ──
589
+ registerTool({
590
+ name: 'stream_dashboard',
591
+ description: 'Get a unified dashboard view across all configured streaming platforms. Shows live status, viewers, title, category, uptime, and follower count from Twitch, Kick, and Rumble.',
592
+ parameters: {},
593
+ tier: 'free',
594
+ execute: async () => {
595
+ const sections = [];
596
+ const configured = [hasTwitch() && 'Twitch', hasRumble() && 'Rumble', hasKick() && 'Kick'].filter(Boolean);
597
+ if (configured.length === 0)
598
+ return noPlatforms();
599
+ sections.push(`STREAM DASHBOARD — ${new Date().toLocaleString()}`);
600
+ sections.push('═'.repeat(50));
601
+ // Twitch
602
+ if (hasTwitch()) {
603
+ try {
604
+ const broadcasterId = getBroadcasterId();
605
+ const [streams, channelInfo, followers] = await Promise.all([
606
+ twitchGet(`/streams?user_id=${broadcasterId}`),
607
+ twitchGet(`/channels?broadcaster_id=${broadcasterId}`),
608
+ twitchGet(`/channels/followers?broadcaster_id=${broadcasterId}&first=1`),
609
+ ]);
610
+ const channel = channelInfo.data?.[0];
611
+ const isLive = streams.data?.length > 0;
612
+ const stream = isLive ? streams.data[0] : null;
613
+ sections.push(`\nTWITCH ${isLive ? '🔴 LIVE' : '⚫ OFFLINE'}` +
614
+ `\n Title: ${stream?.title || channel?.title || 'N/A'}` +
615
+ `\n Category: ${stream?.game_name || channel?.game_name || 'N/A'}` +
616
+ (isLive ? `\n Viewers: ${stream.viewer_count.toLocaleString()}` : '') +
617
+ (isLive ? `\n Uptime: ${formatUptime(stream.started_at)}` : '') +
618
+ `\n Followers: ${(followers.total || 0).toLocaleString()}`);
619
+ }
620
+ catch (e) {
621
+ sections.push(`\nTWITCH: Error — ${e.message}`);
622
+ }
623
+ }
624
+ // Kick
625
+ if (hasKick()) {
626
+ try {
627
+ const data = await kickGetChannel();
628
+ const livestream = data.livestream;
629
+ const isLive = livestream?.is_live || false;
630
+ sections.push(`\nKICK ${isLive ? '🔴 LIVE' : '⚫ OFFLINE'}` +
631
+ `\n Channel: ${data.slug || process.env.KICK_CHANNEL_SLUG}` +
632
+ (isLive ? `\n Title: ${livestream.session_title || 'N/A'}` : '') +
633
+ (isLive ? `\n Category: ${livestream.categories?.[0]?.name || 'N/A'}` : '') +
634
+ (isLive ? `\n Viewers: ${(livestream.viewer_count || 0).toLocaleString()}` : '') +
635
+ `\n Followers: ${(data.followers_count || 0).toLocaleString()}`);
636
+ }
637
+ catch (e) {
638
+ sections.push(`\nKICK: Error — ${e.message}`);
639
+ }
640
+ }
641
+ // Rumble
642
+ if (hasRumble()) {
643
+ try {
644
+ const data = await rumbleGetData();
645
+ const isLive = !!data.is_live;
646
+ sections.push(`\nRUMBLE ${isLive ? '🔴 LIVE' : '⚫ OFFLINE'}` +
647
+ (isLive ? `\n Viewers: ${(data.viewers || 0).toLocaleString()}` : '') +
648
+ (isLive ? `\n Chat msgs: ${(data.chat_messages || 0).toLocaleString()}` : '') +
649
+ (data.followers !== undefined ? `\n Followers: ${(data.followers || 0).toLocaleString()}` : ''));
650
+ }
651
+ catch (e) {
652
+ sections.push(`\nRUMBLE: Error — ${e.message}`);
653
+ }
654
+ }
655
+ // Combined stats
656
+ let totalViewers = 0;
657
+ if (hasTwitch()) {
658
+ try {
659
+ const broadcasterId = getBroadcasterId();
660
+ const s = await twitchGet(`/streams?user_id=${broadcasterId}`);
661
+ if (s.data?.length)
662
+ totalViewers += s.data[0].viewer_count;
663
+ }
664
+ catch { /* skip */ }
665
+ }
666
+ if (hasRumble()) {
667
+ try {
668
+ const d = await rumbleGetData();
669
+ if (d.is_live)
670
+ totalViewers += d.viewers || 0;
671
+ }
672
+ catch { /* skip */ }
673
+ }
674
+ if (hasKick()) {
675
+ try {
676
+ const d = await kickGetChannel();
677
+ if (d.livestream?.is_live)
678
+ totalViewers += d.livestream.viewer_count || 0;
679
+ }
680
+ catch { /* skip */ }
681
+ }
682
+ sections.push(`\n${'═'.repeat(50)}`);
683
+ sections.push(`Combined viewers: ${totalViewers.toLocaleString()}`);
684
+ sections.push(`Platforms: ${configured.join(', ')}`);
685
+ return sections.join('\n');
686
+ },
687
+ });
688
+ // ── stream_setup_oauth ──
689
+ registerTool({
690
+ name: 'stream_setup_oauth',
691
+ description: 'Show instructions for setting up OAuth tokens for Twitch, Kick, and Rumble streaming platform control. Does not execute anything — just shows the setup guide.',
692
+ parameters: {
693
+ platform: { type: 'string', description: 'Specific platform to show setup for: "twitch", "kick", "rumble". Default: all' },
694
+ },
695
+ tier: 'free',
696
+ execute: async (args) => {
697
+ const platform = args.platform ? String(args.platform).toLowerCase() : 'all';
698
+ const sections = [];
699
+ sections.push('STREAM CONTROL — OAUTH SETUP GUIDE');
700
+ sections.push('═'.repeat(50));
701
+ if (platform === 'all' || platform === 'twitch') {
702
+ const scopes = [
703
+ 'channel:manage:broadcast',
704
+ 'chat:edit',
705
+ 'chat:read',
706
+ 'moderator:manage:chat_settings',
707
+ 'clips:edit',
708
+ 'channel:read:stream_key',
709
+ 'moderator:manage:banned_users',
710
+ 'moderator:manage:announcements',
711
+ 'channel:read:editors',
712
+ ].join('+');
713
+ sections.push(`
714
+ TWITCH (Helix API)
715
+ ──────────────────
716
+ 1. Go to: https://dev.twitch.tv/console/apps
717
+ 2. Click "Register Your Application"
718
+ 3. Name: "kbot" (or anything)
719
+ 4. OAuth Redirect URL: http://localhost
720
+ 5. Category: Chat Bot
721
+ 6. Click "Create" — note the Client ID
722
+
723
+ 7. Generate an OAuth token:
724
+ Open this URL in your browser (replace YOUR_CLIENT_ID):
725
+
726
+ https://id.twitch.tv/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost&response_type=token&scope=${scopes}
727
+
728
+ 8. After authorizing, you'll be redirected to:
729
+ http://localhost/#access_token=YOUR_TOKEN&...
730
+ Copy the access_token value.
731
+
732
+ 9. Find your broadcaster ID:
733
+ https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
734
+
735
+ 10. Set environment variables:
736
+ export TWITCH_CLIENT_ID="your_client_id"
737
+ export TWITCH_OAUTH_TOKEN="your_access_token"
738
+ export TWITCH_BROADCASTER_ID="your_user_id"
739
+
740
+ Required scopes:
741
+ channel:manage:broadcast — update title/category
742
+ chat:edit — send chat messages
743
+ chat:read — read chat
744
+ moderator:manage:chat_settings — slow mode, sub-only, etc.
745
+ clips:edit — create clips
746
+ moderator:manage:banned_users — ban/timeout users
747
+ moderator:manage:announcements — send announcements`);
748
+ }
749
+ if (platform === 'all' || platform === 'kick') {
750
+ sections.push(`
751
+ KICK
752
+ ────
753
+ Kick has a limited public API. For most dashboard actions, kbot uses
754
+ its built-in browser (kbot_browse) or computer-use tools to navigate
755
+ dashboard.kick.com.
756
+
757
+ For basic channel info via API:
758
+ export KICK_CHANNEL_SLUG="your_channel_name"
759
+
760
+ For full dashboard control, use kbot with --computer-use flag:
761
+ kbot --computer-use "update my Kick stream title"`);
762
+ }
763
+ if (platform === 'all' || platform === 'rumble') {
764
+ sections.push(`
765
+ RUMBLE
766
+ ──────
767
+ Rumble provides a livestream API key for reading stream data.
768
+
769
+ 1. Go to your Rumble account settings
770
+ 2. Find the "Livestream API" section
771
+ 3. Copy your API key
772
+
773
+ 4. Set environment variable:
774
+ export RUMBLE_API_KEY="your_api_key"
775
+
776
+ Note: Rumble's API is mostly read-only (viewer count, chat, status).
777
+ For dashboard changes, use kbot with --computer-use flag or kbot_browse.`);
778
+ }
779
+ // Show current status
780
+ sections.push(`\n${'═'.repeat(50)}`);
781
+ sections.push('CURRENT STATUS:');
782
+ sections.push(` Twitch: ${hasTwitch() ? 'Configured' : 'NOT configured'}${process.env.TWITCH_BROADCASTER_ID ? '' : ' (missing TWITCH_BROADCASTER_ID)'}`);
783
+ sections.push(` Kick: ${hasKick() ? 'Configured' : 'NOT configured'}`);
784
+ sections.push(` Rumble: ${hasRumble() ? 'Configured' : 'NOT configured'}`);
785
+ return sections.join('\n');
786
+ },
787
+ });
788
+ }
789
+ //# sourceMappingURL=stream-control.js.map