PageSourceSearch

https://worldswave.com/themes/wowonder/javascript/ww-chat-wavebot-cmds.js?v=ww4

js worldswave.com collected 2026-10-02 15:34:03 UTC 10,753 bytes, 261 lines download raw bytes

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.