diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4cbaf71..341e41f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x, 23.x, 25.x] + node-version: [22.x, 24.x, 26.x] steps: - name: Checkout code diff --git a/README.md b/README.md index 7c98a35..ab8b676 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ After starting the container, access the setup wizard at: ๐ŸŽ›๏ธ **[Soundcraft Ui24R Integration](docs/SOUNDCRAFT.md)** - Control mixer volume directly from Slack/Discord +๐Ÿ”’ **[Security & dependency notes](docs/SECURITY.md)** - Overrides, vulnerabilities, and known npm audit false positives + ### ๐ŸŽฎ Discord Setup **Create your Discord bot:** diff --git a/docker/Dockerfile b/docker/Dockerfile index 39c9a32..0389109 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,11 +1,11 @@ # Use the official Node.js image based on Debian Slim # Note: ARM v7 requires Node 22 or lower, other platforms can use Node 25 ARG TARGETPLATFORM -FROM --platform=$TARGETPLATFORM node:22-slim AS base +FROM --platform=$TARGETPLATFORM node:24-slim AS base # System deps needed for native builds (e.g., bcrypt) and git optional deps RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 build-essential && \ + apt-get install -y --no-install-recommends python3 build-essential git && \ rm -rf /var/lib/apt/lists/* && \ npm cache clean --force diff --git a/docker/Dockerfile-local b/docker/Dockerfile-local index e3b4685..f4e1926 100644 --- a/docker/Dockerfile-local +++ b/docker/Dockerfile-local @@ -1,10 +1,11 @@ # Use the official Node.js image based on Alpine Linux # The --platform flag is used here to make sure we use a multi-platform base image -FROM --platform=$BUILDPLATFORM node:25.1-slim AS base +FROM --platform=$BUILDPLATFORM node:26-slim AS base -# Update and install git (if needed for your application) -#RUN apk update && \ -# apk upgrade +# System deps needed by startup diagnostics and optional dependencies. +RUN apt-get update && \ + apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* # Clear npm cache to reduce image size and avoid potential issues RUN npm cache clean --force @@ -25,4 +26,4 @@ COPY . . RUN chmod -R 755 /app # Command to run the application -CMD ["node", "index.js"] \ No newline at end of file +CMD ["node", "index.js"] diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..02adeef --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,25 @@ +# Security + +## Dependency vulnerabilities + +We use **npm overrides** in `package.json` to pin security-patched versions of transitive dependencies. No `npm audit fix --force` is used (that would downgrade packages and risk breaking changes). + +### Current overrides + +| Package | Pinned to | Reason | +|--------|-----------|--------| +| `serialize-javascript` | ^7.0.4 | RCE fix (RegExp.flags / Date.prototype.toISOString) | +| `undici` | ^6.23.0 | Unbounded decompression (Content-Encoding) | +| `diff` | ^8.0.3 | DoS in parsePatch/applyPatch | +| `ip` | ^2.0.1 | SSRF fix in `isPublic` | + +### Known npm audit false positive: `ip` (via `sonos`) + +**Alert:** โ€œip SSRF improper categorization in isPublicโ€ (e.g. Dependabot #60) + +- **Cause:** The `sonos` package ([node-sonos](https://github.com/bencevans/node-sonos)) depends on `ip`. +- **Actual state:** We override `ip` to **2.0.1**, which includes the fix. `sonos@1.14.3` also ships with `ip@2.0.1`. +- **Why it still appears:** The advisory is written against โ€œall versions of ip when required by sonosโ€, so npm/Dependabot still report it even though the installed version is patched. +- **Action:** None required. This is a **known false positive**; no extra โ€œforce updateโ€ or downgrade is needed. + +We have **not** run `npm audit fix --force`; that would downgrade e.g. `sonos`, `mocha`, or `discord.js` and could break the app. Only non-breaking, security-patched overrides are used. diff --git a/index.js b/index.js index 8144c73..8814c1a 100644 --- a/index.js +++ b/index.js @@ -72,10 +72,11 @@ const getReleaseVersion = () => { // 2. Git commit SHA (for native/local development) try { - const sha = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); + const gitExecOptions = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }; + const sha = execSync('git rev-parse --short HEAD', gitExecOptions).trim(); // Try to get tag from git if available try { - const tag = execSync('git describe --tags --exact-match HEAD 2>/dev/null', { encoding: 'utf8' }).trim(); + const tag = execSync('git describe --tags --exact-match HEAD', gitExecOptions).trim(); if (tag) { return tag; // Return exact tag if on tagged commit } @@ -2949,6 +2950,7 @@ const commandRegistry = new Map([ ['configdump', { fn: _configdump, admin: true, aliases: ['cfgdump', 'confdump'] }], ['aiunparsed', { fn: _aiUnparsed, admin: true, aliases: ['aiun', 'aiunknown'] }], ['featurerequest', { fn: _featurerequest, admin: false, aliases: ['feuturerequest'] }], + ['fr', { fn: _featurerequest, admin: false }], ['test', { fn: (args, ch, u) => _addToSpotifyPlaylist(args, ch), admin: true }], ['diagnostics', { fn: _diagnostics, admin: true, aliases: ['diag', 'checksource'] }] ]); diff --git a/lib/add-handlers.js b/lib/add-handlers.js index 6eaae71..6cf5638 100644 --- a/lib/add-handlers.js +++ b/lib/add-handlers.js @@ -7,6 +7,10 @@ */ const queueUtils = require('./queue-utils'); +const { + getFirstQueuedTrackNumber, + playFromQueue +} = require('./sonos-playback'); // ========================================== // DEPENDENCIES (injected via initialize) @@ -163,9 +167,12 @@ async function add(input, channel, userName) { let result = null; try { logger.info(`Attempting to queue: ${firstCandidate.name} by ${firstCandidate.artist} (URI: ${firstCandidate.uri})`); - await sonos.queue(firstCandidate.uri); + const queueResult = await sonos.queue(firstCandidate.uri); logger.info('Successfully queued track: ' + firstCandidate.name); - result = firstCandidate; + result = { + ...firstCandidate, + queuePosition: getFirstQueuedTrackNumber(queueResult, 1) + }; } catch (e) { const errorDetails = e.message || String(e); const upnpErrorMatch = errorDetails.match(/errorCode[>](\d+)[<]/); @@ -212,61 +219,7 @@ async function add(input, channel, userName) { if (state === 'stopped') { (async () => { try { - try { - await sonos.stop(); - await new Promise(resolve => setTimeout(resolve, 500)); - } catch (stopErr) { - logger.debug('Stop before play (may already be stopped): ' + stopErr.message); - } - - // Verify queue has items before trying to play - let queueReady = false; - let retries = 0; - while (!queueReady && retries < 5) { - try { - const q = await sonos.getQueue(); - if (q && q.items && q.items.length > 0) { - queueReady = true; - logger.debug(`Queue verified: ${q.items.length} items ready`); - } else { - logger.debug(`Queue not ready yet (attempt ${retries + 1}/5), waiting...`); - await new Promise(resolve => setTimeout(resolve, 300)); - retries++; - } - } catch (queueErr) { - logger.debug(`Queue check failed (attempt ${retries + 1}/5): ${queueErr.message}`); - await new Promise(resolve => setTimeout(resolve, 300)); - retries++; - } - } - - if (!queueReady) { - logger.warn('Queue not ready after 5 attempts, attempting playback anyway'); - } - - // Try to activate queue by seeking to position 1 - try { - logger.debug('Attempting to seek to queue position 1 to activate queue'); - await sonos.avTransportService().Seek({ - InstanceID: 0, - Unit: 'TRACK_NR', - Target: '1' - }); - logger.debug('Successfully sought to track 1, queue should be active'); - await new Promise(resolve => setTimeout(resolve, 500)); - } catch (seekErr) { - logger.debug('Seek failed, trying next() to activate queue: ' + seekErr.message); - try { - await sonos.next(); - logger.debug('Used next() to activate queue'); - await new Promise(resolve => setTimeout(resolve, 300)); - } catch (nextErr) { - logger.debug('next() also failed: ' + nextErr.message); - } - } - - await new Promise(resolve => setTimeout(resolve, 500)); - await sonos.play(); + await playFromQueue(sonos, logger, { trackNumber: result.queuePosition || 1 }); logger.info('Started playback from queue'); } catch (playErr) { logger.warn('Failed to start playback: ' + playErr.message); @@ -426,19 +379,23 @@ async function queueAlbum(result, albumSearchTerm, channel, userName) { }) ); - await Promise.allSettled(queuePromises); + const queueResults = await Promise.allSettled(queuePromises); + result.queuePosition = getFirstQueuedTrackNumber( + queueResults.find(queueResult => queueResult.status === 'fulfilled' && queueResult.value)?.value, + 1 + ); logger.info(`Added ${allowedTracks.length} tracks from album (filtered ${blacklistedTracks.length})`); } else { - await sonos.queue(result.uri); + const queueResult = await sonos.queue(result.uri); + result.queuePosition = getFirstQueuedTrackNumber(queueResult, 1); logger.info('Added album: ' + result.name); } if (isStopped) { - await new Promise(resolve => setTimeout(resolve, 300)); - await sonos.play(); + await playFromQueue(sonos, logger, { trackNumber: result.queuePosition || 1 }); logger.info('Started playback after album add'); } else if (state !== 'playing' && state !== 'transitioning') { - await sonos.play(); + await playFromQueue(sonos, logger); logger.info('Player was not playing, started playback.'); } } catch (err) { @@ -529,6 +486,8 @@ async function addplaylist(input, channel, userName) { } } + let queuedTrackNumber = null; + // If we have blacklisted tracks, add individually; otherwise use playlist URI if (blacklistedTracks.length > 0) { const allowedTracks = playlistTracks.filter(track => @@ -536,33 +495,27 @@ async function addplaylist(input, channel, userName) { ); for (const track of allowedTracks) { - await sonos.queue(track.uri); + const queueResult = await sonos.queue(track.uri); + queuedTrackNumber = queuedTrackNumber || getFirstQueuedTrackNumber(queueResult, 1); } logger.info(`Added ${allowedTracks.length} tracks from playlist (filtered ${blacklistedTracks.length})`); } else { - await sonos.queue(result.uri); + const queueResult = await sonos.queue(result.uri); + queuedTrackNumber = getFirstQueuedTrackNumber(queueResult, 1); logger.info('Added playlist: ' + result.name); } // Start playback if needed if (isStopped) { try { - try { - await sonos.stop(); - await new Promise(resolve => setTimeout(resolve, 500)); - } catch (stopErr) { - logger.debug('Stop before play (may already be stopped): ' + stopErr.message); - } - - await new Promise(resolve => setTimeout(resolve, 1000)); - await sonos.play(); + await playFromQueue(sonos, logger, { trackNumber: queuedTrackNumber || 1 }); logger.info('Started playback from queue'); } catch (playErr) { logger.warn('Failed to start playback: ' + playErr.message); } } else if (state !== 'playing' && state !== 'transitioning') { try { - await sonos.play(); + await playFromQueue(sonos, logger); logger.info('Player was not playing, started playback.'); } catch (playErr) { logger.warn('Failed to auto-play: ' + playErr.message); @@ -578,23 +531,6 @@ async function addplaylist(input, channel, userName) { logger.info(`Sending playlist confirmation message: ${text}`); sendMessage(text, channel, { trackName: result.name }); - - // Note: Queueing is already done synchronously above (lines 532-545) - // This background task only handles playback if needed - (async () => { - try { - if (isStopped) { - await new Promise(resolve => setTimeout(resolve, 300)); - await sonos.play(); - logger.info('Started playback after playlist add'); - } else if (state !== 'playing' && state !== 'transitioning') { - await sonos.play(); - logger.info('Player was not playing, started playback.'); - } - } catch (err) { - logger.error('Error in background playlist queueing: ' + err.message); - } - })(); } catch (err) { logger.error('Error adding playlist: ' + err.message); sendMessage('๐Ÿ”Ž Couldn\'t find that playlist. Try a Spotify link, or use `searchplaylist ` to pick one. ๐ŸŽต', channel); @@ -645,7 +581,7 @@ async function append(input, channel, userName) { } // Always add to queue (preserving existing tracks) - await sonos.queue(result.uri); + const queueResult = await sonos.queue(result.uri); logger.info('Appended track: ' + result.name); let msg = 'โœ… Added *' + result.name + '* by _' + result.artist + '_ to the queue!'; @@ -656,8 +592,9 @@ async function append(input, channel, userName) { logger.info('Current state after append: ' + state); if (state !== 'playing' && state !== 'transitioning') { - await new Promise(resolve => setTimeout(resolve, 1000)); - await sonos.play(); + await playFromQueue(sonos, logger, { + trackNumber: state === 'stopped' ? getFirstQueuedTrackNumber(queueResult, null) : null + }); logger.info('Started playback after append.'); msg += ' Playback started! :notes:'; } diff --git a/lib/command-handlers.js b/lib/command-handlers.js index 9721e82..b087ee8 100644 --- a/lib/command-handlers.js +++ b/lib/command-handlers.js @@ -7,6 +7,7 @@ */ const queueUtils = require('./queue-utils'); +const { playFromQueue } = require('./sonos-playback'); // ========================================== // DEPENDENCIES (injected via initialize) @@ -78,16 +79,14 @@ function stop(input, channel, userName) { /** * Start playback */ -function play(input, channel, userName) { +async function play(input, channel, userName) { logUserAction(userName, 'play'); - sonos - .play() - .then(() => { - sendMessage('โ–ถ๏ธ Let\'s gooo! Music is flowing! ๐ŸŽถ', channel); - }) - .catch((err) => { - logger.error('Error starting playback: ' + err); - }); + try { + await playFromQueue(sonos, logger); + sendMessage('โ–ถ๏ธ Let\'s gooo! Music is flowing! ๐ŸŽถ', channel); + } catch (err) { + logger.error('Error starting playback: ' + err); + } } /** diff --git a/lib/sonos-playback.js b/lib/sonos-playback.js new file mode 100644 index 0000000..6b19e48 --- /dev/null +++ b/lib/sonos-playback.js @@ -0,0 +1,186 @@ +/** + * Helpers for starting playback from the Sonos queue. + */ + +const DEFAULT_QUEUE_TIMEOUT_MS = 2500; +const DEFAULT_QUEUE_INTERVAL_MS = 300; +const QUEUE_SOURCE_DELAY_MS = 300; +const PLAY_DELAY_MS = 300; + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function toPositiveTrackNumber(value) { + const number = Number.parseInt(value, 10); + return Number.isFinite(number) && number > 0 ? number : null; +} + +function getFirstQueuedTrackNumber(queueResult, fallback = 1) { + return toPositiveTrackNumber(queueResult && queueResult.FirstTrackNumberEnqueued) || + toPositiveTrackNumber(fallback); +} + +function queueHasItems(queue) { + return Boolean( + queue && + ( + (Array.isArray(queue.items) && queue.items.length > 0) || + Number(queue.total) > 0 + ) + ); +} + +async function waitForQueueItems(sonos, logger, options = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS; + const intervalMs = options.intervalMs ?? DEFAULT_QUEUE_INTERVAL_MS; + const deadline = Date.now() + timeoutMs; + let attempt = 1; + + while (Date.now() <= deadline) { + try { + const queue = await sonos.getQueue(); + if (queueHasItems(queue)) { + logger.debug(`Queue verified: ${queue.items ? queue.items.length : queue.total} items ready`); + return queue; + } + + logger.debug(`Queue not ready yet (attempt ${attempt}), waiting...`); + } catch (err) { + logger.debug(`Queue check failed (attempt ${attempt}): ${err.message}`); + } + + attempt++; + await sleep(intervalMs); + } + + return null; +} + +async function getQueueUri(sonos, logger) { + if (typeof sonos.deviceDescription === 'function') { + try { + const deviceInfo = await sonos.deviceDescription(); + const uid = deviceInfo && deviceInfo.UDN && deviceInfo.UDN.replace(/^uuid:/, ''); + if (uid) { + return `x-rincon-queue:${uid}#0`; + } + } catch (err) { + logger.debug('Could not read Sonos device description: ' + err.message); + } + } + + if (typeof sonos.getZoneInfo === 'function') { + const zoneInfo = await sonos.getZoneInfo(); + if (zoneInfo && zoneInfo.MACAddress) { + const port = sonos.port || 1400; + return `x-rincon-queue:RINCON_${zoneInfo.MACAddress.replace(/:/g, '')}0${port}#0`; + } + } + + throw new Error('Could not determine Sonos queue URI'); +} + +function isQueueUri(uri) { + return typeof uri === 'string' && uri.toLowerCase().startsWith('x-rincon-queue:'); +} + +async function ensureQueueSelected(sonos, logger) { + const avTransport = sonos.avTransportService(); + let currentUri = null; + + if (avTransport && typeof avTransport.GetMediaInfo === 'function') { + try { + const mediaInfo = await avTransport.GetMediaInfo(); + currentUri = mediaInfo && mediaInfo.CurrentURI; + if (isQueueUri(currentUri)) { + logger.debug('Sonos queue is already the active transport source'); + return { changed: false, queueUri: currentUri }; + } + } catch (err) { + logger.debug('Could not read current Sonos transport source: ' + err.message); + } + } + + const queueUri = await getQueueUri(sonos, logger); + if (currentUri === queueUri) { + return { changed: false, queueUri }; + } + + logger.debug('Switching Sonos transport source to queue'); + await avTransport.SetAVTransportURI({ + InstanceID: 0, + CurrentURI: queueUri, + CurrentURIMetaData: '' + }); + await sleep(QUEUE_SOURCE_DELAY_MS); + + return { changed: true, queueUri }; +} + +async function selectQueueTrack(sonos, logger, trackNumber) { + const normalizedTrackNumber = toPositiveTrackNumber(trackNumber); + if (!normalizedTrackNumber) { + return false; + } + + try { + if (typeof sonos.selectTrack === 'function') { + await sonos.selectTrack(normalizedTrackNumber); + } else { + await sonos.avTransportService().Seek({ + InstanceID: 0, + Unit: 'TRACK_NR', + Target: String(normalizedTrackNumber) + }); + } + + logger.debug(`Selected Sonos queue track ${normalizedTrackNumber}`); + await sleep(PLAY_DELAY_MS); + return true; + } catch (err) { + logger.debug(`Could not select Sonos queue track ${normalizedTrackNumber}: ${err.message}`); + return false; + } +} + +async function playFromQueue(sonos, logger, options = {}) { + const waitForQueue = options.waitForQueue !== false; + const trackNumber = toPositiveTrackNumber(options.trackNumber); + let queueSelected = false; + + if (waitForQueue) { + const queue = await waitForQueueItems(sonos, logger, { + timeoutMs: options.queueTimeoutMs, + intervalMs: options.queueIntervalMs + }); + + if (!queue) { + logger.warn('Queue not ready before playback attempt, trying playback anyway'); + } + } + + try { + await ensureQueueSelected(sonos, logger); + queueSelected = true; + } catch (err) { + logger.debug('Could not select Sonos queue before playback: ' + err.message); + } + + if (queueSelected && trackNumber) { + await selectQueueTrack(sonos, logger, trackNumber); + } + + if (queueSelected) { + await sleep(PLAY_DELAY_MS); + } + + await sonos.play(); + return true; +} + +module.exports = { + playFromQueue, + ensureQueueSelected, + getFirstQueuedTrackNumber +}; diff --git a/package-lock.json b/package-lock.json index 95d47d1..09da6c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,30 +10,30 @@ "license": "ISC", "dependencies": { "@jsfeb26/urllib-sync": "^1.1.4", - "@sefinek/google-tts-api": "^2.1.11", - "@simplewebauthn/server": "^13.2.2", + "@sefinek/google-tts-api": "^2.1.13", + "@simplewebauthn/server": "^13.3.1", "@slack/rtm-api": "^7.0.4", - "@slack/socket-mode": "^2.0.5", - "@slack/web-api": "^7.12.0", + "@slack/socket-mode": "^2.0.7", + "@slack/web-api": "^7.16.0", "bcrypt": "^6.0.0", - "discord.js": "^14.17.3", - "lodash": "^4.17.23", + "discord.js": "^14.26.4", + "lodash": "^4.18.1", "mp3-duration": "^1.1.0", "nconf": "^0.13.0", - "openai": "^6.16.0", - "posthog-node": "^5.24.9", + "openai": "^6.42.0", + "posthog-node": "^5.36.2", "selfsigned": "^5.5.0", "sonos": "^1.14.2", - "soundcraft-ui-connection": "^4.1.1", + "soundcraft-ui-connection": "^6.0.3", "urlencode": "^2.0.0", "winston": "^3.18.3", "xml2js": "^0.6.2" }, "devDependencies": { - "c8": "^10.1.3", + "c8": "^11.0.0", "chai": "6.2.2", - "mocha": "^11.7.5", - "sinon": "^21.0.1" + "mocha": "^11.7.6", + "sinon": "^22.0.0" }, "engines": { "node": ">=17.0.0" @@ -70,15 +70,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz", - "integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.31", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -115,20 +115,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -149,6 +149,16 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", @@ -301,92 +311,92 @@ } }, "node_modules/@peculiar/asn1-cms": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", - "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", - "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", - "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", - "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", - "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", - "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pfx": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", - "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } @@ -403,9 +413,9 @@ } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", - "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -415,21 +425,21 @@ } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", - "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/x509": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz", - "integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.6.0", @@ -445,7 +455,7 @@ "tsyringe": "^4.10.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=20.0.0" } }, "node_modules/@pkgjs/parseargs": { @@ -460,14 +470,20 @@ } }, "node_modules/@posthog/core": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.22.0.tgz", - "integrity": "sha512-WkmOnq95aAOu6yk6r5LWr5cfXsQdpVbWDCwOxQwxSne8YV6GuZET1ziO5toSQXgrgbdcjrSz2/GopAfiL6iiAA==", + "version": "1.30.8", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.30.8.tgz", + "integrity": "sha512-rRJxn7UjPR5LWgRwicJgHWD7tu3P2IebdWjGJ1xpXkbNqpFyW+SbSDGjhunmmXXl2c59ejOICtnbrwN6njS1lw==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6" + "@posthog/types": "1.380.1" } }, + "node_modules/@posthog/types": { + "version": "1.380.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.380.1.tgz", + "integrity": "sha512-GaeyU1vPxwZvYlSWdpxbLCRPqY2WKUZYUNjBlJHAlaAXbMmCfLgB2cvkwjidr8lhX8nyxINjjvQMiOSSfSSxcg==", + "license": "MIT" + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -502,28 +518,28 @@ } }, "node_modules/@sefinek/google-tts-api": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/@sefinek/google-tts-api/-/google-tts-api-2.1.11.tgz", - "integrity": "sha512-fTpC4t9yxrKRL1tKG4HuoqH6GFVDLrl0AeizpdXzqomI6xliRQhcv82l5OIxqqTpP+R+XnT5djqnxSJfpWgkyQ==", + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@sefinek/google-tts-api/-/google-tts-api-2.1.13.tgz", + "integrity": "sha512-y/u+UsOPdJRdriNFqJwOEjInyHRymp4IwPiZcSyC+qOygAr/3rv2kpBbBcPS0vW/xkLQI3WzDkSWsDs/2R2CWA==", "license": "MIT", "dependencies": { - "axios": "^1.11.0" + "axios": "^1.13.6" } }, "node_modules/@simplewebauthn/server": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz", - "integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==", + "version": "13.3.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz", + "integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==", "license": "MIT", "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", - "@peculiar/asn1-android": "^2.3.10", - "@peculiar/asn1-ecc": "^2.3.8", - "@peculiar/asn1-rsa": "^2.3.8", - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "@peculiar/x509": "^1.13.0" + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" }, "engines": { "node": ">=20.0.0" @@ -540,9 +556,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.0.tgz", - "integrity": "sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -550,9 +566,9 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", - "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -571,12 +587,12 @@ } }, "node_modules/@slack/logger": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", - "integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", "license": "MIT", "dependencies": { - "@types/node": ">=18.0.0" + "@types/node": ">=18" }, "engines": { "node": ">= 18", @@ -604,13 +620,13 @@ } }, "node_modules/@slack/socket-mode": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.5.tgz", - "integrity": "sha512-VaapvmrAifeFLAFaDPfGhEwwunTKsI6bQhYzxRXw7BSujZUae5sANO76WqlVsLXuhVtCVrBWPiS2snAQR2RHJQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", + "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", "license": "MIT", "dependencies": { - "@slack/logger": "^4", - "@slack/web-api": "^7.10.0", + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", "@types/node": ">=18", "@types/ws": "^8", "eventemitter3": "^5", @@ -622,9 +638,9 @@ } }, "node_modules/@slack/types": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.0.tgz", - "integrity": "sha512-PVF6P6nxzDMrzPC8fSCsnwaI+kF8YfEpxf3MqXmdyjyWTYsZQURpkK7WWUWvP5QpH55pB7zyYL9Qem/xSgc5VA==", + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", "license": "MIT", "engines": { "node": ">= 12.13.0", @@ -632,16 +648,16 @@ } }, "node_modules/@slack/web-api": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.14.1.tgz", - "integrity": "sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.16.0.tgz", + "integrity": "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg==", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.0", - "@slack/types": "^2.20.0", - "@types/node": ">=18.0.0", + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", "@types/retry": "0.12.0", - "axios": "^1.13.5", + "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", @@ -673,12 +689,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/retry": { @@ -721,6 +737,18 @@ "node": ">= 16.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -789,14 +817,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -836,9 +865,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -862,9 +891,9 @@ } }, "node_modules/c8": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", - "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", "dev": true, "license": "ISC", "dependencies": { @@ -875,7 +904,7 @@ "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", - "test-exclude": "^7.0.1", + "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -884,7 +913,7 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=18" + "node": "20 || >=22" }, "peerDependencies": { "monocart-coverage-reports": "^2" @@ -948,19 +977,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1157,6 +1173,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1219,9 +1236,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1253,33 +1270,33 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.35", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.35.tgz", - "integrity": "sha512-pI6FKJmkyIhToiDK5f8iok7acugSJDFnr3D2a0m+r91EMSFWCzAAEgUro9Km0AUYQPAUluS6iJaXVKt6+wBF7w==", + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, "node_modules/discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -1396,9 +1413,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -1453,9 +1470,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1564,6 +1581,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -1659,6 +1677,19 @@ "dev": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -1696,9 +1727,9 @@ } }, "node_modules/ip": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", - "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "license": "MIT" }, "node_modules/is-electron": { @@ -1765,6 +1796,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -1792,19 +1824,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -1871,9 +1890,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -1924,9 +1943,9 @@ "license": "ISC" }, "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, "node_modules/make-dir": { @@ -1945,19 +1964,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1998,13 +2004,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2023,19 +2029,19 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", "dev": true, "license": "MIT", "dependencies": { @@ -2069,14 +2075,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/modern-isomorphic-ws": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/modern-isomorphic-ws/-/modern-isomorphic-ws-1.0.5.tgz", - "integrity": "sha512-cgJzdkn1//XyYJsFZkjPYKN21CXeG+KC38Q5uFwTnbUwG3pmmsUDS9a5RRJ6lFnjsnEbUKx+rIXjxX/uCUFYvQ==", - "deprecated": "Starting with version 22.4.0, Node.js includes a WebSocket client API without an experimental flag", + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "license": "MIT", - "peerDependencies": { - "ws": "^8.11.0" + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/mp3-duration": { @@ -2210,9 +2222,9 @@ } }, "node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -2239,13 +2251,10 @@ } }, "node_modules/openai": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.22.0.tgz", - "integrity": "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==", + "version": "6.42.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz", + "integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==", "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" @@ -2408,6 +2417,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2455,22 +2465,33 @@ } }, "node_modules/posthog-node": { - "version": "5.24.15", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.24.15.tgz", - "integrity": "sha512-0QnWVOZAPwEAlp+r3r0jIGfk2IaNYM/2YnEJJhBMJZXs4LpHcTu7mX42l+e95o9xX87YpVuZU0kOkmtQUxgnOA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.36.2.tgz", + "integrity": "sha512-k+URjhZyxR0PJ92JZkYcgyk7+2U+T8r0fnfsQFNkW4GeKcuYH6t13VLzjI+bH4YLSknUuLmDDg4CczGO9nad2Q==", "license": "MIT", "dependencies": { - "@posthog/core": "1.22.0" + "@posthog/core": "1.30.8" }, "engines": { "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pvtsutils": { "version": "1.3.6", @@ -2490,16 +2511,6 @@ "node": ">=16.0.0" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2588,10 +2599,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", - "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", - "license": "BlueOak-1.0.0" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/secure-keys": { "version": "1.0.0", @@ -2613,28 +2627,33 @@ } }, "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, "license": "ISC", "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2647,6 +2666,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2666,50 +2686,26 @@ } }, "node_modules/sinon": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.1.tgz", - "integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.1.0", - "@sinonjs/samsam": "^8.0.3", - "diff": "^8.0.2", - "supports-color": "^7.2.0" + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/sinon/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/sonos": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/sonos/-/sonos-1.14.2.tgz", - "integrity": "sha512-E2haOiusny1mgfZvZxXCKOlnvrzoxdnTFXKhcVKPkpWGN1FYzjHUt9UZxQHzflnt48eVKpwGhX6d6miniNBfSQ==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/sonos/-/sonos-1.14.3.tgz", + "integrity": "sha512-hlVo2yn76yOG+9drxVLz2OKwjMysO98kx5pvb9XiCKCJ32mdmAD85N5Z6wUG4fgifRt2JkDjKoz+hWefVOyLgQ==", "license": "MIT", "dependencies": { "axios": "^1.6.0", @@ -2732,13 +2728,12 @@ } }, "node_modules/soundcraft-ui-connection": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/soundcraft-ui-connection/-/soundcraft-ui-connection-4.1.1.tgz", - "integrity": "sha512-bG+ReU0kGUNClJz8C+Xo6WDBhxAH6a+CBkLT7AZi16wgeascEB16G10mKZnthWUlVuTXE/bXRcLjTV/a6T6Q/Q==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/soundcraft-ui-connection/-/soundcraft-ui-connection-6.0.3.tgz", + "integrity": "sha512-oK6wFdmlKSUVG5voVXNRcBA0iVvnXvcpPQMh2LN/g7ugAX28T5vXN37rkKBgfhdUHgVqFcRxvApZuJtgu0iGJg==", "license": "MIT", - "dependencies": { - "modern-isomorphic-ws": "1.0.5", - "ws": "^8.18.0" + "engines": { + "node": ">=22" } }, "node_modules/stack-trace": { @@ -2833,13 +2828,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -2886,34 +2881,115 @@ } }, "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=8" } }, "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" + "glob": "^13.0.6", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=18" + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-hex": { @@ -2972,18 +3048,18 @@ } }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/urlencode": { @@ -3079,6 +3155,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3102,10 +3179,19 @@ "node": ">=0.10.0" } }, + "node_modules/win-release/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/winston": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", - "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", @@ -3241,9 +3327,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index cb03526..5ddfbb8 100644 --- a/package.json +++ b/package.json @@ -26,35 +26,42 @@ "url": "git@github.com:htilly/SlackONOS.git" }, "devDependencies": { - "c8": "^10.1.3", + "c8": "^11.0.0", "chai": "6.2.2", - "mocha": "^11.7.5", - "sinon": "^21.0.1" + "mocha": "^11.7.6", + "sinon": "^22.0.0" }, "author": "", "license": "ISC", "dependencies": { "@jsfeb26/urllib-sync": "^1.1.4", - "@sefinek/google-tts-api": "^2.1.11", - "@simplewebauthn/server": "^13.2.2", + "@sefinek/google-tts-api": "^2.1.13", + "@simplewebauthn/server": "^13.3.1", "@slack/rtm-api": "^7.0.4", - "@slack/socket-mode": "^2.0.5", - "@slack/web-api": "^7.12.0", + "@slack/socket-mode": "^2.0.7", + "@slack/web-api": "^7.16.0", "bcrypt": "^6.0.0", - "discord.js": "^14.17.3", - "lodash": "^4.17.23", + "discord.js": "^14.26.4", + "lodash": "^4.18.1", "mp3-duration": "^1.1.0", "nconf": "^0.13.0", - "openai": "^6.16.0", - "posthog-node": "^5.24.9", + "openai": "^6.42.0", + "posthog-node": "^5.36.2", "selfsigned": "^5.5.0", "sonos": "^1.14.2", - "soundcraft-ui-connection": "^4.1.1", + "soundcraft-ui-connection": "^6.0.3", "urlencode": "^2.0.0", "winston": "^3.18.3", "xml2js": "^0.6.2" }, "engines": { - "node": ">=17.0.0" + "node": ">=22.0.0" + }, + "overrides": { + "axios": "^1.17.0", + "serialize-javascript": "^7.0.5", + "undici": "^7.0.0", + "diff": "^8.0.3", + "ip": "^2.0.1" } } diff --git a/templates/help/helpText.txt b/templates/help/helpText.txt index 1f9c7ac..2d0f035 100644 --- a/templates/help/helpText.txt +++ b/templates/help/helpText.txt @@ -26,7 +26,7 @@ > `flushvote` - Vote to clear the entire queue. Needs *{{flushVoteLimit}}* votes within *{{voteTimeLimitMinutes}}* min. ๐Ÿ—‘๏ธ *๐Ÿ“ Feedback:* -> `featurerequest ` - Create a GitHub issue for a feature request. โœจ +> `featurerequest` (or `fr`) `` - Wish for what new feature this bot should have!!! โœจ _Tip: You can use Spotify URIs (spotify:track:...) OR paste Spotify links (https://open.spotify.com/...) with add, append, addalbum, and addplaylist commands!_ diff --git a/test/INTEGRATION_TESTING.md b/test/INTEGRATION_TESTING.md index 732f226..d7e7774 100644 --- a/test/INTEGRATION_TESTING.md +++ b/test/INTEGRATION_TESTING.md @@ -134,6 +134,15 @@ npm run test:integration Runs all tests automatically and reports results. +The suite also pings the configured Sonos device in parallel during the run and stores packet loss/latency in `test/timing-log.json`, both overall and per test window. Failed tests print the latest ping samples before the test and the samples that overlapped the test window. The host defaults to `sonosPingHost` in `test/config/test-config.json`, then `sonos` in `config/config.json`. You can override or disable it: + +```bash +SONOS_PING_HOST=192.168.1.50 npm run test:integration +SONOS_PING_INTERVAL_MS=1000 npm run test:integration +SONOS_PING=0 npm run test:integration +SLACK_RESPONSE_GRACE_SECONDS=10 npm run test:integration +``` + ### 2. Interactive Test Helper ```bash diff --git a/test/config/README.md b/test/config/README.md index 8ff4523..75133a2 100644 --- a/test/config/README.md +++ b/test/config/README.md @@ -15,7 +15,10 @@ This directory contains configuration for integration tests. "slackBotToken": "xoxb-YOUR-TEST-BOT-TOKEN", "slackChannel": "C01JS8A0YC9", "slackAdminChannel": "C01J1TBLCA0", - "slackONOSBotId": "U123ABC456" + "slackONOSBotId": "U123ABC456", + "sonosPingHost": "192.168.1.50", + "sonosPingIntervalMs": 2000, + "slackResponseGraceSeconds": 5 } ``` @@ -24,6 +27,9 @@ This directory contains configuration for integration tests. - `slackChannel` - Channel ID for regular tests (e.g., #music) - `slackAdminChannel` - Channel ID for admin command tests (e.g., #music-admin) - `slackONOSBotId` - **Production SlackONOS bot** user ID (U...) - needed for @mention tests +- `sonosPingHost` - Optional Sonos IP/host to ping during the integration suite (defaults to `config/config.json` `sonos`) +- `sonosPingIntervalMs` - Optional ping interval in milliseconds (default: `2000`) +- `slackResponseGraceSeconds` - Extra seconds to wait for slow bot responses before failing a test (default: `5`) ## Using a Separate Test Bot @@ -69,6 +75,15 @@ SLACK_BOT_TOKEN=xoxb-test-bot-token node test/tools/integration-test-helper.mjs Priority: ENV var > test-config.json > main config.json +For the full integration suite, Sonos ping monitoring is enabled automatically when a Sonos host is configured. Failed tests print the latest ping samples before the test and the samples that overlapped the test window. Override it with: + +```bash +SONOS_PING_HOST=192.168.1.50 npm run test:integration +SONOS_PING_INTERVAL_MS=1000 npm run test:integration +SONOS_PING=0 npm run test:integration +SLACK_RESPONSE_GRACE_SECONDS=10 npm run test:integration +``` + ## Configuration Fields | Field | Description | Example | @@ -76,6 +91,9 @@ Priority: ENV var > test-config.json > main config.json | `slackBotToken` | Bot User OAuth Token for test bot | `xoxb-123...` | | `slackChannel` | Default channel ID for tests | `CJ51NPNN4` | | `slackAdminChannel` | Admin channel ID for admin command tests | `C01J1TBLCA0` | +| `sonosPingHost` | Optional Sonos IP/host to monitor during E2E runs | `192.168.1.50` | +| `sonosPingIntervalMs` | Optional ping interval for Sonos monitoring | `2000` | +| `slackResponseGraceSeconds` | Extra response wait before a test is considered failed | `5` | ## Security diff --git a/test/config/test-config.json.example b/test/config/test-config.json.example index b3abacf..0773eeb 100644 --- a/test/config/test-config.json.example +++ b/test/config/test-config.json.example @@ -3,5 +3,8 @@ "slackChannel": "CJ51NPNN4", "slackAdminChannel": "C01J1TBLCA0", "slackONOSBotId": "U123ABC456", + "sonosPingHost": "192.168.1.50", + "sonosPingIntervalMs": 2000, + "slackResponseGraceSeconds": 5, "description": "Test configuration for integration tests. slackONOSBotId is the USER ID of the production SlackONOS bot (for @mention tests)." } diff --git a/test/helpText.mjs b/test/helpText.mjs new file mode 100644 index 0000000..7e9ae54 --- /dev/null +++ b/test/helpText.mjs @@ -0,0 +1,306 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +/** + * Help Text Template Tests (SLAC-9) + * + * Verifies that: + * 1. The featurerequest command description reads exactly: + * "Wish for what new feature this bot should have!!!" + * 2. No other command descriptions were altered as a side effect. + * 3. All expected command sections and entries are present. + * 4. Handlebars-style placeholders used by both Slack and Discord are intact. + * 5. The file can be read without errors (bot can start). + */ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const HELP_TEXT_PATH = join(__dirname, '..', 'templates', 'help', 'helpText.txt'); + +// Read once for all tests +let helpText; +try { + helpText = readFileSync(HELP_TEXT_PATH, 'utf8'); +} catch (err) { + helpText = null; +} + +// โ”€โ”€โ”€ SLAC-9: featurerequest description โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” SLAC-9: featurerequest description', function () { + + it('should be readable without errors', function () { + expect(helpText).to.be.a('string'); + expect(helpText.length).to.be.greaterThan(0); + }); + + it('should contain the exact new featurerequest description text', function () { + expect(helpText).to.include('Wish for what new feature this bot should have!!!'); + }); + + it('should NOT contain the old featurerequest description text', function () { + expect(helpText).to.not.include('Create a GitHub issue for a feature request.'); + }); + + it('should list featurerequest command with its "fr" alias', function () { + expect(helpText).to.match(/`featurerequest`\s*\(or\s*`fr`\)/); + }); + + it('should include the feature description argument placeholder', function () { + expect(helpText).to.include(''); + }); + + it('should have the featurerequest entry in the Feedback section', function () { + const feedbackSectionStart = helpText.indexOf('*๐Ÿ“ Feedback:*'); + expect(feedbackSectionStart).to.be.greaterThan(-1, 'Feedback section header not found'); + + const featureRequestIndex = helpText.indexOf('featurerequest', feedbackSectionStart); + expect(featureRequestIndex).to.be.greaterThan( + feedbackSectionStart, + 'featurerequest entry should appear after the Feedback section header' + ); + }); + + it('should have the new description on the same line as the featurerequest command entry', function () { + const lines = helpText.split('\n'); + const featureRequestLine = lines.find(line => line.includes('featurerequest') && line.includes('fr')); + expect(featureRequestLine).to.be.a('string', 'Could not find the featurerequest command line'); + expect(featureRequestLine).to.include('Wish for what new feature this bot should have!!!'); + }); + + it('should have exactly one featurerequest entry', function () { + const matches = helpText.match(/`featurerequest`/g); + expect(matches).to.not.be.null; + expect(matches.length).to.equal(1); + }); +}); + +// โ”€โ”€โ”€ Section headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” Section headers are intact', function () { + + it('should contain the Music Commands section header', function () { + expect(helpText).to.include('*๐ŸŽต Music Commands:*'); + }); + + it('should contain the Info Commands section header', function () { + expect(helpText).to.include('*โ„น๏ธ Info Commands:*'); + }); + + it('should contain the Voting Commands section header', function () { + expect(helpText).to.include('*๐Ÿ—ณ๏ธ Voting Commands:*'); + }); + + it('should contain the Feedback section header', function () { + expect(helpText).to.include('*๐Ÿ“ Feedback:*'); + }); +}); + +// โ”€โ”€โ”€ Music commands unchanged โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” Music command descriptions are unchanged', function () { + + it('should contain the add command description', function () { + expect(helpText).to.include('`add [track]`'); + expect(helpText).to.include('Add a track (search term, Spotify URI, or link). When stopped, starts a fresh queue.'); + }); + + it('should contain the append command description', function () { + expect(helpText).to.include('`append [track]`'); + expect(helpText).to.include('Add a track (search term, Spotify URI, or link) without clearing the queue.'); + }); + + it('should contain the addalbum command description', function () { + expect(helpText).to.include('`addalbum [album]`'); + expect(helpText).to.include('Add an entire album (search term, Spotify URI, or link) to the queue.'); + }); + + it('should contain the addplaylist command description', function () { + expect(helpText).to.include('`addplaylist [playlist]`'); + expect(helpText).to.include('Add an entire playlist (search term, Spotify URI, or link) to the queue.'); + }); + + it('should contain the search command description with searchLimit placeholder', function () { + expect(helpText).to.include('`search [track]`'); + expect(helpText).to.include('{{searchLimit}}'); + }); + + it('should contain the searchalbum command description', function () { + expect(helpText).to.include('`searchalbum [album]`'); + expect(helpText).to.include('Search for an album on Spotify.'); + }); + + it('should contain the searchplaylist command description', function () { + expect(helpText).to.include('`searchplaylist [playlist]`'); + expect(helpText).to.include('Search for a playlist on Spotify.'); + }); +}); + +// โ”€โ”€โ”€ Info commands unchanged โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” Info command descriptions are unchanged', function () { + + it('should contain the current / wtf command description', function () { + expect(helpText).to.include('`current` (or `wtf`)'); + expect(helpText).to.include("Show what's currently playing."); + }); + + it('should contain the list / ls / playlist command description', function () { + expect(helpText).to.include('`list` (or `ls`, `playlist`)'); + expect(helpText).to.include('Show the entire queue.'); + }); + + it('should contain the upnext command description', function () { + expect(helpText).to.include('`upnext`'); + expect(helpText).to.include('Show the next 5 tracks.'); + }); + + it('should contain the size / count command description', function () { + expect(helpText).to.include('`size` (or `count`)'); + expect(helpText).to.include('Get the number of songs in the queue.'); + }); + + it('should contain the volume command description', function () { + expect(helpText).to.include('`volume`'); + expect(helpText).to.include('Get the current volume level.'); + }); + + it('should contain the status command description', function () { + expect(helpText).to.include('`status`'); + expect(helpText).to.include('Get the current playback status.'); + }); + + it('should contain the bestof command description', function () { + expect(helpText).to.include('`bestof [user]`'); + expect(helpText).to.include('Show the top tracks added by a user.'); + }); +}); + +// โ”€โ”€โ”€ Voting commands unchanged โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” Voting command descriptions are unchanged', function () { + + it('should contain the gong / dong command description with gongLimit placeholder', function () { + expect(helpText).to.include('`gong` (or `dong`)'); + expect(helpText).to.include('Vote to skip the current track. Needs *{{gongLimit}}* votes.'); + }); + + it('should contain the gongcheck command description', function () { + expect(helpText).to.include('`gongcheck`'); + expect(helpText).to.include('Check how many GONG votes are remaining.'); + }); + + it('should contain the voteimmune command description with voteImmuneLimit placeholder', function () { + expect(helpText).to.include('`voteimmune [position]`'); + expect(helpText).to.include('Protect a track from being gonged. Needs *{{voteImmuneLimit}}* votes.'); + }); + + it('should contain the voteimmunecheck command description', function () { + expect(helpText).to.include('`voteimmunecheck`'); + expect(helpText).to.include('Check vote immune status.'); + }); + + it('should contain the vote command description with voteLimit placeholder', function () { + expect(helpText).to.include('`vote [position]`'); + expect(helpText).to.include('Move a track to the top. Needs *{{voteLimit}}* votes.'); + }); + + it('should contain the votecheck command description', function () { + expect(helpText).to.include('`votecheck`'); + expect(helpText).to.include('Check the current vote counts.'); + }); + + it('should contain the flushvote command description with flushVoteLimit and voteTimeLimitMinutes placeholders', function () { + expect(helpText).to.include('`flushvote`'); + expect(helpText).to.include('Vote to clear the entire queue. Needs *{{flushVoteLimit}}* votes within *{{voteTimeLimitMinutes}}* min.'); + }); +}); + +// โ”€โ”€โ”€ Handlebars placeholders (shared Slack + Discord rendering) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” Template placeholders for Slack and Discord are intact', function () { + + it('should contain the {{searchLimit}} placeholder', function () { + expect(helpText).to.include('{{searchLimit}}'); + }); + + it('should contain the {{gongLimit}} placeholder', function () { + expect(helpText).to.include('{{gongLimit}}'); + }); + + it('should contain the {{voteImmuneLimit}} placeholder', function () { + expect(helpText).to.include('{{voteImmuneLimit}}'); + }); + + it('should contain the {{voteLimit}} placeholder', function () { + expect(helpText).to.include('{{voteLimit}}'); + }); + + it('should contain the {{flushVoteLimit}} placeholder', function () { + expect(helpText).to.include('{{flushVoteLimit}}'); + }); + + it('should contain the {{voteTimeLimitMinutes}} placeholder', function () { + expect(helpText).to.include('{{voteTimeLimitMinutes}}'); + }); +}); + +// โ”€โ”€โ”€ Footer / tip lines unchanged โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” Footer and tip lines are unchanged', function () { + + it('should contain the Spotify URI tip line', function () { + expect(helpText).to.include( + 'Tip: You can use Spotify URIs (spotify:track:...) OR paste Spotify links (https://open.spotify.com/...)' + ); + }); + + it('should contain the GitHub link in the footer', function () { + expect(helpText).to.include('https://github.com/htilly/SlackONOS'); + }); + + it('should contain the suggestions/bugs footer line', function () { + expect(helpText).to.include('Suggestions or bugs?'); + }); +}); + +// โ”€โ”€โ”€ Structural / ordering checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('helpText.txt โ€” Section ordering is correct', function () { + + it('should have Music Commands before Info Commands', function () { + const musicIdx = helpText.indexOf('*๐ŸŽต Music Commands:*'); + const infoIdx = helpText.indexOf('*โ„น๏ธ Info Commands:*'); + expect(musicIdx).to.be.greaterThan(-1); + expect(infoIdx).to.be.greaterThan(-1); + expect(musicIdx).to.be.lessThan(infoIdx); + }); + + it('should have Info Commands before Voting Commands', function () { + const infoIdx = helpText.indexOf('*โ„น๏ธ Info Commands:*'); + const votingIdx = helpText.indexOf('*๐Ÿ—ณ๏ธ Voting Commands:*'); + expect(infoIdx).to.be.greaterThan(-1); + expect(votingIdx).to.be.greaterThan(-1); + expect(infoIdx).to.be.lessThan(votingIdx); + }); + + it('should have Voting Commands before Feedback section', function () { + const votingIdx = helpText.indexOf('*๐Ÿ—ณ๏ธ Voting Commands:*'); + const feedbackIdx = helpText.indexOf('*๐Ÿ“ Feedback:*'); + expect(votingIdx).to.be.greaterThan(-1); + expect(feedbackIdx).to.be.greaterThan(-1); + expect(votingIdx).to.be.lessThan(feedbackIdx); + }); + + it('should have the featurerequest entry after the Feedback section header and before the footer tip', function () { + const feedbackIdx = helpText.indexOf('*๐Ÿ“ Feedback:*'); + const featureRequestIdx = helpText.indexOf('featurerequest'); + const tipIdx = helpText.indexOf('_Tip:'); + expect(feedbackIdx).to.be.greaterThan(-1); + expect(featureRequestIdx).to.be.greaterThan(feedbackIdx); + expect(featureRequestIdx).to.be.lessThan(tipIdx); + }); +}); diff --git a/test/sonos-playback.test.mjs b/test/sonos-playback.test.mjs new file mode 100644 index 0000000..658ea45 --- /dev/null +++ b/test/sonos-playback.test.mjs @@ -0,0 +1,89 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +const { + getFirstQueuedTrackNumber, + playFromQueue +} = require('../lib/sonos-playback.js'); + +describe('Sonos Playback Helpers', function() { + let avTransport; + let logger; + let sonos; + + beforeEach(function() { + avTransport = { + GetMediaInfo: sinon.stub().resolves({ CurrentURI: 'x-rincon:external-source' }), + SetAVTransportURI: sinon.stub().resolves(), + Seek: sinon.stub().resolves() + }; + + logger = { + debug: sinon.stub(), + warn: sinon.stub() + }; + + sonos = { + avTransportService: sinon.stub().returns(avTransport), + deviceDescription: sinon.stub().resolves({ UDN: 'uuid:RINCON_00112233445501400' }), + getQueue: sinon.stub().resolves({ + items: [{ title: 'Track 1', artist: 'Artist 1' }], + total: 1 + }), + play: sinon.stub().resolves() + }; + }); + + afterEach(function() { + sinon.restore(); + }); + + it('switches to the Sonos queue before selecting a track and playing', async function() { + await playFromQueue(sonos, logger, { trackNumber: 1 }); + + expect(avTransport.SetAVTransportURI.calledOnce).to.equal(true); + expect(avTransport.SetAVTransportURI.firstCall.args[0]).to.deep.equal({ + InstanceID: 0, + CurrentURI: 'x-rincon-queue:RINCON_00112233445501400#0', + CurrentURIMetaData: '' + }); + expect(avTransport.Seek.calledOnceWith({ + InstanceID: 0, + Unit: 'TRACK_NR', + Target: '1' + })).to.equal(true); + expect(sonos.play.calledOnce).to.equal(true); + expect(avTransport.SetAVTransportURI.calledBefore(avTransport.Seek)).to.equal(true); + expect(avTransport.Seek.calledBefore(sonos.play)).to.equal(true); + }); + + it('does not switch source when the Sonos queue is already active', async function() { + avTransport.GetMediaInfo.resolves({ + CurrentURI: 'x-rincon-queue:RINCON_00112233445501400#0' + }); + + await playFromQueue(sonos, logger, { trackNumber: 1 }); + + expect(avTransport.SetAVTransportURI.called).to.equal(false); + expect(avTransport.Seek.calledOnce).to.equal(true); + expect(sonos.play.calledOnce).to.equal(true); + }); + + it('falls back to plain play if the queue source cannot be selected', async function() { + sonos.avTransportService = sinon.stub().throws(new Error('No AVTransport')); + + await playFromQueue(sonos, logger, { waitForQueue: false, trackNumber: 1 }); + + expect(sonos.play.calledOnce).to.equal(true); + expect(logger.debug.calledWithMatch('Could not select Sonos queue before playback')).to.equal(true); + }); + + it('extracts the first queued track number from Sonos queue results', function() { + expect(getFirstQueuedTrackNumber({ FirstTrackNumberEnqueued: '3' }, 1)).to.equal(3); + expect(getFirstQueuedTrackNumber({}, 2)).to.equal(2); + expect(getFirstQueuedTrackNumber({}, null)).to.equal(null); + }); +}); diff --git a/test/tools/integration-test-suite.mjs b/test/tools/integration-test-suite.mjs index eef9bb2..20be7cf 100644 --- a/test/tools/integration-test-suite.mjs +++ b/test/tools/integration-test-suite.mjs @@ -13,6 +13,7 @@ */ import { WebClient } from '@slack/web-api'; +import { execFile } from 'child_process'; import { readFileSync, writeFileSync, appendFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -25,7 +26,9 @@ const sourceCode = readFileSync(__filename, 'utf8'); // Load test config const testConfigPath = join(__dirname, '../config/test-config.json'); +const mainConfigPath = join(__dirname, '../../config/config.json'); let config = {}; +let mainConfig = {}; try { config = JSON.parse(readFileSync(testConfigPath, 'utf8')); @@ -35,6 +38,12 @@ try { process.exit(1); } +try { + mainConfig = JSON.parse(readFileSync(mainConfigPath, 'utf8')); +} catch (error) { + mainConfig = {}; +} + const slackToken = process.env.SLACK_BOT_TOKEN || config.slackBotToken; if (!slackToken) { console.error('โŒ No Slack bot token found'); @@ -48,6 +57,16 @@ const args = process.argv.slice(2); let channelId = config.slackChannel || 'C01JS8A0YC9'; let adminChannelId = config.slackAdminChannel || 'C01J1TBLCA0'; const slackONOSBotId = config.slackONOSBotId || null; +const sonosPingHost = process.env.SONOS_PING_HOST || config.sonosPingHost || config.sonos || mainConfig.sonos || null; +const sonosPingEnabled = process.env.SONOS_PING !== '0' && process.env.SONOS_PING_DISABLED !== '1' && !!sonosPingHost; +const sonosPingIntervalMs = Math.max( + 1000, + parseInt(process.env.SONOS_PING_INTERVAL_MS || config.sonosPingIntervalMs || '2000', 10) || 2000 +); +const slackResponseGraceSeconds = Math.max( + 0, + parseInt(process.env.SLACK_RESPONSE_GRACE_SECONDS || config.slackResponseGraceSeconds || '5', 10) || 0 +); let verbose = false; for (let i = 0; i < args.length; i++) { @@ -59,6 +78,156 @@ for (let i = 0; i < args.length; i++) { } } +function runPing(host, timeoutMs = 1500) { + const startedAt = Date.now(); + + return new Promise((resolve) => { + execFile('ping', ['-c', '1', '-n', host], { timeout: timeoutMs }, (error, stdout = '', stderr = '') => { + const finishedAt = Date.now(); + const output = `${stdout}\n${stderr}`; + const latencyMatch = output.match(/time[=<]([0-9.]+)\s*ms/i); + const latencyMs = latencyMatch ? Number.parseFloat(latencyMatch[1]) : null; + const timedOut = error && (error.killed || error.signal === 'SIGTERM'); + + resolve({ + timestamp: new Date(startedAt).toISOString(), + startedAtMs: startedAt, + finishedAtMs: finishedAt, + ok: !error && latencyMs !== null, + latencyMs, + durationMs: finishedAt - startedAt, + error: error ? (timedOut ? 'timeout' : error.message) : null + }); + }); + }); +} + +class PingMonitor { + constructor(host, intervalMs) { + this.host = host; + this.intervalMs = intervalMs; + this.samples = []; + this.timer = null; + this.running = false; + this.inFlight = false; + } + + start() { + if (!this.host || this.running) return; + this.running = true; + this._sample(); + this.timer = setInterval(() => this._sample(), this.intervalMs); + } + + async _sample() { + if (!this.running || this.inFlight) return; + this.inFlight = true; + + try { + const sample = await runPing(this.host); + this.samples.push(sample); + if (verbose && !sample.ok) { + console.log(`\nโš ๏ธ Sonos ping failed (${this.host}): ${sample.error || 'no response'}`); + } + } finally { + this.inFlight = false; + } + } + + stop() { + this.running = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + summary(samples = this.samples) { + const total = samples.length; + const success = samples.filter(sample => sample.ok); + const failed = samples.filter(sample => !sample.ok); + const latencies = success + .map(sample => sample.latencyMs) + .filter(latency => Number.isFinite(latency)); + const avgLatencyMs = latencies.length + ? latencies.reduce((sum, latency) => sum + latency, 0) / latencies.length + : null; + + return { + host: this.host, + intervalMs: this.intervalMs, + samples: total, + ok: success.length, + failed: failed.length, + lossPercent: total ? (failed.length / total) * 100 : null, + minLatencyMs: latencies.length ? Math.min(...latencies) : null, + maxLatencyMs: latencies.length ? Math.max(...latencies) : null, + avgLatencyMs, + recentFailures: failed.slice(-5) + }; + } + + summaryForRange(startMs, endMs) { + if (!startMs || !endMs) { + return null; + } + + const samples = this.samplesForRange(startMs, endMs); + + return this.summary(samples); + } + + samplesForRange(startMs, endMs) { + if (!startMs || !endMs) { + return []; + } + + return this.samples.filter(sample => + sample.startedAtMs <= endMs && sample.finishedAtMs >= startMs + ); + } + + samplesAroundRange(startMs, endMs, beforeCount = 5) { + if (!startMs || !endMs) { + return { before: [], during: [] }; + } + + const before = this.samples + .filter(sample => sample.finishedAtMs < startMs) + .slice(-beforeCount); + const during = this.samplesForRange(startMs, endMs); + + return { before, during }; + } +} + +function formatPingValue(value) { + return value === null || value === undefined ? 'n/a' : value.toFixed(1); +} + +function formatRelativeSeconds(ms) { + const seconds = ms / 1000; + const prefix = seconds >= 0 ? '+' : ''; + return `${prefix}${seconds.toFixed(1)}s`; +} + +function formatPingSample(sample, referenceMs) { + const offset = formatRelativeSeconds(sample.startedAtMs - referenceMs); + if (sample.ok) { + return `${offset} ok ${formatPingValue(sample.latencyMs)}ms (${sample.durationMs}ms)`; + } + + return `${offset} ${sample.error || 'failed'} (${sample.durationMs}ms)`; +} + +function formatPingSamples(samples, referenceMs) { + if (!samples || samples.length === 0) { + return 'none'; + } + + return samples.map(sample => formatPingSample(sample, referenceMs)).join(', '); +} + // Get bot user ID let botUserId = null; @@ -123,10 +292,12 @@ async function sendAndWaitForResponse(message, waitTime = 3, targetChannel = nul const sentMessageTs = parseFloat(result.ts); // Poll for responses instead of just waiting - // Check every 200ms for responses, but wait up to waitTime seconds total + // Poll for bot responses. The grace window only matters when the bot is slow. let firstResponseTime = null; const pollInterval = 1000; // Check every 1s to avoid rate limits - const maxWaitTime = waitTime * 1000; // Convert to milliseconds + const baseWaitTime = waitTime * 1000; + const graceWaitTime = slackResponseGraceSeconds * 1000; + const maxWaitTime = baseWaitTime + graceWaitTime; const startTime = Date.now(); let allResponses = []; let seenMessageIds = new Set(); @@ -222,13 +393,18 @@ async function sendAndWaitForResponse(message, waitTime = 3, targetChannel = nul // Calculate timing const totalTime = Date.now() - sendTime; const responseTime = firstResponseTime ? firstResponseTime - sendTime : null; + const graceUsed = graceWaitTime > 0 && totalTime > baseWaitTime; return { responses: allResponses, timing: { totalTime: totalTime, firstResponseTime: responseTime, - responseCount: allResponses.length + responseCount: allResponses.length, + baseWaitTime, + graceWaitTime, + graceUsed, + responseArrivedDuringGrace: responseTime !== null && responseTime > baseWaitTime } }; } catch (error) { @@ -238,7 +414,11 @@ async function sendAndWaitForResponse(message, waitTime = 3, targetChannel = nul timing: { totalTime: Date.now() - sendTime, firstResponseTime: null, - responseCount: 0 + responseCount: 0, + baseWaitTime: waitTime * 1000, + graceWaitTime: slackResponseGraceSeconds * 1000, + graceUsed: false, + responseArrivedDuringGrace: false } }; } @@ -257,6 +437,8 @@ class TestCase { this.error = null; this.responses = []; this.timing = null; // Will store { totalTime, firstResponseTime, responseCount } + this.startedAt = null; + this.endedAt = null; } // Helper to describe what the validator expects @@ -439,6 +621,19 @@ class TestCase { } async run(isRetry = false) { + this.startedAt = Date.now(); + this.endedAt = null; + this.passed = false; + this.failed = false; + this.error = null; + this.responses = []; + this.timing = null; + + const finish = (result) => { + this.endedAt = Date.now(); + return result; + }; + if (verbose) console.log(`\n๐Ÿงช Running: ${this.name}${isRetry ? ' (RETRY)' : ''}`); if (verbose) console.log(` Command: "${this.command}"`); if (verbose && this.targetChannel) console.log(` Channel: ${this.targetChannel === adminChannelId ? 'Admin' : 'Standard'}`); @@ -466,7 +661,7 @@ class TestCase { this.failed = true; this.error = 'No response from bot'; if (verbose) console.log(` โŒ Failed: ${this.error}`); - return false; + return finish(false); } try { @@ -474,18 +669,18 @@ class TestCase { if (validationResult === true) { this.passed = true; if (verbose) console.log(` โœ… Validation passed`); - return true; + return finish(true); } else { this.failed = true; this.error = validationResult || 'Validation failed'; if (verbose) console.log(` โŒ Validation failed: ${this.error}`); - return false; + return finish(false); } } catch (error) { this.failed = true; this.error = error.message; if (verbose) console.log(` โŒ Exception: ${this.error}`); - return false; + return finish(false); } } } @@ -1684,6 +1879,14 @@ async function runTestSuite() { // Get bot user ID const testBotId = await getBotUserId(); console.log(`๐Ÿค– TestBot ID: ${testBotId}\n`); + console.log(`โณ Slack response grace: ${slackResponseGraceSeconds.toFixed(1)}s`); + + const pingMonitor = sonosPingEnabled ? new PingMonitor(sonosPingHost, sonosPingIntervalMs) : null; + if (pingMonitor) { + console.log(`๐Ÿ“ก Sonos ping monitor: ${sonosPingHost} every ${(sonosPingIntervalMs / 1000).toFixed(1)}s`); + } else { + console.log('๐Ÿ“ก Sonos ping monitor: disabled (no Sonos host configured)'); + } // Assign testSuite so TestCase instances can access it testSuite = testSuiteArray; @@ -1710,9 +1913,21 @@ async function runTestSuite() { timestamp: new Date().toISOString(), channel: channelId, botId: testBotId, + sonosPing: { + enabled: !!pingMonitor, + host: sonosPingHost, + intervalMs: pingMonitor ? sonosPingIntervalMs : null, + summary: null, + samples: [] + }, + slackResponseGraceSeconds, tests: [] }; + if (pingMonitor) { + pingMonitor.start(); + } + const startTime = Date.now(); console.log('โ”€'.repeat(60)); @@ -1739,18 +1954,30 @@ async function runTestSuite() { retried = true; } + const testPingSummary = pingMonitor ? pingMonitor.summaryForRange(test.startedAt, test.endedAt) : null; + const testPingSamples = pingMonitor ? pingMonitor.samplesAroundRange(test.startedAt, test.endedAt) : null; + // Log timing data - timingLog.tests.push({ + const testTimingEntry = { name: test.name, command: test.command, channel: test.targetChannel === adminChannelId ? 'admin' : 'standard', passed: finalResult, timing: test.timing || { totalTime: null, firstResponseTime: null, responseCount: 0 }, + startedAt: test.startedAt ? new Date(test.startedAt).toISOString() : null, + endedAt: test.endedAt ? new Date(test.endedAt).toISOString() : null, + sonosPing: testPingSummary, responseCount: test.responses.length, error: test.error || null, retried: retried, retrySucceeded: retried && finalResult - }); + }; + + if (!finalResult && testPingSamples) { + testTimingEntry.sonosPingSamples = testPingSamples; + } + + timingLog.tests.push(testTimingEntry); if (finalResult) { if (retried) { @@ -1765,6 +1992,18 @@ async function runTestSuite() { console.log(` Still failed after retry`); } console.log(` Error: ${test.error}`); + if (testPingSummary && testPingSummary.samples > 0) { + console.log( + ` Sonos ping during test: ${testPingSummary.ok}/${testPingSummary.samples} ok, ` + + `${formatPingValue(testPingSummary.lossPercent)}% loss, ` + + `avg ${formatPingValue(testPingSummary.avgLatencyMs)}ms, ` + + `max ${formatPingValue(testPingSummary.maxLatencyMs)}ms` + ); + } + if (testPingSamples) { + console.log(` Sonos ping before: ${formatPingSamples(testPingSamples.before, test.startedAt)}`); + console.log(` Sonos ping samples: ${formatPingSamples(testPingSamples.during, test.startedAt)}`); + } if (verbose) { console.log(` Expected: ${test.getExpectedDescription()}`); if (test.responses.length > 0) { @@ -1785,6 +2024,9 @@ async function runTestSuite() { // ABORT EARLY if pre-flight checks fail - bot needs restart if (test.name.startsWith('Pre-flight:')) { + if (pingMonitor) { + pingMonitor.stop(); + } console.log('\n' + 'โ•'.repeat(60)); console.log('๐Ÿ›‘ PRE-FLIGHT CHECK FAILED - ABORTING TEST SUITE'); console.log(''); @@ -1803,6 +2045,13 @@ async function runTestSuite() { // Delay between tests to avoid rate limits and allow bot to process await new Promise(resolve => setTimeout(resolve, 3000)); } + + if (pingMonitor) { + pingMonitor.stop(); + const pingSummary = pingMonitor.summary(); + timingLog.sonosPing.summary = pingSummary; + timingLog.sonosPing.samples = pingMonitor.samples; + } // Calculate total time as sum of all test timings (excluding delays) const totalTime = timingLog.tests.reduce((sum, test) => { @@ -1832,6 +2081,13 @@ async function runTestSuite() { console.log(` ๐Ÿ”„ Retried: ${retriedTests} test(s) (${retrySucceeded} succeeded after retry)`); } console.log(` โฑ๏ธ Total Test Time: ${(totalTime / 1000).toFixed(2)}s (wall clock: ${(wallClockTime / 1000).toFixed(2)}s)`); + if (timingLog.sonosPing.summary) { + const ping = timingLog.sonosPing.summary; + console.log(` ๐Ÿ“ก Sonos Ping: ${ping.ok}/${ping.samples} ok, ${formatPingValue(ping.lossPercent)}% loss, avg ${formatPingValue(ping.avgLatencyMs)}ms, max ${formatPingValue(ping.maxLatencyMs)}ms`); + if (ping.failed > 0) { + console.log(` Recent failures: ${ping.recentFailures.map(f => `${f.timestamp} (${f.error || 'no response'})`).join(', ')}`); + } + } // Compare with previous run if available if (previousTimingLog && previousTimingLog.tests) { @@ -1936,6 +2192,16 @@ async function postResultsToAdminChannel(passed, failed, total, totalTime, wallC retryInfo = `\n*Retries:* ${retriedTests} test(s) retried (${retrySucceeded} succeeded)`; } + let pingInfo = ''; + if (timingLog.sonosPing?.summary) { + const ping = timingLog.sonosPing.summary; + pingInfo = + `\n*Sonos Ping:* ${ping.ok}/${ping.samples} ok, ` + + `${formatPingValue(ping.lossPercent)}% loss, ` + + `avg ${formatPingValue(ping.avgLatencyMs)}ms, ` + + `max ${formatPingValue(ping.maxLatencyMs)}ms`; + } + // Build failed tests list if any let failedTestsList = ''; if (failed > 0 && timingLog && timingLog.tests) { @@ -1979,6 +2245,7 @@ async function postResultsToAdminChannel(passed, failed, total, totalTime, wallC `*Success Rate:* ${successRate}%\n` + `*Duration:* ${(totalTime / 1000).toFixed(2)}s (wall clock: ${(wallClockTime / 1000).toFixed(2)}s)` + retryInfo + + pingInfo + timeComparison + failedTestsList;