PageSourceSearch

https://worldswave.com/themes/wowonder/javascript/ww-chat-commands.js?v=1789458101

js worldswave.com collected 2026-10-02 15:33:59 UTC 19,611 bytes, 474 lines download raw bytes

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.