1/* ww-chat-wavebot-cmds.js â Wavebot commands in World's Wave chat. 2 * 3 * Two jobs: 4 * 1. type "!" in the chat box and the available commands appear, filtered as 5 * you keep typing. Nobody can use a command they cannot discover, and 6 * until now the only way to learn one was to be told. 7 * 2. carry a typed command to the Wavebot engine. 8 * 9 * ââ Why the message is NOT swallowed âââââââââââââââââââââââââââââââââââââ 10 * The command still goes to chat as an ordinary message, exactly as it does on 11 * Twitch or Kick: everyone sees who asked for the clip, and the bot's reply 12 * lands underneath. So this listens in the CAPTURE phase and fires the command 13 * alongside the normal send rather than replacing it. 14 * 15 * ââ Why a server round trip ââââââââââââââââââââââââââââââââââââââââââââââ 16 * Native chat goes over the socket into the live backend, which does not call 17 * the command trigger â external platforms reach it through their ingest paths, 18 * World's Wave did not. wavedeck_cmd.php bridges that with the streamer's key, 19 * minted server-side. The viewer's handle comes from their session, so nobody 20 * can run a command as somebody else. 21 */ 22(function () { 23 'use strict'; 24 25 var ROW_LIMIT = 8; 26 var prefix = '!'; 27 var commands = []; 28 var csrf = ''; 29 var sid = 0; 30 var box = null; 31 var input = null; 32 var active = -1; 33 34 function getSID() { 35 var w = 0; 36 try { w = parseInt(window.WW_WC_SID || 0, 10) || 0; } catch (e) {} 37 if (!w) { try { w = parseInt(window.parent.WW_WC_SID || 0, 10) || 0; } catch (e) {} } 38 if (!w) { 39 var host = document.querySelector('[data-ww-stream-id]'); 40 if (host) { w = parseInt(host.getAttribute('data-ww-stream-id'), 10) || 0; } 41 } 42 return w; 43 } 44 45 var css = 46 '.wwcmd-pop{position:absolute;z-index:80;background:#15171c;border:1px solid #333;' 47 + 'border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.65);min-width:210px;max-width:320px;' 48 + 'overflow:hidden;font-size:13px}' 49 + '.wwcmd-pop .wwcmd-h{padding:6px 11px;color:#9aa0aa;font-size:11px;text-transform:uppercase;' 50 + 'letter-spacing:.4px;border-bottom:1px solid #262a31}' 51 + '.wwcmd-pop button{display:block;width:100%;text-align:left;background:none;border:0;' 52 + 'color:#e8e8e8;padding:8px 11px;cursor:pointer;line-height:1.25}' 53 + '.wwcmd-pop button:hover,.wwcmd-pop button.on{background:#22252c}' 54 + '.wwcmd-pop .c{color:#ffc74a;font-weight:600}' 55 + '.wwcmd-pop .p{color:#9aa0aa;font-size:11px;margin-left:6px}' 56 + '.wwcmd-pop .h{display:block;color:#9aa0aa;font-size:11px;margin-top:1px;' 57 + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'; 58 var st = document.createElement('style'); 59 st.textContent = css; 60 document.head.appendChild(st); 61 62 function load() { 63 sid = getSID(); 64 if (!sid) { return; } 65 fetch('/wavedeck_cmd.php?stream_id=' + sid, { credentials: 'same-origin' }) 66 .then(function (r) { return r.ok ? r.json() : null; }) 67 .then(function (j) { 68 if (!j || !j.ok) { return; } 69 prefix = j.prefix || '!'; 70 commands = j.commands || []; 71 /* Same response carries the command token. It used to come from 72 wavedeck_pin.php, which only issues one to the host and mods â so 73 every ordinary viewer held '' and their commands did nothing. */ 74 if (j.csrf) { csrf = j.csrf; } 75 })['catch'](function () {}); 76 } 77 78 function close() { 79 if (box && box.parentNode) { box.parentNode.removeChild(box); } 80 box = null; active = -1; 81 } 82 83 function matches(txt) { 84 if (!commands.length) { return []; } 85 if (txt.slice(0, prefix.length) !== prefix) { return []; } 86 var typed = txt.slice(prefix.length).split(/\s/)[0].toLowerCase(); 87 /* Once there is a space the command is chosen and they are typing an 88 argument â the picker gets out of the way. */ 89 if (/\s/.test(txt.slice(prefix.length))) { return []; } 90 return commands.filter(function (c) { 91 return c.cmd.toLowerCase().indexOf(typed) === 0; 92 }).slice(0, ROW_LIMIT); 93 } 94 95 function choose(cmd) { 96 if (!input) { return; } 97 input.value = prefix + cmd + ' '; 98 close(); 99 try { input.focus(); } catch (e) {} 100 } 101 102 function paint(list) { 103 close(); 104 if (!list.length || !input) { return; } 105 box = document.createElement('div'); 106 box.className = 'wwcmd-pop'; 107 var h = document.createElement('div'); 108 h.className = 'wwcmd-h'; 109 h.textContent = 'Commands'; 110 box.appendChild(h); 111 112 list.forEach(function (c, i) { 113 var b = document.createElement('button'); 114 b.type = 'button'; 115 var name = document.createElement('span'); 116 name.className = 'c'; 117 name.textContent = prefix + c.cmd; 118 b.appendChild(name); 119 if (c.perm && c.perm !== 'everyone') { 120 var p = document.createElement('span'); 121 p.className = 'p'; 122 p.textContent = c.perm === 'broadcaster' ? 'host only' : c.perm + ' only'; 123 b.appendChild(p); 124 } 125 if (c.help) { 126 var hh = document.createElement('span'); 127 hh.className = 'h'; 128 hh.textContent = c.help; 129 b.appendChild(hh); 130 } 131 b.addEventListener('mousedown', function (e) { e.preventDefault(); choose(c.cmd); }); 132 box.appendChild(b); 133 }); 134 135 /* Anchored above the input: the chat box sits at the bottom of a panel, so 136 a menu opening downward would be off-screen every time. */ 137 var r = input.getBoundingClientRect(); 138 document.body.appendChild(box); 139 var top = r.top + window.pageYOffset - box.offsetHeight - 6; 140 if (top < 4) { top = r.bottom + window.pageYOffset + 6; } 141 box.style.left = Math.max(4, r.left + window.pageXOffset) + 'px'; 142 box.style.top = top + 'px'; 143 active = -1; 144 } 145 146 /* WW-CMDCSRF: get a token, from cache or from the server. 147 The startup fetch is a warm cache, not a prerequisite -- losing that race 148 used to leave csrf empty for the life of the page, and run() then failed 149 silently while the command still appeared in chat. */ 150 function withCsrf(done) { 151 if (csrf) { done(csrf); return; } 152 if (!sid) { done(''); return; } 153 fetch('/wavedeck_cmd.php?stream_id=' + sid, { credentials: 'same-origin' }) 154 .then(function (r) { return r.ok ? r.json() : null; }) 155 .then(function (j) { csrf = (j && j.csrf) || ''; done(csrf); }) 156 ['catch'](function () { done(''); }); 157 } 158 159 function post(text, token, retry) { 160 var body = 'stream_id=' + encodeURIComponent(sid) 161 + '&text=' + encodeURIComponent(text) 162 + '&csrf=' + encodeURIComponent(token); 163 fetch('/wavedeck_cmd.php', { 164 method: 'POST', credentials: 'same-origin', 165 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 166 body: body 167 }).then(function (r) { 168 /* A rejected token is what a rotated one looks like. Drop it, take a 169 fresh one, and try exactly once more. */ 170 if ((r.status === 403 || r.status === 419) && retry) { 171 csrf = ''; 172 withCsrf(function (t) { if (t) { post(text, t, false); } }); 173 } 174 })['catch'](function () {}); /* the engine posts its own reply into chat */ 175 } 176 177 /* One entry point for both triggers. Enter reaches the keydown listener AND 178 the submit listener, so without this a single press would clip twice. */ 179 var lastFired = '', lastAt = 0; 180 function fire(raw) { 181 var txt = String(raw || '').trim(); 182 if (!txt || txt.slice(0, prefix.length) !== prefix) { return; } 183 var now = Date.now(); 184 if (txt === lastFired && (now - lastAt) < 1500) { return; } 185 lastFired = txt; lastAt = now; 186 run(txt); 187 } 188 189 function run(text) { 190 if (!sid) { return; } 191 withCsrf(function (t) { 192 if (t) { post(text, t, true); return; } 193 /* No token means not signed in (or the session went away). Say so once, 194 rather than swallowing the command and looking broken â which is exactly 195 how this failed for viewers before. */
196 try { console.warn('[wavebot] chat commands need you to be signed in to World\'s Wave'); } catch (e) {} 197 }); 198 } 199 200 /* Delegated: the chat input is rebuilt on reconnect, so binding once to the 201 document survives what binding to the element would not. */ 202 document.addEventListener('input', function (e) { 203 var t = e.target; 204 if (!t || t.tagName !== 'INPUT' && t.tagName !== 'TEXTAREA') { return; } 205 if (!/message|comment|chat/i.test((t.className || '') + ' ' + (t.placeholder || '') + ' ' + (t.id || ''))) { return; } 206 input = t; 207 paint(matches(String(t.value || ''))); 208 }, true); 209 210 document.addEventListener('keydown', function (e) { 211 if (!input || e.target !== input) { return; } 212 213 if (box) { 214 var items = box.querySelectorAll('button'); 215 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 216 e.preventDefault(); 217 active += (e.key === 'ArrowDown' ? 1 : -1); 218 if (active < 0) { active = items.length - 1; } 219 if (active >= items.length) { active = 0; } 220 for (var i = 0; i < items.length; i++) { items[i].classList.toggle('on', i === active); } 221 return; 222 } 223 if (e.key === 'Tab' || (e.key === 'Enter' && active >= 0)) { 224 e.preventDefault(); 225 e.stopPropagation(); 226 var pick = items[active >= 0 ? active : 0]; 227 if (pick) { choose(pick.querySelector('.c').textContent.slice(prefix.length)); } 228 return; 229 } 230 if (e.key === 'Escape') { close(); return; } 231 } 232 233 /* Enter with a command typed: let the message send as normal AND run it. 234 Kept as a fallback for chat surfaces whose input is not inside a form; 235 the submit listener below is what normally fires. */ 236 if (e.key === 'Enter' && !e.shiftKey) { 237 fire(String(input.value || '')); 238 close(); 239 } 240 }, true); 241 242 /* WW-CMDSUBMIT: the send BUTTON is a send too. 243 Enter was the only trigger, so clicking the paper-plane -- which is how 244 every phone sends -- posted the message and ran no command. Submit covers 245 Enter, the button and mobile alike. Capture phase, because the chat's own 246 submit handler preventDefaults in the bubble phase. */ 247 document.addEventListener('submit', function (e) { 248 var form = e.target; 249 if (!form || typeof form.querySelector !== 'function') { return; } 250 var el = form.querySelector('input[type="text"], input:not([type]), textarea'); 251 if (!el) { return; } 252 fire(String(el.value || '')); 253 close(); 254 }, true); 255 256 document.addEventListener('click', function (e) { 257 if (box && !box.contains(e.target) && e.target !== input) { close(); } 258 }); 259 260 load(); 261})();
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.