From 5c780735400193c67f97ce8a6a7625a12838fdd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 06:34:56 +0000 Subject: [PATCH 1/6] Initial plan From 7b5704e0dd96618ca2f7d83da9104255eb4038b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 06:41:45 +0000 Subject: [PATCH 2/6] Add Zoom co-host multi-pin automation userscript and supporting files Co-authored-by: FriskyDevelopments <220081212+FriskyDevelopments@users.noreply.github.com> --- docs/automation-design.md | 192 ++++++++ docs/testing-checklist.md | 127 ++++++ scripts/zoom-host-tools.user.js | 731 ++++++++++++++++++++++++++++++ selectors/zoom-dom-selectors.json | 160 +++++++ 4 files changed, 1210 insertions(+) create mode 100644 docs/automation-design.md create mode 100644 docs/testing-checklist.md create mode 100644 scripts/zoom-host-tools.user.js create mode 100644 selectors/zoom-dom-selectors.json diff --git a/docs/automation-design.md b/docs/automation-design.md new file mode 100644 index 000000000..279d72723 --- /dev/null +++ b/docs/automation-design.md @@ -0,0 +1,192 @@ +# Zoom Host Tools — Automation Design + +## Overview + +`zoom-host-tools.user.js` is a Tampermonkey userscript that automates routine +Host / Co-Host tasks inside the **Zoom Web** client (`*.zoom.us/wc/*` and +`*.zoom.us/j/*`). It runs entirely in the browser — no backend, no external +services, no Zoom API credentials required. + +The script is structured in **three phases**: + +| Phase | Status | Description | +|-------|--------|-------------| +| 1 | **Fully implemented** | Detect raised hands → auto-grant Multi-Pin | +| 2 | Scaffold | After Multi-Pin grant, check camera state; optionally send a reminder | +| 3 | Scaffold | Monitor chat for spam/suspicious links | + +--- + +## Architecture + +``` +zoom-host-tools.user.js +│ +├── CONFIG Global tuning knobs (intervals, retries, debug flag) +├── STATE Runtime state (processed participants set, debug stats) +│ +├── Logging layer log(level, ...args) — respects CONFIG.DEBUG +│ +├── Selector layer SELECTORS map + resolveElement() + resolveAllElements() +│ └── Falls back through candidates array; logs selector mismatches as warnings +│ +├── Participant identity getParticipantId(row) — stable ID, name, or fingerprint +│ +├── Phase 1 — Multi-Pin +│ ├── hasRaisedHand(row) +│ ├── alreadyProcessed(participantId) +│ ├── grantMultiPin(row, participantId) ← async, handles menu open/click/retry +│ └── scanParticipants() ← called every SCAN_INTERVAL_MS +│ +├── Phase 2 scaffold +│ └── checkCameraStatus(row, participantId) ← called after every grant +│ +├── Phase 3 scaffold +│ ├── checkMessageForSpam(text, sender) +│ └── startChatMonitor() ← MutationObserver on chat container +│ +├── Debug panel createDebugPanel() / updateDebugPanel() +│ +└── Entry point waitForZoomReady() → startMainLoop() +``` + +--- + +## DOM Strategy + +### Selector Mapping Layer + +All CSS selectors are defined in two places that must be kept in sync: + +1. **`/selectors/zoom-dom-selectors.json`** — canonical source of truth. Edit + this file to update selectors after a Zoom UI change. The JSON contains + multiple `candidates` per element, plus human-readable fallback notes. + +2. **`SELECTORS` constant in `zoom-host-tools.user.js`** — an inline copy of + the candidate arrays so the userscript works as a single self-contained file. + +When Zoom changes its DOM, update the JSON first, then copy the relevant +`candidates` arrays into the `SELECTORS` constant in the script. + +### Fallback Strategy (per element) + +``` +Priority 1 → data-testid attribute selectors (most stable; Zoom uses these internally) +Priority 2 → explicit aria-label attribute (accessibility labels; change less often) +Priority 3 → class name selectors (liable to change with UI rebuilds) +Priority 4 → text content / aria-label keywords (last resort; language-dependent) +Priority 5 → structural DOM traversal (absolute fallback) +``` + +`resolveElement()` iterates the `candidates` array and returns the first match. +A `[WARN]` log is emitted for any selector that throws (malformed selector) and +a `[DEBUG]` log for every successful match, making selector debugging easy. + +--- + +## Assumptions + +1. **Permissions** — The script assumes the logged-in Zoom user has Host or + Co-Host permissions. It does not verify this; attempting actions without + permissions will simply result in the menu item being absent or greyed out. + +2. **Participant panel must be open** — The script can only scan participants + when the Participants panel is visible. If it is closed, `getParticipantListContainer()` + returns `null` and the scan is skipped silently. + +3. **Hover reveals menu button** — Zoom hides the "…" (More) button until the + participant row is hovered. The script dispatches synthetic `mouseover` / + `mouseenter` / `mousemove` events to reveal it before looking for the button. + This approach may break if Zoom adds a CSP or replaces hover with a click + trigger. + +4. **Single-session state** — `STATE.processedParticipants` is an in-memory + `Set`. It is reset when the page reloads (e.g. the user rejoins the meeting). + This is intentional: a participant who left and rejoin should be re-evaluated. + +5. **Multi-Pin menu item text** — The "Allow to Multi-Pin" text is used as the + final fallback. If Zoom localises this string, `MULTIPIN_TEXT_KEYWORDS` in + the script must be updated. + +6. **Camera detection is uncertain** — Zoom Web does not expose a reliable + DOM-level camera on/off indicator in all versions. Phase 2's camera check is + best-effort; it logs the result but does not act on uncertainty. + +--- + +## Extension Points + +### Adding a new Phase 2 action + +After `grantMultiPin()` calls `checkCameraStatus()`, add your logic inside that +function: + +```js +if (cameraOff) { + // Phase 2: send one-time chat message + await sendChatMessage(participantName, 'Please turn your camera on to use Multi-Pin.'); +} +``` + +Implement `sendChatMessage(target, message)` using the chat input selectors in +`SELECTORS.chatInput` and `SELECTORS.chatSendButton`. + +### Adding a Phase 3 moderation action + +Inside `checkMessageForSpam()`, replace the `// TODO` comment with a call to +your moderation function: + +```js +if (pattern.test(text)) { + log('warn', `Spam detected from "${sender}": ${text}`); + await moderateUser(sender); // e.g. mute, remove, or warn +} +``` + +### Adding new spam patterns + +Edit `CONFIG.SPAM_PATTERNS` at the top of the script. Each entry is a +`RegExp`: + +```js +SPAM_PATTERNS: [ + /https?:\/\//i, + /t\.me\//i, + /yournewthing\.com/i, // <-- add here +], +``` + +### Updating selectors after a Zoom UI change + +1. Open the Zoom Web client in Chrome DevTools. +2. Inspect the affected element. +3. Update the `candidates` array for that element in + `/selectors/zoom-dom-selectors.json`. +4. Copy the updated array into the matching entry in the `SELECTORS` constant + in `zoom-host-tools.user.js`. +5. Reload the script in Tampermonkey and verify in the browser console that the + `[DEBUG] Selector matched` log points at your new selector. + +--- + +## Security Considerations + +- The script runs entirely in the browser under the user's existing Zoom session. +- No credentials are stored or transmitted. +- No external resources are loaded. +- The debug panel uses `innerHTML` with string interpolation; all values + inserted are numeric counters or sanitised strings from the DOM — no user + input is ever inserted raw. +- DOM events dispatched (`mouseover`, `keydown`) are standard synthetic events + and do not exfiltrate data. + +--- + +## Files + +| File | Purpose | +|------|---------| +| `scripts/zoom-host-tools.user.js` | Main Tampermonkey userscript | +| `selectors/zoom-dom-selectors.json` | Selector map and fallback notes | +| `docs/automation-design.md` | This file — architecture and assumptions | +| `docs/testing-checklist.md` | Manual test plan | diff --git a/docs/testing-checklist.md b/docs/testing-checklist.md new file mode 100644 index 000000000..cbcc1f829 --- /dev/null +++ b/docs/testing-checklist.md @@ -0,0 +1,127 @@ +# Zoom Host Tools — Manual Testing Checklist + +Use this checklist to verify that the script is working correctly in a live or +test Zoom Web meeting. All tests assume you are logged in with **Host** or +**Co-Host** permissions. + +--- + +## Prerequisites + +- [ ] Tampermonkey (or Violentmonkey) extension installed in Chrome / Firefox / Edge +- [ ] `zoom-host-tools.user.js` installed in Tampermonkey and enabled +- [ ] A test Zoom Web meeting open at `https://*.zoom.us/wc/*` or `https://*.zoom.us/j/*` +- [ ] At least two participants in the meeting (one as host/co-host, one as a test participant) +- [ ] Browser DevTools console open (F12 → Console) so you can read script logs + +--- + +## 1 — Script Load + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 1.1 | Open a Zoom Web meeting URL | Script matches the `@match` URL patterns | | +| 1.2 | Check browser console | `[ZoomHostTools] [INFO] Waiting for Zoom Web to be ready…` log appears | | +| 1.3 | Wait ~5 seconds after meeting loads | `[ZoomHostTools] [INFO] Zoom Web ready. Starting automation.` log appears | | +| 1.4 | Check bottom-right of page | A small dark debug panel labelled "🔧 ZoomHostTools" appears | | +| 1.5 | Open `about:blank` in a new tab | No script logs appear (URL does not match `@match`) | | + +--- + +## 2 — Raised Hand Detection + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 2.1 | Open the Participants panel in Zoom | Debug panel "Scans" counter increments every ~2.5 seconds | | +| 2.2 | Test participant raises their hand | Console log: `[ZoomHostTools] [INFO] Detected raised hand: name:` | | +| 2.3 | Test participant lowers their hand | On the next scan, no raised-hand log for that participant | | +| 2.4 | Close the Participants panel | No error logs appear; scans continue silently | | + +--- + +## 3 — Multi-Pin Grant (Phase 1 — Core Feature) + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 3.1 | Test participant raises their hand (first time) | Console: `[INFO] Granting multipin for participant: name:` | | +| 3.2 | Same scan | Participant's action menu opens briefly and closes | | +| 3.3 | Same scan | Console: `[INFO] Multi-Pin granted successfully for participant: name:` | | +| 3.4 | Verify in Zoom | Participant now has Multi-Pin permission (visible in Zoom participant list or menu) | | +| 3.5 | Debug panel | "Grants attempted" counter increments by 1 | | + +--- + +## 4 — Deduplication (No Repeated Grants) + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 4.1 | Keep test participant's hand raised after grant | On next scan, console: `[DEBUG] Participant already processed, skipping: name:` | | +| 4.2 | Test participant lowers and re-raises hand | Participant is already in the processed set — no repeated grant | | +| 4.3 | Reload the page and repeat 3.1 | Grant executes again (state was reset on reload — expected) | | + +--- + +## 5 — Error Handling and Selector Failures + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 5.1 | Temporarily change a selector in `SELECTORS` to `"[data-testid='does-not-exist']"` | Console: `[WARN] Failed to find menu button for participant: …` — script does NOT crash | | +| 5.2 | Open a non-Zoom HTTPS page | No logs, no debug panel (URL does not match) | | +| 5.3 | Revoke Co-Host before a grant attempt | Menu opens; "Allow to Multi-Pin" is absent; console: `[WARN] Failed to find Multi-Pin menu item for participant: …` | | +| 5.4 | Use DevTools to delete the participant list container from the DOM | Console: `[DEBUG] Participant list container not found` — script continues to run | | + +--- + +## 6 — Selector Debugging Workflow + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 6.1 | Enable `CONFIG.DEBUG = true` | All `[DEBUG]` logs visible in console | | +| 6.2 | Trigger a participant scan | Console shows `[DEBUG] Selector matched: ""` for each element found | | +| 6.3 | Update a selector to a new value in the script | Console immediately shows the new selector in the "matched" log | | +| 6.4 | View `selectors/zoom-dom-selectors.json` | File contains `candidates` arrays and `fallbackStrategy` notes for every element | | + +--- + +## 7 — Phase 2 Camera Check (Scaffold Verification) + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 7.1 | Grant Multi-Pin to a participant with camera ON | Console: `[INFO] Camera appears ON for participant: …` OR `[DEBUG] Camera status: unable to detect…` | | +| 7.2 | Grant Multi-Pin to a participant with camera OFF | Console: `[INFO] Camera is OFF for participant: …` OR `[DEBUG] Camera status: unable to detect…` | | +| 7.3 | Review Phase 2 TODO comments in script | `checkCameraStatus()` contains clear `// TODO (Phase 2):` comments describing next steps | | + +--- + +## 8 — Phase 3 Chat Monitoring (Scaffold Verification) + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 8.1 | Send a normal chat message | No warning log | | +| 8.2 | Send a chat message containing `https://example.com` | Console: `[WARN] Potential spam detected from "…": …` | | +| 8.3 | Send a message containing `t.me/something` | Console: `[WARN] Potential spam detected from "…": …` | | +| 8.4 | Send a message containing `discord.gg/invite` | Console: `[WARN] Potential spam detected from "…": …` | | +| 8.5 | Review Phase 3 TODO comments in script | `checkMessageForSpam()` has clear `// TODO (Phase 3):` comments for future moderation actions | | + +--- + +## 9 — Extension and Maintainability + +| # | Test | Expected Result | Pass/Fail | +|---|------|-----------------|-----------| +| 9.1 | Read `docs/automation-design.md` | Architecture, assumptions, and extension points are clearly documented | | +| 9.2 | Find all `// TODO` comments in the script | Each TODO belongs to a clearly labelled Phase (2 or 3) | | +| 9.3 | Identify where to add a new spam pattern | `CONFIG.SPAM_PATTERNS` array at the top of the script — one line to add | | +| 9.4 | Identify where to update a broken selector | `SELECTORS` constant in the script and matching entry in `selectors/zoom-dom-selectors.json` | | + +--- + +## Notes + +- Zoom Web's DOM structure can change with any Zoom update. If any test in + section 3 fails, compare the live DOM in DevTools against the selectors in + `SELECTORS` and update the `candidates` arrays. +- Camera detection (section 7) is explicitly best-effort. Failure to detect + camera state is not a bug — see Phase 2 design notes in `automation-design.md`. +- All Phase 3 chat tests only verify logging; no automated moderation action + should be triggered at this stage. diff --git a/scripts/zoom-host-tools.user.js b/scripts/zoom-host-tools.user.js new file mode 100644 index 000000000..062cdabde --- /dev/null +++ b/scripts/zoom-host-tools.user.js @@ -0,0 +1,731 @@ +// ==UserScript== +// @name Zoom Host Tools – Multi-Pin Auto-Grant +// @namespace https://github.com/FriskyDevelopments/browser +// @version 1.0.0 +// @description Automatically grants Multi-Pin permission to participants who raise their hand in Zoom Web. Requires Host or Co-Host permissions. +// @author FriskyDevelopments +// @match https://*.zoom.us/wc/* +// @match https://*.zoom.us/j/* +// @grant none +// @run-at document-idle +// ==/UserScript== + +(function () { + 'use strict'; + + // ───────────────────────────────────────────────────────────────────────────── + // CONFIG + // Toggle DEBUG to see verbose console output in the browser console. + // ───────────────────────────────────────────────────────────────────────────── + const CONFIG = { + DEBUG: true, // Set false to silence non-critical logs + SCAN_INTERVAL_MS: 2500, // How often to scan for raised hands (ms) + MENU_OPEN_WAIT_MS: 600, // Time to wait after opening a participant menu (ms) + MENU_CLICK_RETRIES: 3, // Max retries when looking for the Multi-Pin menu item + MENU_RETRY_WAIT_MS: 400, // Wait between each retry (ms) + SPAM_PATTERNS: [ // Phase 3 – chat link patterns to flag + /https?:\/\//i, + /t\.me\//i, + /bit\.ly\//i, + /discord\.gg\//i, + ], + ZOOM_READY_TIMEOUT_S: 60, // Max seconds to wait for Zoom Web to be ready + }; + + // ───────────────────────────────────────────────────────────────────────────── + // STATE + // ───────────────────────────────────────────────────────────────────────────── + const STATE = { + // Set of participant identifiers that have already been processed. + // Prevents repeated Multi-Pin grants for the same participant. + processedParticipants: new Set(), + + // Debug panel counters (only used when debug panel is active) + stats: { + scans: 0, + raisedHandsFound: 0, + multipinGrantsAttempted: 0, + lastAction: 'idle', + }, + }; + + // ───────────────────────────────────────────────────────────────────────────── + // LOGGING + // ───────────────────────────────────────────────────────────────────────────── + function log(level, ...args) { + const prefix = '[ZoomHostTools]'; + if (level === 'debug' && !CONFIG.DEBUG) return; + const fn = level === 'warn' ? console.warn : level === 'error' ? console.error : console.log; + fn(prefix, `[${level.toUpperCase()}]`, ...args); + } + + // ───────────────────────────────────────────────────────────────────────────── + // SELECTOR RESOLUTION + // Selectors are loaded from the external JSON at initialisation time so the + // mapping can be updated without changing this script. + // ───────────────────────────────────────────────────────────────────────────── + + // Inline copy of the selector map so the userscript works as a single file. + // To update selectors, edit /selectors/zoom-dom-selectors.json and copy the + // relevant candidate arrays back here. + const SELECTORS = { + participantListContainer: [ + "[data-testid='participant-list']", + '.participants-section-container__participants', + '.participants-section', + "[aria-label='Participant list']", + '.participants-ul', + ], + participantRow: [ + "[data-testid='participant-item']", + '.participants-item', + '.participant-item__container', + 'li.participants-item', + ], + raisedHandIndicator: [ + "[aria-label='Raise Hand']", + "[aria-label='Hand raised']", + '.participants-item__raise-hand', + '.raise-hand-icon', + "[data-testid='raise-hand-icon']", + "svg[aria-label*='hand' i]", + 'span.hand-icon', + ], + participantName: [ + "[data-testid='participant-name']", + '.participants-item__display-name', + '.participant-item__display-name', + '.participants-item__name', + '.participant-name', + ], + participantMenuButton: [ + "[aria-label='More options for participant']", + "[data-testid='participant-more-button']", + '.participants-item__more-btn', + "button[aria-label*='more' i]", + "button[aria-label*='options' i]", + '.participants-item__action-more', + ], + multiPinMenuItem: [ + "[aria-label='Allow to Multi-Pin']", + "[data-testid='allow-multipin']", + ], + cameraStatusIndicator: [ + "[aria-label='Stop Video']", + "[aria-label='Start Video']", + "[data-testid='video-status-icon']", + '.participants-item__video-status', + '.video-icon--off', + '.video-icon--on', + "svg[aria-label*='video' i]", + ], + chatContainer: [ + "[data-testid='chat-message-list']", + '.chat-message__container', + '.chatbox-messages', + '.chat-list', + "[aria-label='Chat message list']", + ], + chatMessageRow: [ + "[data-testid='chat-message']", + '.chat-message', + '.chatbox-message-item', + 'li.chat-message', + ], + chatInput: [ + "[data-testid='chat-input']", + "[aria-label='Type message here']", + '.chat-box__chat-input', + "div[contenteditable='true']", + 'textarea.chat-message-input', + ], + chatSendButton: [ + "[aria-label='Send chat message']", + "[data-testid='chat-send-button']", + 'button.chat-box__send-btn', + "button[aria-label*='send' i]", + ], + chatMessageSender: [ + "[data-testid='chat-message-sender']", + '.chat-message__sender', + '.chatbox-message-sender', + 'span.chat-message-author', + ], + }; + + // Multi-Pin text keywords used as a last-resort fallback when attribute + // selectors fail (Zoom may render menu items as plain text nodes). + const MULTIPIN_TEXT_KEYWORDS = ['Allow to Multi-Pin', 'Multi-Pin', 'multipin']; + + /** + * Resolves the first matching element from a candidates array. + * Tries each selector in order; returns the first hit or null. + * + * @param {string[]} candidates - Array of CSS selectors to try. + * @param {Element|Document} root - DOM root to query against. + * @returns {Element|null} + */ + function resolveElement(candidates, root = document) { + for (const selector of candidates) { + try { + const el = root.querySelector(selector); + if (el) { + log('debug', `Selector matched: "${selector}"`); + return el; + } + } catch (err) { + log('warn', `Selector error for "${selector}":`, err.message); + } + } + return null; + } + + /** + * Resolves all matching elements from a candidates array. + * + * @param {string[]} candidates + * @param {Element|Document} root + * @returns {Element[]} + */ + function resolveAllElements(candidates, root = document) { + for (const selector of candidates) { + try { + const els = Array.from(root.querySelectorAll(selector)); + if (els.length > 0) { + log('debug', `Selector matched ${els.length} element(s): "${selector}"`); + return els; + } + } catch (err) { + log('warn', `Selector error for "${selector}":`, err.message); + } + } + return []; + } + + // ───────────────────────────────────────────────────────────────────────────── + // PARTICIPANT IDENTITY + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Returns the most stable identifier available for a participant row element. + * Priority: data-participant-id > data-id > display name > row fingerprint. + * + * @param {Element} row + * @returns {string} + */ + function getParticipantId(row) { + // Prefer explicit ID attributes injected by Zoom + const explicitId = + row.dataset.participantId || + row.dataset.id || + row.getAttribute('data-participant-id') || + row.getAttribute('data-id'); + if (explicitId) return `id:${explicitId}`; + + // Fall back to display name + const nameEl = resolveElement(SELECTORS.participantName, row); + const name = nameEl ? nameEl.textContent.trim() : ''; + if (name) return `name:${name}`; + + // Last resort: generate a structural fingerprint + const fingerprint = `${row.tagName}:${row.className}:${row.children.length}`; + log('warn', 'Could not find stable participant ID; using DOM fingerprint:', fingerprint); + return `fp:${fingerprint}`; + } + + // ───────────────────────────────────────────────────────────────────────────── + // RAISED HAND DETECTION + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Returns true if the participant row currently shows a raised-hand indicator. + * + * Strategy: + * 1. Try explicit selectors (aria-label, data-testid, class names) + * 2. Fall back to aria-label / text content containing "hand" + * + * @param {Element} row + * @returns {boolean} + */ + function hasRaisedHand(row) { + // Strategy 1 – direct selector match + const bySelector = resolveElement(SELECTORS.raisedHandIndicator, row); + if (bySelector) return true; + + // Strategy 2 – text/aria fallback + const allDescendants = row.querySelectorAll('*'); + for (const el of allDescendants) { + const label = (el.getAttribute('aria-label') || '').toLowerCase(); + const text = (el.textContent || '').toLowerCase().trim(); + if (label.includes('hand') || text.includes('✋') || label.includes('raise')) { + log('debug', 'Raised hand detected via fallback traversal'); + return true; + } + } + + return false; + } + + // ───────────────────────────────────────────────────────────────────────────── + // MULTI-PIN STATUS DETECTION + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Attempts to determine whether a participant already has Multi-Pin enabled. + * + * Because Zoom does not expose a reliable "multipin granted" indicator in the + * participant row, we rely on: + * 1. The processed-participants set (STATE.processedParticipants) – most + * reliable guard against repeat grants. + * 2. Checking the open participant menu for "Remove Multi-Pin" text, which + * would indicate the permission is already granted. + * + * This function only covers the in-memory state check (1). + * The menu check (2) is done inside grantMultiPin() after the menu is open. + * + * @param {string} participantId + * @returns {boolean} + */ + function alreadyProcessed(participantId) { + return STATE.processedParticipants.has(participantId); + } + + // ───────────────────────────────────────────────────────────────────────────── + // MULTI-PIN GRANTING + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Utility: returns a Promise that resolves after `ms` milliseconds. + * + * @param {number} ms + * @returns {Promise} + */ + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Looks for the Multi-Pin menu item inside a currently-open participant menu. + * + * Strategy: + * 1. Try explicit selectors / aria-label + * 2. Iterate all [role=menuitem] elements and match text against keywords + * + * @returns {Element|null} + */ + function findMultiPinMenuItem() { + // Strategy 1 – attribute selectors + const byAttr = resolveElement(SELECTORS.multiPinMenuItem); + if (byAttr) return byAttr; + + // Strategy 2 – text match across all visible menu items + const menuItems = document.querySelectorAll('[role="menuitem"], [role="option"], li.menu-item'); + for (const item of menuItems) { + const text = item.textContent.trim(); + for (const keyword of MULTIPIN_TEXT_KEYWORDS) { + if (text.toLowerCase().includes(keyword.toLowerCase())) { + log('debug', `Multi-Pin menu item found via text match: "${text}"`); + return item; + } + } + } + + return null; + } + + /** + * Checks whether the open menu contains a "Remove Multi-Pin" entry, which + * indicates that Multi-Pin has already been granted. + * + * @returns {boolean} + */ + function menuShowsMultiPinAlreadyGranted() { + const menuItems = document.querySelectorAll('[role="menuitem"], [role="option"], li.menu-item'); + const removeKeywords = ['Remove Multi-Pin', 'Revoke Multi-Pin', 'Disallow Multi-Pin']; + for (const item of menuItems) { + const text = item.textContent.trim(); + for (const keyword of removeKeywords) { + if (text.toLowerCase().includes(keyword.toLowerCase())) { + return true; + } + } + } + return false; + } + + /** + * Attempts to hover over a participant row so that the menu button appears. + * Zoom hides the "More" button until the row is hovered. + * + * @param {Element} row + */ + function hoverRow(row) { + // Dispatch both mouseover and mouseenter to maximise compatibility + ['mouseover', 'mouseenter', 'mousemove'].forEach(type => { + row.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + }); + } + + /** + * Core function: opens the participant action menu and clicks "Allow to + * Multi-Pin". Records the participant in STATE.processedParticipants on + * success to prevent future reprocessing. + * + * @param {Element} row - Participant row element + * @param {string} participantId - Stable participant identifier + * @returns {Promise} + */ + async function grantMultiPin(row, participantId) { + log('info', `Granting multipin for participant: ${participantId}`); + STATE.stats.lastAction = `Granting multipin: ${participantId}`; + STATE.stats.multipinGrantsAttempted++; + updateDebugPanel(); + + // Step 1: hover row to reveal the menu button + hoverRow(row); + await sleep(200); + + // Step 2: find and click the menu button + const menuBtn = resolveElement(SELECTORS.participantMenuButton, row); + if (!menuBtn) { + log('warn', `Failed to find menu button for participant: ${participantId}`); + STATE.stats.lastAction = `Menu button not found: ${participantId}`; + updateDebugPanel(); + return; + } + menuBtn.click(); + await sleep(CONFIG.MENU_OPEN_WAIT_MS); + + // Step 3: check if Multi-Pin is already granted (menu-level check) + if (menuShowsMultiPinAlreadyGranted()) { + log('info', `Participant already has multipin (menu check): ${participantId}`); + STATE.processedParticipants.add(participantId); + STATE.stats.lastAction = `Already has multipin: ${participantId}`; + closeOpenMenu(); + updateDebugPanel(); + return; + } + + // Step 4: retry loop — find the Multi-Pin menu item + let menuItem = null; + for (let attempt = 1; attempt <= CONFIG.MENU_CLICK_RETRIES; attempt++) { + menuItem = findMultiPinMenuItem(); + if (menuItem) break; + log('debug', `Multi-Pin menu item not found (attempt ${attempt}/${CONFIG.MENU_CLICK_RETRIES}); retrying…`); + await sleep(CONFIG.MENU_RETRY_WAIT_MS); + } + + if (!menuItem) { + log('warn', `Failed to find Multi-Pin menu item for participant: ${participantId}`); + STATE.stats.lastAction = `Menu item not found: ${participantId}`; + updateDebugPanel(); + closeOpenMenu(); + return; + } + + // Step 5: click the menu item + menuItem.click(); + log('info', `Multi-Pin granted successfully for participant: ${participantId}`); + STATE.processedParticipants.add(participantId); + STATE.stats.lastAction = `Multipin granted: ${participantId}`; + updateDebugPanel(); + + // Step 6: Phase 2 scaffold — check camera status after granting + await sleep(300); + checkCameraStatus(row, participantId); + } + + /** + * Closes any currently open participant menu by pressing Escape. + * This is a safe fallback that should not harm the page. + */ + function closeOpenMenu() { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + } + + // ───────────────────────────────────────────────────────────────────────────── + // CAMERA STATUS CHECK (Phase 2 – scaffold) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Attempts to detect whether a participant's camera is currently on or off. + * Camera state in Zoom Web is difficult to detect reliably; this function + * uses best-effort DOM inspection and falls back gracefully. + * + * TODO (Phase 2): If camera is off, send the participant a one-time chat + * message: "Please turn your camera on to use Multi-Pin." + * + * @param {Element} row - Participant row element + * @param {string} participantId - Stable participant identifier + */ + function checkCameraStatus(row, participantId) { + const cameraEl = resolveElement(SELECTORS.cameraStatusIndicator, row); + + if (!cameraEl) { + log('debug', `Camera status: unable to detect for participant ${participantId}. Selector mismatch or element not present.`); + // TODO (Phase 2): implement alternative camera detection strategies + return; + } + + const label = (cameraEl.getAttribute('aria-label') || '').toLowerCase(); + const classList = Array.from(cameraEl.classList).join(' ').toLowerCase(); + + // "Start Video" aria-label → camera is currently OFF + const cameraOff = + label.includes('start video') || + classList.includes('video-icon--off') || + classList.includes('video-off'); + + if (cameraOff) { + log('info', `Camera is OFF for participant: ${participantId}`); + // TODO (Phase 2): call sendCameraOnRequest(participantId) here + } else { + log('info', `Camera appears ON for participant: ${participantId}`); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // PARTICIPANT SCANNING + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Locates the participant list container in the DOM. + * Logs a warning and returns null if not found (e.g. panel is closed). + * + * @returns {Element|null} + */ + function getParticipantListContainer() { + const container = resolveElement(SELECTORS.participantListContainer); + if (!container) { + log('debug', 'Participant list container not found — panel may be closed or selectors need updating.'); + } + return container; + } + + /** + * Scans all participant rows and grants Multi-Pin to any participant who has + * their hand raised and has not already been processed. + * + * @returns {Promise} + */ + async function scanParticipants() { + STATE.stats.scans++; + updateDebugPanel(); + + const container = getParticipantListContainer(); + if (!container) return; + + const rows = resolveAllElements(SELECTORS.participantRow, container); + if (rows.length === 0) { + log('debug', 'No participant rows found.'); + return; + } + + log('debug', `Scanning ${rows.length} participant row(s)…`); + + for (const row of rows) { + if (!hasRaisedHand(row)) continue; + + STATE.stats.raisedHandsFound++; + updateDebugPanel(); + + const participantId = getParticipantId(row); + log('info', `Detected raised hand: ${participantId}`); + + if (alreadyProcessed(participantId)) { + log('debug', `Participant already processed, skipping: ${participantId}`); + continue; + } + + // Grant Multi-Pin asynchronously; do not await here so the loop + // continues scanning other rows without blocking. + grantMultiPin(row, participantId).catch(err => { + log('error', `Unexpected error while granting multipin for ${participantId}:`, err); + }); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // CHAT MONITORING (Phase 3 – scaffold) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Checks a chat message string against configured spam patterns. + * Logs any match; no punitive action is taken yet. + * + * TODO (Phase 3): Add moderation hooks here (e.g. remove message, warn user, + * auto-mute, notify host via chat). + * + * @param {string} text - Message text content + * @param {string} sender - Sender display name (if available) + */ + function checkMessageForSpam(text, sender) { + for (const pattern of CONFIG.SPAM_PATTERNS) { + if (pattern.test(text)) { + log('warn', `Potential spam detected from "${sender}": ${text}`); + // TODO (Phase 3): trigger moderation action + return; + } + } + } + + /** + * Attaches a MutationObserver to the chat container to monitor new messages. + * Safe to call even when the chat panel is not yet visible; it will retry + * until the container appears. + */ + function startChatMonitor() { + const tryAttach = () => { + const container = resolveElement(SELECTORS.chatContainer); + if (!container) { + log('debug', 'Chat container not found; will retry when it appears.'); + return false; + } + + log('info', 'Attaching chat MutationObserver.'); + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + // Determine whether this node is a chat message row + const isMessageRow = SELECTORS.chatMessageRow.some(sel => { + try { return node.matches(sel); } catch { return false; } + }); + if (!isMessageRow) continue; + + const text = node.textContent || ''; + // Use the centralised SELECTORS.chatMessageSender candidates + const senderEl = resolveElement(SELECTORS.chatMessageSender, node); + const sender = senderEl ? senderEl.textContent.trim() : 'unknown'; + checkMessageForSpam(text, sender); + } + } + }); + + observer.observe(container, { childList: true, subtree: true }); + return true; + }; + + if (!tryAttach()) { + // Poll until the chat container appears (it may not be open yet) + const interval = setInterval(() => { + if (tryAttach()) clearInterval(interval); + }, 3000); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // DEBUG PANEL (optional UI) + // ───────────────────────────────────────────────────────────────────────────── + + let debugPanel = null; + + /** + * Creates a small floating debug panel in the bottom-right corner of the page. + * Only created when CONFIG.DEBUG is true. + */ + function createDebugPanel() { + if (!CONFIG.DEBUG) return; + + const panel = document.createElement('div'); + panel.id = 'zoom-host-tools-debug'; + + // Inline styles to keep the panel self-contained + Object.assign(panel.style, { + position: 'fixed', + bottom: '16px', + right: '16px', + zIndex: '999999', + background: 'rgba(0,0,0,0.82)', + color: '#e0e0e0', + fontFamily: 'monospace', + fontSize: '12px', + padding: '10px 14px', + borderRadius: '8px', + minWidth: '220px', + lineHeight: '1.6', + pointerEvents: 'none', // Does not block clicks + }); + + panel.innerHTML = '🔧 ZoomHostTools
Loaded ✓
'; + document.body.appendChild(panel); + debugPanel = panel; + log('info', 'Debug panel created.'); + } + + /** + * Refreshes the debug panel content with current stats. + */ + function updateDebugPanel() { + if (!debugPanel) return; + const s = STATE.stats; + debugPanel.innerHTML = ` + 🔧 ZoomHostTools
+ Scans: ${s.scans}
+ Raised hands: ${s.raisedHandsFound}
+ Grants attempted: ${s.multipinGrantsAttempted}
+ Last action: ${s.lastAction} + `.trim(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // MAIN LOOP + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Main polling loop. Scans participants every SCAN_INTERVAL_MS milliseconds. + * Also starts the chat monitor. + */ + function startMainLoop() { + log('info', 'Zoom Host Tools started. Scan interval:', CONFIG.SCAN_INTERVAL_MS, 'ms'); + createDebugPanel(); + startChatMonitor(); + + // Initial scan immediately + scanParticipants(); + + // Then scan on a regular interval + setInterval(() => { + scanParticipants().catch(err => { + log('error', 'Error during participant scan:', err); + }); + }, CONFIG.SCAN_INTERVAL_MS); + } + + // ───────────────────────────────────────────────────────────────────────────── + // ENTRY POINT + // Wait for the page to be sufficiently ready before starting. + // Zoom is a heavy SPA; the DOM we need won't exist at document-idle for + // the very first load, so we wait for the first meaningful participant list + // appearance using a short poll. + // ───────────────────────────────────────────────────────────────────────────── + function waitForZoomReady() { + log('info', 'Waiting for Zoom Web to be ready…'); + const maxWait = CONFIG.ZOOM_READY_TIMEOUT_S; // seconds + let elapsed = 0; + + const check = setInterval(() => { + elapsed++; + // Consider Zoom "ready" when either the participant list OR the meeting + // toolbar appears. These are reliable indicators that the meeting has + // loaded. + const participantListPresent = !!resolveElement(SELECTORS.participantListContainer); + const toolbarPresent = !!document.querySelector( + '[data-testid="meeting-toolbar"], .meeting-toolbar, .footer-toolbar' + ); + + if (participantListPresent || toolbarPresent) { + clearInterval(check); + log('info', 'Zoom Web ready. Starting automation.'); + startMainLoop(); + } else if (elapsed >= maxWait) { + clearInterval(check); + log('warn', 'Zoom Web did not become ready within timeout. Starting anyway.'); + startMainLoop(); + } + }, 1000); + } + + // Kick off + waitForZoomReady(); +})(); diff --git a/selectors/zoom-dom-selectors.json b/selectors/zoom-dom-selectors.json new file mode 100644 index 000000000..e2d551864 --- /dev/null +++ b/selectors/zoom-dom-selectors.json @@ -0,0 +1,160 @@ +{ + "_comment": "Zoom Web DOM selector map. Selectors are listed in priority order inside each 'candidates' array. The automation code iterates them until one matches. Update this file whenever Zoom changes its UI — no JavaScript changes needed.", + + "participantListContainer": { + "description": "The scrollable list that contains all participant rows.", + "candidates": [ + "[data-testid='participant-list']", + ".participants-section-container__participants", + ".participants-section", + "[aria-label='Participant list']", + ".participants-ul" + ], + "fallbackStrategy": "Search for a UL/OL whose children include raised-hand icons." + }, + + "participantRow": { + "description": "A single participant entry inside the participant list.", + "candidates": [ + "[data-testid='participant-item']", + ".participants-item", + ".participant-item__container", + "li.participants-item" + ], + "fallbackStrategy": "Iterate all LI elements inside the participant list container." + }, + + "raisedHandIndicator": { + "description": "Element that is visible when a participant has raised their hand.", + "candidates": [ + "[aria-label='Raise Hand']", + "[aria-label='Hand raised']", + ".participants-item__raise-hand", + ".raise-hand-icon", + "[data-testid='raise-hand-icon']", + "svg[aria-label*='hand' i]", + "span.hand-icon" + ], + "fallbackStrategy": "Search for an element whose text or aria-label contains 'hand' (case-insensitive) within a participant row." + }, + + "participantName": { + "description": "Element holding the display name of a participant.", + "candidates": [ + "[data-testid='participant-name']", + ".participants-item__display-name", + ".participant-item__display-name", + ".participants-item__name", + ".participant-name" + ], + "fallbackStrategy": "First SPAN or P child of the participant row that has non-empty text content." + }, + + "participantMenuButton": { + "description": "The '...' or 'More' button that opens the per-participant action menu.", + "candidates": [ + "[aria-label='More options for participant']", + "[data-testid='participant-more-button']", + ".participants-item__more-btn", + "button[aria-label*='more' i]", + "button[aria-label*='options' i]", + ".participants-item__action-more" + ], + "fallbackStrategy": "Find a BUTTON inside the participant row whose aria-label includes 'more' or '...'." + }, + + "multiPinMenuItem": { + "description": "Menu item inside the participant action menu that grants Multi-Pin permission.", + "candidates": [ + "[aria-label='Allow to Multi-Pin']", + "[data-testid='allow-multipin']", + "li[role='menuitem'] span", + "li[role='menuitem']" + ], + "textMatchKeywords": ["Allow to Multi-Pin", "Multi-Pin", "multipin"], + "fallbackStrategy": "Iterate all menu items and match text content against textMatchKeywords." + }, + + "cameraStatusIndicator": { + "description": "Element inside a participant row that reflects whether their camera is on or off.", + "candidates": [ + "[aria-label='Stop Video']", + "[aria-label='Start Video']", + "[data-testid='video-status-icon']", + ".participants-item__video-status", + ".video-icon--off", + ".video-icon--on", + "svg[aria-label*='video' i]" + ], + "fallbackStrategy": "Look for an element whose aria-label contains 'video' (case-insensitive). Presence of '--off' class or 'Start Video' label implies camera is off.", + "notes": "Camera status detection is uncertain in all Zoom Web versions. Treat as best-effort. See Phase 2 in automation-design.md." + }, + + "chatContainer": { + "description": "The container element that holds all chat messages.", + "candidates": [ + "[data-testid='chat-message-list']", + ".chat-message__container", + ".chatbox-messages", + ".chat-list", + "[aria-label='Chat message list']" + ], + "fallbackStrategy": "Find a DIV whose role is 'log' or which contains child elements with chat message text." + }, + + "chatMessageRow": { + "description": "A single chat message entry.", + "candidates": [ + "[data-testid='chat-message']", + ".chat-message", + ".chatbox-message-item", + "li.chat-message" + ], + "fallbackStrategy": "Iterate all children of the chat container." + }, + + "chatMessageText": { + "description": "The text body of a single chat message row.", + "candidates": [ + "[data-testid='chat-message-text']", + ".chat-message__text", + ".chatbox-message-content", + "span.chat-message-body" + ], + "fallbackStrategy": "textContent of the chat message row." + }, + + "chatInput": { + "description": "The text input / contenteditable area used to type chat messages.", + "candidates": [ + "[data-testid='chat-input']", + "[aria-label='Type message here']", + ".chat-box__chat-input", + "div[contenteditable='true']", + "textarea.chat-message-input" + ], + "fallbackStrategy": "Find the first contenteditable DIV or TEXTAREA inside the chat panel." + }, + + "chatSendButton": { + "description": "Button that sends a chat message.", + "candidates": [ + "[aria-label='Send chat message']", + "[data-testid='chat-send-button']", + "button.chat-box__send-btn", + "button[aria-label*='send' i]" + ], + "fallbackStrategy": "Find a BUTTON near the chat input whose text or aria-label contains 'send'." + }, + + "chatMessageSender": { + "description": "Element inside a chat message row that shows the sender's display name.", + "candidates": [ + "[data-testid='chat-message-sender']", + ".chat-message__sender", + ".chatbox-message-sender", + "span.chat-message-author" + ], + "fallbackStrategy": "Find the first SPAN or element whose class contains 'sender' or 'author' inside the chat message row." + } +} From f2605d834882f92131a6429d4a92a66ff8fe4d04 Mon Sep 17 00:00:00 2001 From: Frisky Developments Date: Fri, 20 Mar 2026 06:58:56 -0600 Subject: [PATCH 3/6] Update zoom-host-tools.user.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/zoom-host-tools.user.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/zoom-host-tools.user.js b/scripts/zoom-host-tools.user.js index 062cdabde..1607f7d8b 100644 --- a/scripts/zoom-host-tools.user.js +++ b/scripts/zoom-host-tools.user.js @@ -61,13 +61,13 @@ // ───────────────────────────────────────────────────────────────────────────── // SELECTOR RESOLUTION - // Selectors are loaded from the external JSON at initialisation time so the - // mapping can be updated without changing this script. + // This userscript embeds an inline selector map so it can run as a single file + // without fetching external resources at runtime. To update selectors, edit + // /selectors/zoom-dom-selectors.json in the repo (or another reference file) + // and manually copy the relevant candidate arrays back into the map below. // ───────────────────────────────────────────────────────────────────────────── - // Inline copy of the selector map so the userscript works as a single file. - // To update selectors, edit /selectors/zoom-dom-selectors.json and copy the - // relevant candidate arrays back here. + // Inline copy of the selector map used by this userscript. const SELECTORS = { participantListContainer: [ "[data-testid='participant-list']", From 3f53263ff0ed2555e0cd17f5e51895b9a3e564ec Mon Sep 17 00:00:00 2001 From: Frisky Developments Date: Fri, 20 Mar 2026 06:59:08 -0600 Subject: [PATCH 4/6] Update zoom-host-tools.user.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/zoom-host-tools.user.js | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/scripts/zoom-host-tools.user.js b/scripts/zoom-host-tools.user.js index 1607f7d8b..3061294ee 100644 --- a/scripts/zoom-host-tools.user.js +++ b/scripts/zoom-host-tools.user.js @@ -222,15 +222,34 @@ row.getAttribute('data-id'); if (explicitId) return `id:${explicitId}`; - // Fall back to display name + // Fall back to display name + a structural fingerprint to avoid collisions const nameEl = resolveElement(SELECTORS.participantName, row); const name = nameEl ? nameEl.textContent.trim() : ''; - if (name) return `name:${name}`; - // Last resort: generate a structural fingerprint - const fingerprint = `${row.tagName}:${row.className}:${row.children.length}`; - log('warn', 'Could not find stable participant ID; using DOM fingerprint:', fingerprint); - return `fp:${fingerprint}`; + // Build a more unique fingerprint using DOM structure and sibling index + const parent = row.parentElement; + let index = -1; + if (parent) { + const siblings = Array.from(parent.children); + index = siblings.indexOf(row); + } + const fingerprint = [ + row.tagName || '', + row.className || '', + String(row.children.length), + index >= 0 ? String(index) : '' + ].join('|'); + + if (name) { + const compositeId = `name:${name}|fp:${fingerprint}`; + log('debug', 'Using composite participant identifier:', compositeId); + return compositeId; + } + + // Last resort: use structural fingerprint only + const fpOnly = `fp:${fingerprint}`; + log('warn', 'Could not find explicit participant ID or name; using DOM fingerprint:', fpOnly); + return fpOnly; } // ───────────────────────────────────────────────────────────────────────────── From 80f85c962f096ff8a5441e1dc87d0620525efaba Mon Sep 17 00:00:00 2001 From: Frisky Developments Date: Fri, 20 Mar 2026 06:59:31 -0600 Subject: [PATCH 5/6] Update zoom-dom-selectors.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- selectors/zoom-dom-selectors.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selectors/zoom-dom-selectors.json b/selectors/zoom-dom-selectors.json index e2d551864..34e05f947 100644 --- a/selectors/zoom-dom-selectors.json +++ b/selectors/zoom-dom-selectors.json @@ -1,5 +1,5 @@ { - "_comment": "Zoom Web DOM selector map. Selectors are listed in priority order inside each 'candidates' array. The automation code iterates them until one matches. Update this file whenever Zoom changes its UI — no JavaScript changes needed.", + "_comment": "Zoom Web DOM selector map. Selectors are listed in priority order inside each 'candidates' array, and the automation code iterates them until one matches. When Zoom changes its UI, update this file and the corresponding inline SELECTORS map in the userscript to keep them in sync; changing this file alone is not sufficient.", "participantListContainer": { "description": "The scrollable list that contains all participant rows.", From 8b25fcac1591bc804bea7a93ff74e3d58b1af831 Mon Sep 17 00:00:00 2001 From: Frisky Developments Date: Fri, 20 Mar 2026 06:59:38 -0600 Subject: [PATCH 6/6] Update automation-design.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/automation-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/automation-design.md b/docs/automation-design.md index 279d72723..0c5920d4e 100644 --- a/docs/automation-design.md +++ b/docs/automation-design.md @@ -174,9 +174,9 @@ SPAM_PATTERNS: [ - The script runs entirely in the browser under the user's existing Zoom session. - No credentials are stored or transmitted. - No external resources are loaded. -- The debug panel uses `innerHTML` with string interpolation; all values - inserted are numeric counters or sanitised strings from the DOM — no user - input is ever inserted raw. +- The debug panel uses `innerHTML` with string interpolation for internal + status messages; values are numeric counters or strings taken from the Zoom + DOM (for example, participant identifiers derived from display names). - DOM events dispatched (`mouseover`, `keydown`) are standard synthetic events and do not exfiltrate data.