1/* WW-CHATCMD -- the one chat command system. 2 * 3 * â ï¸ WHY THIS FILE EXISTS. There were two parallel mechanisms: 4 * 5 * /feature signed by WWChatCmd (in ww-feature-alert.js) and sent over the 6 * socket for knlive_Core.js to act on. 7 * /clip ~200 lines of parsing, permission checks, a palette and a fetch, 8 * inline in wavedeck_chatpop_gift.php and therefore working in 9 * exactly one of the site's three chat boxes. 10 * 11 * Adding a third command meant picking a side and copying a pile of code. Now 12 * there is one registry, one parser, one permission model, one help surface and 13 * one dispatcher, and a command declares how it EXECUTES rather than living in 14 * a different code path: 15 * 16 * transport:'socket' the node owns it. handle() returns false and the chat 17 * box sends it exactly as it always did, signed. 18 * transport:'local' this file owns it. run() does the work, handle() 19 * returns true and nothing reaches the socket. 20 * 21 * â ï¸ /feature stays on the socket ON PURPOSE. The node owns the featured-stream 22 * file and its timed expiry; re-implementing that in PHP to force a single 23 * transport would be churn with real risk and no benefit. The transports differ 24 * because the OWNERS differ - the system around them does not. 25 * 26 * â ï¸ This file is the sole definition of window.WWChatCmd. The signing half used 27 * to live in ww-feature-alert.js; it moved here whole so that two files can 28 * never race to define the same global. If you add a loader for one, add it for 29 * the other. 30 * 31 * â ï¸ Nothing here is a security boundary. show() only decides what a chat box 32 * OFFERS; every command re-checks permission server-side, because a POST is 33 * trivial to forge. 34 */ 35(function () { 36 if (window.WWChatCmd && window.WWChatCmd.__wwCmdSystem) { return; } 37 38 /* ------------------------------------------------ 1. signing (moved here) */ 39 40 var authCache = null, authAt = 0, authInFlight = null; 41 /* Well inside whatever replay window the node enforces, and the same five 42 seconds vy_lvst.auth() settled on. Do not raise without checking the node. */ 43 var AUTH_TTL = 5000; 44 45 function fetchAuth() { 46 var now = Date.now(); 47 if (authCache && (now - authAt) < AUTH_TTL) { return Promise.resolve(authCache); } 48 if (authInFlight) { return authInFlight; } 49 50 /* vy_lvst has its own single-flight cache; prefer it where it exists so a 51 page with both does not sign twice. */ 52 if (window.vy_lvst && typeof window.vy_lvst.auth === 'function') { 53 authInFlight = Promise.resolve() 54 .then(function () { return window.vy_lvst.auth(); }) 55 .then(function (a) { 56 authInFlight = null; 57 if (a && a.sig) { authCache = a; authAt = Date.now(); } 58 return (a && a.sig) ? a : null; 59 }) 60 .catch(function () { authInFlight = null; return null; }); 61 return authInFlight; 62 } 63 64 authInFlight = fetch('/kn_livecmd.php?cmd=auth', { credentials: 'include' }) 65 .then(function (r) { return r.json(); }) 66 .then(function (a) { 67 authInFlight = null; 68 if (a && a.sig) { authCache = a; authAt = Date.now(); return a; } 69 authCache = null; authAt = 0; 70 return null; 71 }) 72 .catch(function () { 73 authInFlight = null; authCache = null; authAt = 0; 74 return null; 75 }); 76 return authInFlight; 77 } 78 79 /* ------------------------------------------------------------ 2. helpers */ 80 81 function esc(v) { 82 var d = document.createElement('div'); 83 d.innerText = String(v == null ? '' : v); 84 return d.innerHTML; 85 } 86 87 function minsOf(secs) { return (secs % 60 === 0) ? (secs / 60) : (Math.round(secs / 6) / 10); } 88 89 function lengthLabel(secs) { 90 return secs >= 60 ? (minsOf(secs) + ' minute' + (secs > 60 ? 's' : '')) : (secs + ' seconds'); 91 } 92 93 /* A context is whatever the surface can tell us. Every field is optional and 94 every reader treats absence as "not allowed" rather than guessing. */ 95 function norm(ctx) { 96 ctx = ctx || {}; 97 return { 98 streamId: Number(ctx.streamId || 0), 99 site: String(ctx.site || (location.origin || '')), 100 canChat: !!ctx.canChat, 101 isPro: !!ctx.isPro, 102 isAdmin: !!ctx.isAdmin, 103 clipOk: !!ctx.clipOk, 104 clipWhy: String(ctx.clipWhy || ''), 105 token: String(ctx.token || ''), 106 maxFree: Number(ctx.maxFree || 300), 107 maxPro: Number(ctx.maxPro || 900), 108 minSecs: Number(ctx.minSecs || 15), 109 say: (typeof ctx.say === 'function') ? ctx.say : function () {}, 110 announce: (typeof ctx.announce === 'function') ? ctx.announce : function () {}, 111 openDialog: (typeof ctx.openDialog === 'function') ? ctx.openDialog : function () { return false; } 112 }; 113 } 114 115 /* ------------------------------------------------------ 3. the /clip work */ 116 117 var clipRunning = false;
118 119 function clipCap(c) { return c.isPro ? c.maxPro : c.maxFree; } 120 121 function clipRun(secs, c) { 122 if (clipRunning) { return; } 123 clipRunning = true; 124 125 /* â ï¸ Claimed inside the click/submit event, while it is still a user 126 gesture. A window.open() after the fetch resolves is blocked everywhere. */ 127 var tab = null; 128 try { 129 tab = window.open('', '_blank'); 130 if (tab && tab.document) { 131 tab.document.write('<!doctype html><meta charset="utf-8"><title>Making your clip...</title>' 132 + '<style>html,body{margin:0;height:100%;display:flex;align-items:center;justify-content:center;' 133 + 'background:#0b0809;color:#cfc6c8;font:600 15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}</style>' 134 + '<div>Making your clip...</div>'); 135 tab.document.close(); 136 } 137 } catch (e) { tab = null; } 138 139 c.say('<b>Clipping</b> the last ' + lengthLabel(secs) + 'â¦'); 140 141 var body = new URLSearchParams(); 142 body.append('streamId', c.streamId); 143 body.append('secs', secs); 144 body.append('token', c.token); 145 146 fetch(c.site + '/ww_live_clip.php', { method: 'POST', body: body, credentials: 'same-origin' }) 147 .then(function (r) { return r.json().catch(function () { return null; }); }) 148 .then(function (d) { 149 /* WW-CLIP-DRAFT: a clip is no longer posted for you. It is cut and 150 saved as a draft, and this opens the editor where you caption it and 151 decide whether to post it at all. editUrl is the new field; postUrl is 152 kept pointing at the same place so nothing breaks mid-deploy. */ 153 var dest = (d && (d.editUrl || d.postUrl)) || ''; 154 if (d && d.ok && dest) { 155 c.announce('clip', 'clipped the last ' + lengthLabel(d.duration ? Math.round(d.duration) : secs)); 156 if (tab) { try { tab.location = dest; } catch (e) {} } 157 else { c.say('<b>Clip ready.</b> <a href="' + esc(dest) + '" target="_blank">Caption and post it</a>'); } 158 return; 159 } 160 if (tab) { try { tab.close(); } catch (e) {} } 161 if (d && d.code === 'needs_pro') { 162 c.say('<b>' + esc(d.error) + '</b> <a href="' + esc(c.site) + '/go-pro" target="_blank">Upgrade to Pro</a>'); 163 } else { 164 c.say('<b>' + esc((d && d.error) ? d.error : 'Could not make that clip.') + '</b>'); 165 } 166 }) 167 .catch(function () { 168 if (tab) { try { tab.close(); } catch (e) {} } 169 c.say('<b>Could not reach the server.</b> Please try again.'); 170 }) 171 .then(function () { clipRunning = false; }); 172 } 173 174 /* --------------------------------------------------------- 4. the registry */ 175 176 var CMDS = []; 177 178 function register(cmd) { 179 if (!cmd || !cmd.name) { return; } 180 CMDS = CMDS.filter(function (c) { return c.name !== cmd.name; }); 181 CMDS.push(cmd); 182 } 183 184 register({ 185 name: 'clip', 186 args: '[minutes]', 187 transport: 'local', 188 show: function (c) { return c.canChat && c.streamId > 0; }, 189 desc: function (c) { 190 var cap = clipCap(c) / 60; 191 return '<b>/clip</b> opens the clip picker. <b>/clip 2</b> clips the last 2 minutes ' 192 + 'and opens it so you can caption and post it. You can clip up to <b>' + cap + ' minutes</b>' 193 + (c.isPro ? ' with Pro.' : ' â Pro raises it to ' + (c.maxPro / 60) + '.'); 194 }, 195 run: function (arg, c) { 196 if (!c.canChat) { c.say('<b>Sign in to clip this stream.</b>'); return; } 197 198 if (!c.clipOk) { 199 c.say(c.clipWhy === 'private' 200 ? '<b>This is a private stream, so clipping is off.</b>' 201 : '<b>This streamer has recording turned off, so clipping is off for their streams.</b>'); 202 return; 203 } 204 205 if (arg === '') { 206 if (!c.openDialog()) { 207 c.say('<b>Use the Clip button on the stream page,</b> or type a length â <b>/clip 2</b>.'); 208 } 209 return; 210 } 211 212 /* "2", "2m", "90s", "1.5" - minutes unless it says seconds. */ 213 var um = /^(\d+(?:\.\d+)?)\s*(m|min|mins|minutes?|s|secs?|seconds?)?$/i.exec(arg); 214 if (!um) { 215 c.say('<b>Usage: /clip [minutes]</b> â for example <b>/clip 2</b>. ' 216 + 'On its own, <b>/clip</b> opens the picker.'); 217 return; 218 } 219 220 var n = parseFloat(um[1]); 221 var unit = (um[2] || 'm').toLowerCase(); 222 var secs = Math.round(/^s/.test(unit) ? n : n * 60); 223 var cap = clipCap(c); 224 225 if (!(secs > 0)) { 226 c.say('<b>Usage: /clip [minutes]</b> â for example <b>/clip 2</b>.'); 227 return; 228 } 229 if (secs < c.minSecs) { 230 c.say('<b>The shortest clip is ' + c.minSecs + ' seconds.</b>'); 231 return; 232 } 233 if (secs > cap) { 234 if (c.isPro) { 235 c.say('<b>The longest clip is ' + (c.maxPro / 60) + ' minutes.</b>'); 236 } else { 237 c.say('<b>Free accounts can clip up to ' + (c.maxFree / 60) + ' minutes.</b> ' 238 + 'Upgrade to Pro to clip up to ' + (c.maxPro / 60) + ' minutes â ' 239 + '<a href="' + esc(c.site) + '/go-pro" target="_blank">Go Pro</a>'); 240 } 241 return; 242 } 243 244 clipRun(secs, c); 245 } 246 }); 247 248 register({ 249 name: 'feature', 250 args: '[minutes|off]', 251 transport: 'socket', /* the node owns the featured file and its expiry */ 252 show: function (c) { return c.isAdmin; }, 253 desc: function () { 254 return '<b>/feature</b> features this stream indefinitely, <b>/feature 30</b> for 30 ' 255 + 'minutes, <b>/feature off</b> clears it. Admins only.'; 256 } 257 }); 258 259 /* --------------------------------------------------------- 5. dispatching */ 260 261 var SLASH = /^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i; 262 263 function parse(text) { 264 var m = SLASH.exec(String(text == null ? '' : text).trim()); 265 if (!m) { return null; } 266 return { name: m[1].toLowerCase(), arg: (m[2] || '').trim() }; 267 } 268 269 function visible(ctx) { 270 var c = norm(ctx); 271 return CMDS.filter(function (cmd) {
272 try { return cmd.show ? !!cmd.show(c) : true; } catch (e) { return false; } 273 }); 274 } 275 276 /* 277 * true -> this file dealt with it; the caller must NOT send it. 278 * false -> not ours. The caller sends it exactly as before, which keeps 279 * /feature working and leaves room for future node commands. 280 */ 281 function handle(text, ctx) { 282 var p = parse(text); 283 if (!p) { return false; } 284 var cmd = CMDS.filter(function (x) { return x.name === p.name; })[0]; 285 if (!cmd || cmd.transport !== 'local' || typeof cmd.run !== 'function') { return false; } 286 var c = norm(ctx); 287 if (cmd.show && !cmd.show(c)) { return false; } 288 try { cmd.run(p.arg, c); } catch (e) { c.say('<b>That command failed.</b>'); } 289 return true; 290 } 291 292 /* ----------------------------------------------------------- 6. the palette */ 293 294 var PALETTE_CSS_ID = 'ww-chatcmd-css'; 295 296 /* 297 * â ï¸ !important on the row's own painting, and it is not laziness. 298 * 299 * The palette is injected INTO each chat box's composer, and those composers 300 * style their buttons by ID: "#wch_page .wch_send button{background:linear- 301 * gradient(...)}" is (1 id, 1 class, 1 element) and beats any class-only rule 302 * this file can write, so every command row rendered as a red Send button. 303 * A shared widget cannot know what selectors its host uses, and cannot raise 304 * its own specificity without hardcoding one host's id - so the handful of 305 * properties that define the row's appearance are pinned. 306 */ 307 var PALETTE_CSS = '' 308 + '.ww-cmdbox{position:absolute;left:10px;right:10px;bottom:calc(100% + 6px);z-index:40;' 309 + 'display:none;flex-direction:column;gap:2px;padding:7px;border-radius:11px;' 310 + 'background:rgba(16,16,20,.72);border:1px solid rgba(255,255,255,.14);' 311 + '-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);' 312 + 'box-shadow:0 14px 38px rgba(0,0,0,.55);max-height:250px;overflow-y:auto;text-align:left}' 313 + '.ww-cmdbox.is-open{display:flex}' 314 + '.ww-cmdbox .ww-cmdhint{font-size:10.5px;font-weight:800;letter-spacing:.4px;' 315 + 'text-transform:uppercase;color:#8a8a99;padding:3px 7px 5px}' 316 + '.ww-cmdbox .ww-cmdrow{display:block!important;width:100%!important;text-align:left!important;' 317 + 'border:0!important;cursor:pointer;background:none!important;background-image:none!important;' 318 + 'padding:7px 8px!important;border-radius:7px!important;margin:0!important;' 319 + 'font-family:inherit!important;font-size:13px!important;color:#e8e8ef!important;' 320 + 'box-shadow:none!important;opacity:1!important}' 321 + '.ww-cmdbox .ww-cmdrow:hover:not(:disabled),.ww-cmdbox .ww-cmdrow.is-sel' 322 + '{background:rgba(255,255,255,.11)!important;background-image:none!important}' 323 + '.ww-cmdbox .ww-cmdrow_t{display:block!important;font-size:13px;font-weight:800;color:#fff}' 324 + '.ww-cmdbox .ww-cmdrow_t em{font-style:normal;font-weight:600;color:#9fd8ff;margin-left:5px}' 325 + '.ww-cmdbox .ww-cmdrow_d{display:block!important;font-size:11.5px;line-height:1.45;' 326 + 'color:#a8a8b4;margin-top:3px}' 327 + '.ww-cmdbox .ww-cmdrow_d b{color:#d8d8e2;font-weight:700;margin:0}' 328 + '.ww-cmdbox .ww-cmdrow_d a{color:#9fd8ff}'; 329 330 function ensureCss(doc) { 331 doc = doc || document; 332 if (doc.getElementById(PALETTE_CSS_ID)) { return; } 333 var s = doc.createElement('style'); 334 s.id = PALETTE_CSS_ID; 335 s.textContent = PALETTE_CSS; 336 (doc.head || doc.documentElement).appendChild(s); 337 } 338 339 /* 340 * Wire the "/" palette to one composer. 341 * 342 * `input` is the text field, `anchor` the positioned element the box hangs 343 * from (defaults to the input's offsetParent-ish container), and getCtx() is 344 * re-read on every keystroke so Pro status or a stream change is picked up 345 * without re-attaching. 346 */ 347 function attachPalette(input, anchor, getCtx) { 348 if (!input || input.__wwCmdPalette) { return null; } 349 input.__wwCmdPalette = true; 350 351 var doc = input.ownerDocument || document; 352 ensureCss(doc); 353 354 anchor = anchor || input.parentNode; 355 if (anchor && getComputedStyle(anchor).position === 'static') { 356 anchor.style.position = 'relative'; 357 } 358 359 var boxEl = doc.createElement('div'); 360 boxEl.className = 'ww-cmdbox'; 361 boxEl.setAttribute('role', 'listbox'); 362 boxEl.setAttribute('aria-label', 'Chat commands'); 363 anchor.appendChild(boxEl); 364 365 var sel = -1, shown = []; 366 367 /* â ï¸ A throwing getCtx() must not look like "no commands available". This 368 swallowed a ReferenceError once and the palette simply listed nothing, 369 with no error anywhere - the surface was handing us a say() that no longer 370 existed. Fall back, but say so out loud. */ 371 function ctx() {
372 try { return norm(getCtx()); } 373 catch (e) { 374 if (!ctx.__warned) { 375 ctx.__warned = true; 376 try { console.warn('[WWChatCmd] context threw; no commands will be offered:', e); } catch (e2) {} 377 } 378 return norm({}); 379 } 380 } 381 382 function close() { boxEl.classList.remove('is-open'); sel = -1; shown = []; } 383 384 function paint(list, c) { 385 shown = list; 386 if (!list.length) { close(); return; } 387 var html = '<div class="ww-cmdhint">Commands' 388 + (list.length > 1 ? ' â ââ to pick, Tab to fill' : '') + '</div>'; 389 list.forEach(function (cmd, i) { 390 html += '<button type="button" class="ww-cmdrow' + (i === sel ? ' is-sel' : '') 391 + '" data-cmd="' + esc(cmd.name) + '" role="option">' 392 + '<span class="ww-cmdrow_t">/' + esc(cmd.name) + ' <em>' + esc(cmd.args || '') + '</em></span>' 393 + '<span class="ww-cmdrow_d">' + (cmd.desc ? cmd.desc(c) : '') + '</span>' 394 + '</button>'; 395 }); 396 boxEl.innerHTML = html; 397 boxEl.classList.add('is-open'); 398 } 399 400 /* Open while the line starts with "/" and the command word is still being 401 typed, or once it matches one exactly - so the usage text stays on screen 402 while the argument is entered. */ 403 function sync() { 404 var v = input.value; 405 if (v.charAt(0) !== '/') { close(); return; } 406 var c = ctx(); 407 var word = v.slice(1).split(/\s/)[0].toLowerCase(); 408 var all = visible(c); 409 var hit = all.filter(function (x) { return x.name.indexOf(word) === 0; }); 410 if (/\s/.test(v)) { hit = all.filter(function (x) { return x.name === word; }); } 411 if (!hit.length) { close(); return; } 412 if (sel >= hit.length) { sel = hit.length - 1; } 413 paint(hit, c); 414 } 415 416 function fill(cmd) { 417 if (!cmd) { return; } 418 input.value = '/' + cmd.name + ' '; 419 input.focus(); 420 sel = -1; 421 sync(); 422 } 423 424 boxEl.addEventListener('mousedown', function (e) { e.preventDefault(); }); 425 boxEl.addEventListener('click', function (e) { 426 var row = e.target.closest ? e.target.closest('.ww-cmdrow') : null; 427 if (!row) { return; } 428 var name = row.getAttribute('data-cmd'); 429 fill(visible(ctx()).filter(function (x) { return x.name === name; })[0]); 430 }); 431 432 input.addEventListener('input', sync); 433 input.addEventListener('blur', function () { setTimeout(close, 150); }); 434 input.addEventListener('keydown', function (e) { 435 if (!boxEl.classList.contains('is-open')) { return; } 436 if (e.key === 'Escape') { close(); e.preventDefault(); return; } 437 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 438 e.preventDefault(); 439 if (!shown.length) { return; } 440 sel = (e.key === 'ArrowDown') ? ((sel + 1) % shown.length) 441 : ((sel <= 0 ? shown.length : sel) - 1); 442 paint(shown, ctx()); 443 return; 444 } 445 if (e.key === 'Tab') { e.preventDefault(); fill(shown[sel >= 0 ? sel : 0]); return; } 446 /* Enter completes only when the arrows actually chose something. 447 Otherwise it sends, which is what Enter in a chat box has always done. */ 448 if (e.key === 'Enter' && sel >= 0) { e.preventDefault(); fill(shown[sel]); return; } 449 }); 450 451 return { close: close, sync: sync, el: boxEl }; 452 } 453 454 /* ------------------------------------------------------------- 7. exports */ 455 456 window.WWChatCmd = { 457 version: 2, 458 __wwCmdSystem: true, 459 460 /* Back-compat, unchanged semantics: every chat box already calls these to 461 decide whether to sign before sending. A slash command is anything the 462 server might act on; the rest are stored as ordinary messages. */ 463 is: function (text) { 464 return /^\/[a-z][a-z0-9_-]*(\s|$)/i.test(String(text == null ? '' : text).trim()); 465 }, 466 sign: fetchAuth, 467 468 register: register, 469 parse: parse, 470 list: visible, 471 handle: handle, 472 attachPalette: attachPalette 473 }; 474})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.