Skip to content

Add SmartTEAM Live extension#2377

Open
marianobat wants to merge 1 commit into
TurboWarp:masterfrom
marianobat:add-smartteam-live
Open

Add SmartTEAM Live extension#2377
marianobat wants to merge 1 commit into
TurboWarp:masterfrom
marianobat:add-smartteam-live

Conversation

@marianobat
Copy link
Copy Markdown

@marianobat marianobat commented Jan 2, 2026

  • Adds “SmartTEAM Live”, an unsandboxed extension that subscribes to a room-based WebSocket and exposes blocks for connected status, room, last label (currently gestures), confidence, and subscriber count.
  • Security: read-only subscriber connection (no token support), no eval/new Function, no VM internals access, no third-party fetches; only WebSocket connection. wsBase override accepted only for ws:/wss: and connections are gated with Scratch.canFetch.
  • Test: npm ci && npm run dev, then open https://turbowarp.org/editor?extension=http://localhost:8000/marianobat/live.js&room=ST-XXXX.

@github-actions github-actions Bot added the pr: new extension Pull requests that add a new extension label Jan 2, 2026
@marianobat marianobat changed the title Agregar extensión SmartTEAM Live para señales AI en tiempo real Add SmartTEAM Live extension Jan 2, 2026
@Brackets-Coder
Copy link
Copy Markdown
Contributor

Is it just me or do the comments seem oddly AI generated

@unknown07724
Copy link
Copy Markdown

Is it just me or do the comments seem oddly AI generated

yeah, like what is the point of using /* *\ for comments, even if they are one line long

@Seigh-sword
Copy link
Copy Markdown

I looked in the code, and

"// Name: SmartTEAM Live
// ID: smartteamlive
// Description: Read real-time AI signals from a SmartTEAM WebSocket room (currently gestures).
// By: marianobat <https://scratch.mit.edu/users/marianobat/>
// License: MPL-2.0
// Manual testing:
// https://turbowarp.org/editor?extension=http://localhost:8000/marianobat/live.js&room=ST-XXXX&wsBase=wss://smartteam-gesture-bridge.marianobat.workers.dev/ws


(function (Scratch) {
  "use strict";


  if (!Scratch) return;


  const DEFAULT_WS_BASE =
    "wss://smartteam-gesture-bridge.marianobat.workers.dev/ws";


  // Backoff sequence (ms). Cap is handled by last value.
  const BACKOFF_MS = [1000, 2000, 3000, 5000];


  /**
   * Read a querystring param from current page.
   * TurboWarp editor runs in a browser page with window.location.search.
   */
  function getQueryParam(name) {
    try {
      const params = new URLSearchParams(window.location.search || "");
      const v = params.get(name);
      return v == null ? "" : String(v);
    } catch (e) {
      return "";
    }
  }


  /**
   * Validate wsBase override:
   * Only allow ws:// or wss://. If invalid, return default.
   */
  function normalizeWsBase(maybeWsBase) {
    const raw = (maybeWsBase || "").trim();
    if (!raw) return DEFAULT_WS_BASE;
    try {
      const u = new URL(raw);
      if (u.protocol === "ws:" || u.protocol === "wss:") return raw;
      return DEFAULT_WS_BASE;
    } catch (e) {
      return DEFAULT_WS_BASE;
    }
  }


  /**
   * Normalize room string:
   * - Trim
   * - Keep as-is otherwise (backend expects ST-XXXXXXX style)
   */
  function normalizeRoom(room) {
    return String(room || "").trim();
  }


  /**
   * Safe parse JSON. Returns null on failure.
   */
  function safeJsonParse(text) {
    try {
      return JSON.parse(text);
    } catch (e) {
      return null;
    }
  }


  /**
   * Coerce confidence to a finite number.
   */
  function toFiniteNumber(x, fallback) {
    const n = Number(x);
    return Number.isFinite(n) ? n : fallback;
  }


  /**
   * Round to 2 decimals for reporting.
   */
  function round2(n) {
    // Avoid showing -0
    const r = Math.round(n * 100) / 100;
    return Object.is(r, -0) ? 0 : r;
  }


  class SmartteamGesturesExtension {
    constructor() {
      // Internal state
      this._connected = false;
      this._room = "";
      this._wsBase = normalizeWsBase(getQueryParam("wsBase"));
      this._gesture = "";
      this._confidence = 0;
      this._subscribers = 0;


      // WebSocket + reconnect handling
      this._ws = null;
      this._shouldReconnect = false;
      this._reconnectTimer = null;
      this._backoffIndex = 0;


      // Auto-connect from URL (?room=ST-...)
      const urlRoom = normalizeRoom(getQueryParam("room"));
      if (urlRoom) {
        this.setRoomInternal(urlRoom, /*auto*/ true);
      } else {
        // Protection: if room empty, do not connect.
        this._connected = false;
      }
    }


    getInfo() {
      return {
        id: "smartteamlive",
        name: Scratch.translate("SmartTEAM Live"),
        blocks: [
          {
            opcode: "getRoom",
            blockType: Scratch.BlockType.REPORTER,
            text: Scratch.translate("room"),
          },
          {
            opcode: "isConnected",
            blockType: Scratch.BlockType.BOOLEAN,
            text: Scratch.translate("connected?"),
          },
          {
            opcode: "getGesture",
            blockType: Scratch.BlockType.REPORTER,
            text: Scratch.translate("gesture"),
          },
          {
            opcode: "getConfidence",
            blockType: Scratch.BlockType.REPORTER,
            text: Scratch.translate("confidence"),
          },
          {
            opcode: "getSubscribers",
            blockType: Scratch.BlockType.REPORTER,
            text: Scratch.translate("subscribers"),
          },
          "---",
          {
            opcode: "setRoom",
            blockType: Scratch.BlockType.COMMAND,
            text: Scratch.translate("set room to [ROOM]"),
            arguments: {
              ROOM: {
                type: Scratch.ArgumentType.STRING,
                defaultValue: Scratch.translate("ST-XXXXXXX"),
              },
            },
          },
          {
            opcode: "reconnect",
            blockType: Scratch.BlockType.COMMAND,
            text: Scratch.translate("reconnect"),
          },
          {
            opcode: "disconnect",
            blockType: Scratch.BlockType.COMMAND,
            text: Scratch.translate("disconnect"),
          },
        ],
      };
    }


    // --- Reporters/Booleans ---


    getRoom() {
      return this._room;
    }


    isConnected() {
      return !!this._connected;
    }


    getGesture() {
      return this._gesture || "";
    }


    getConfidence() {
      return round2(toFiniteNumber(this._confidence, 0));
    }


    getSubscribers() {
      return toFiniteNumber(this._subscribers, 0);
    }


    // --- Commands ---


    setRoom(args) {
      const room = normalizeRoom(args.ROOM);
      this.setRoomInternal(room, /*auto*/ false);
    }


    reconnect() {
      // Explicit reconnect: close existing and attempt again using current room.
      if (!this._room) {
        this._connected = false;
        return;
      }
      this._backoffIndex = 0;
      this._shouldReconnect = true;
      this._clearReconnectTimer();
      this._closeWs("manual reconnect");
      this._openWs();
    }


    disconnect() {
      // Explicit disconnect: stop reconnecting and close socket.
      this._shouldReconnect = false;
      this._clearReconnectTimer();
      this._closeWs("manual disconnect");
      this._connected = false;
    }


    // --- Internal logic ---


    setRoomInternal(room, auto) {
      // Protection: if empty, do not connect.
      if (!room) {
        this._room = "";
        this._connected = false;


        // If user sets room empty manually, treat as disconnect.
        if (!auto) {
          this._shouldReconnect = false;
          this._clearReconnectTimer();
          this._closeWs("room cleared");
        }
        return;
      }


      // If unchanged, do nothing.
      if (room === this._room && this._ws) return;


      this._room = room;


      // Reset gesture values when room changes (avoid misleading stale data).
      this._gesture = "";
      this._confidence = 0;
      this._subscribers = 0;


      // (Re)connect
      this._backoffIndex = 0;
      this._shouldReconnect = true;
      this._clearReconnectTimer();
      this._closeWs("room changed");
      this._openWs();
    }


    _buildWsUrl() {
      // wsBase is expected to be like wss://.../ws
      const base = this._wsBase || DEFAULT_WS_BASE;


      // Build URL with ?room=... (no token, subscriber read-only)
      const u = new URL(base);
      u.searchParams.set("room", this._room);
      return u.toString();
    }


    _openWs() {
      void this._openWsAsync();
    }


    async _openWsAsync() {
      if (!this._room) {
        this._connected = false;
        return;
      }


      // Only allow reconnect attempts if explicitly enabled.
      if (!this._shouldReconnect) {
        this._connected = false;
        return;
      }


      let wsUrl = "";
      try {
        wsUrl = this._buildWsUrl();
      } catch (e) {
        // If URL building fails, fall back to default base and retry once.
        this._wsBase = DEFAULT_WS_BASE;
        try {
          wsUrl = this._buildWsUrl();
        } catch (e2) {
          this._connected = false;
          this._scheduleReconnect();
          return;
        }
      }


      let allowed = false;
      try {
        allowed = await Scratch.canFetch(wsUrl);
      } catch (e) {
        this._connected = false;
        this._scheduleReconnect();
        return;
      }


      if (!allowed) {
        this._connected = false;
        this._scheduleReconnect();
        return;
      }


      try {
        // eslint-disable-next-line extension/check-can-fetch
        const ws = new WebSocket(wsUrl);
        this._ws = ws;


        ws.onopen = () => {
          this._connected = true;
          this._backoffIndex = 0; // reset backoff after successful open
        };


        ws.onmessage = (evt) => {
          // Parse and update internal state
          const msg = safeJsonParse(evt.data);
          if (!msg || typeof msg !== "object") return;


          // Ignore everything except gesture & presence
          if (msg.type === "gesture") {
            // Accept missing fields (room/seq/ts). Normalize what we use.
            const label = typeof msg.label === "string" ? msg.label : "";
            const conf = toFiniteNumber(msg.confidence, 0);


            this._gesture = label;
            this._confidence = conf;
          } else if (msg.type === "presence") {
            const subs = toFiniteNumber(msg.subscribers, this._subscribers);
            this._subscribers = subs;
          }
        };


        ws.onerror = () => {
          // Some browsers also call onclose; we handle robustly there.
          this._connected = false;
        };


        ws.onclose = () => {
          this._connected = false;
          this._ws = null;
          this._scheduleReconnect();
        };
      } catch (e) {
        this._connected = false;
        this._ws = null;
        this._scheduleReconnect();
      }
    }


    _closeWs(reason) {
      // Close without triggering reconnect logic beyond existing schedule,
      // but onclose handler will still run; we guard with _shouldReconnect.
      const ws = this._ws;
      this._ws = null;


      if (ws) {
        try {
          ws.onopen = null;
          ws.onmessage = null;
          ws.onerror = null;
          ws.onclose = null;
          ws.close(1000, reason || "close");
        } catch (e) {
          // ignore
        }
      }
    }


    _scheduleReconnect() {
      if (!this._shouldReconnect) return;
      if (!this._room) return;


      // If already scheduled, don't schedule another.
      if (this._reconnectTimer) return;


      const idx = Math.min(this._backoffIndex, BACKOFF_MS.length - 1);
      const delay = BACKOFF_MS[idx];


      // Increase backoff for next time (capped).
      this._backoffIndex = Math.min(
        this._backoffIndex + 1,
        BACKOFF_MS.length - 1
      );


      this._reconnectTimer = setTimeout(() => {
        this._reconnectTimer = null;
        // If a socket appeared in the meantime or reconnect disabled, skip.
        if (this._ws) return;
        if (!this._shouldReconnect) return;
        if (!this._room) return;
        this._openWs();
      }, delay);
    }


    _clearReconnectTimer() {
      if (this._reconnectTimer) {
        try {
          clearTimeout(this._reconnectTimer);
        } catch (e) {
          // ignore
        }
        this._reconnectTimer = null;
      }
    }
  }


  Scratch.extensions.register(new SmartteamGesturesExtension());
})(Scratch);"

who even makes comments like this,

** // --- Commands --- **
ok... like this is not human code

@ScratchFakemon
Copy link
Copy Markdown
Contributor

@marianobat address the allegations, it seems like you're being accused of using AI in your code

/hj

@Brackets-Coder
Copy link
Copy Markdown
Contributor

@GarboMuffin or @CubesterYT what do we think about this PR?

@kanebuilt
Copy link
Copy Markdown

@marianobat (Trying to be an aid here...) Due to recent policy changes (see the new contributor guidelines), you need to disclose to reviewers if your extension is AI-generated.

@Brackets-Coder
Copy link
Copy Markdown
Contributor

Brackets-Coder commented May 27, 2026

To be honest I don't think this is going to be merged

@marianobat how does this extension differ from @CubesterYT's already-existing WebSocket extension?

@marianobat
Copy link
Copy Markdown
Author

marianobat commented May 27, 2026 via email

@Brackets-Coder
Copy link
Copy Markdown
Contributor

I'm sorry, it was an error on my side. It wasn't intended to be merged. Please ignore it. My apologies

On Wed, 27 May 2026 at 16:31 Brackets-Coder @.> wrote: Brackets-Coder left a comment (TurboWarp/extensions#2377) <#2377 (comment)> To be honest I don't think this is going to be merged @marianobat https://github.com/marianobat how does this extension differ from @CubesterYT https://github.com/CubesterYT's already-existing WebSocket extension? — Reply to this email directly, view it on GitHub <#2377?email_source=notifications&email_token=ALYDCIJ33IYYTZEQJ3OFW6D4447AVA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTINJVG44TQMBZGMY2M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-4557980931>, or unsubscribe https://github.com/notifications/unsubscribe-auth/ALYDCIISWKVAWP6DOOUM4MT4447AVAVCNFSM6AAAAACQPY5XU2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DKNJXHE4DAOJTGE . You are receiving this because you were mentioned.Message ID: @.>

It's not your fault and this hasn't been merged yet. Don't worry

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr: new extension Pull requests that add a new extension

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants