diff --git a/lib/command-handlers.js b/lib/command-handlers.js index 9721e82..87b1265 100644 --- a/lib/command-handlers.js +++ b/lib/command-handlers.js @@ -282,104 +282,30 @@ async function showQueue(channel) { if (state === 'playing' && !isFromQueue) { emptyMsg += '\n⚠️ Note: Currently playing from external source (not queue). Run `stop` to switch to queue.'; } + sendMessage(emptyMsg, channel); return; } - // Build single compact message - let message = ''; - - if (state === 'playing' && track) { - message += `Currently playing: *${track.title}* by _${track.artist}_\n`; - if (track.duration && track.position) { - const remaining = track.duration - track.position; - const remainingMin = Math.floor(remaining / 60); - const remainingSec = Math.floor(remaining % 60); - const durationMin = Math.floor(track.duration / 60); - const durationSec = Math.floor(track.duration % 60); - message += `:stopwatch: ${remainingMin}:${remainingSec.toString().padStart(2, '0')} remaining (${durationMin}:${durationSec.toString().padStart(2, '0')} total)\n`; - } + // Build queue list + let message = `*πŸ“‹ Current Queue* (${result.items.length} tracks):\n`; - if (!isFromQueue) { - message += `⚠️ Source: *External* (not from queue)\n`; - } - } else { - message += `Playback state: *${state}*\n`; - } - - message += `\nTotal tracks in queue: ${result.total}\n====================\n`; - - logger.info(`Total tracks in queue: ${result.total}, items returned: ${result.items.length}`); - if (process.env.DEBUG_QUEUE_ITEMS === 'true' && result.items.length <= 100) { - logger.debug(`Queue items: ${JSON.stringify(result.items.map((item, i) => ({ pos: i, title: item.title, artist: item.artist })))}`); - } else if (result.items.length > 0) { - logger.debug(`Queue sample: first="${result.items[0].title}", last="${result.items[result.items.length - 1].title}"`); - } - if (track) { - logger.debug(`Current track: queuePosition=${track.queuePosition}, title="${track.title}", artist="${track.artist}"`); - } - - const tracks = []; - - result.items.forEach(function (item, i) { - let trackTitle = item.title; - let prefix = ''; - - // Match by position OR by title/artist - const positionMatch = track && (i + 1) === track.queuePosition; - const nameMatch = track && item.title === track.title && item.artist === track.artist; - const isCurrentTrack = positionMatch || (nameMatch && isFromQueue); - - // Check if track is gong banned (immune) - const isImmune = voting && voting.isTrackGongBanned({ title: item.title, artist: item.artist, uri: item.uri }); - if (isImmune) { - prefix = ':lock: '; - trackTitle = item.title; - } else if (isCurrentTrack && isFromQueue) { - trackTitle = '*' + trackTitle + '*'; - } else { - trackTitle = '_' + trackTitle + '_'; - } - - // Add star prefix for tracks with active votes - const hasVotes = voting && voting.hasActiveVotes(i, item.uri, item.title, item.artist); - if (hasVotes) { - prefix = ':star: ' + prefix; - } - - if (isCurrentTrack && isFromQueue) { - tracks.push(':notes: ' + '_#' + i + '_ ' + trackTitle + ' by ' + item.artist); - } else { - tracks.push(prefix + '>_#' + i + '_ ' + trackTitle + ' by ' + item.artist); - } + result.items.forEach((item, index) => { + const position = index + 1; + const isCurrentTrack = isFromQueue && track && track.queuePosition === position; + const prefix = isCurrentTrack ? '▢️ ' : `${position}. `; + message += `${prefix}*${item.title}* by _${item.artist}_\n`; }); - - // Check if we should use threads (always thread if >20 tracks) - const shouldUseThread = result.total > 20; - const threadOptions = shouldUseThread ? { forceThread: true } : {}; - - // Use array join to build message chunks efficiently - const messageChunks = []; - for (let i = 0; i < tracks.length; i++) { - messageChunks.push(tracks[i]); - if (i > 0 && Math.floor(i % 100) === 0) { - sendMessage(message + messageChunks.join('\n') + '\n', channel, threadOptions); - messageChunks.length = 0; - message = ''; - } - } - if (message || messageChunks.length > 0) { - sendMessage(message + messageChunks.join('\n') + '\n', channel, threadOptions); - } + sendMessage(message, channel); } catch (err) { - logger.error('Error fetching queue: ' + err); - sendMessage('🚨 Error fetching queue. Try again! πŸ”„', channel); + logger.error('Error showing queue: ' + err); + sendMessage('🚨 Error getting the queue. Try again! πŸ”„', channel); } } /** - * Show upcoming tracks + * Show the next N tracks in the queue */ async function upNext(channel) { try { @@ -389,60 +315,47 @@ async function upNext(channel) { ]); if (!result || !result.items || result.items.length === 0) { - logger.debug('Queue is empty or undefined'); - sendMessage('🎢 The queue is emptier than a broken jukebox! Add something with `add `! 🎡', channel); + sendMessage('πŸ¦— *Crickets...* The queue is empty! Try `add ` to get started! 🎡', channel); return; } - if (!track) { - logger.debug('Current track is undefined'); - sendMessage('🎡 No track is currently playing. Start something with `add `! 🎢', channel); + const currentPosition = (track && track.queuePosition) ? track.queuePosition : 0; + const nextTracks = result.items.slice(currentPosition, currentPosition + 5); + + if (nextTracks.length === 0) { + sendMessage('🎡 No more tracks coming up! Add some with `add ` 🎢', channel); return; } - let message = 'Upcoming tracks\n====================\n'; - let tracks = []; - let currentIndex = track.queuePosition; - - // Add current track and upcoming tracks - result.items.forEach((item, i) => { - if (i >= currentIndex && i <= currentIndex + 5) { - tracks.push('_#' + i + '_ ' + '_' + item.title + '_' + ' by ' + item.artist); - } + let message = '*⏭️ Up Next:*\n'; + nextTracks.forEach((item, index) => { + message += `${index + 1}. *${item.title}* by _${item.artist}_\n`; }); - for (let i in tracks) { - message += tracks[i] + '\n'; - } - - if (message) { - sendMessage(message, channel); - } + sendMessage(message, channel); } catch (err) { - logger.error('Error fetching queue for upNext: ' + err); - sendMessage('🚨 Error fetching upcoming tracks. Try again! πŸ”„', channel); + logger.error('Error getting up next: ' + err); + sendMessage('🚨 Error getting upcoming tracks. Try again! πŸ”„', channel); } } /** - * Count tracks in queue + * Get the size of the queue */ -function countQueue(channel, cb) { - sonos - .getQueue() - .then((result) => { - if (cb) { - return cb(result.total); - } - sendMessage(`🎡 We've got *${result.total}* ${result.total === 1 ? 'track' : 'tracks'} queued up and ready to rock! 🎸`, channel); - }) - .catch((err) => { - logger.error(err); - if (cb) { - return cb(null, err); - } - sendMessage('🀷 Error getting queue length. Try again in a moment! πŸ”„', channel); - }); +async function queueSize(channel) { + try { + const result = await sonos.getQueue(); + const size = (result && result.total) ? result.total : 0; + + if (size === 0) { + sendMessage('πŸ¦— The queue is empty! Add some tracks with `add ` 🎡', channel); + } else { + sendMessage(`πŸ“Š There ${size === 1 ? 'is' : 'are'} *${size}* ${size === 1 ? 'track' : 'tracks'} in the queue! 🎢`, channel); + } + } catch (err) { + logger.error('Error getting queue size: ' + err); + sendMessage('🚨 Error getting queue size. Try again! πŸ”„', channel); + } } // ========================================== @@ -450,121 +363,109 @@ function countQueue(channel, cb) { // ========================================== /** - * Get current volume + * Get or set the volume */ -async function getVolume(channel) { - const { maxVolume } = getConfig(); - - try { - const vol = await sonos.getVolume(); - logger.info('The volume is: ' + vol); - let message = 'πŸ”Š *Sonos:* Currently blasting at *' + vol + '* out of ' + (maxVolume || 100) + ' (your ears\' limits, not ours)'; - - // If Soundcraft is enabled, also show Soundcraft channel volumes - if (soundcraft && soundcraft.isEnabled()) { - const scVolumes = await soundcraft.getAllVolumes(); - if (Object.keys(scVolumes).length > 0) { - message += '\n\nπŸŽ›οΈ *Soundcraft Channels:*'; - for (const [name, scVol] of Object.entries(scVolumes)) { - message += `\n> *${name}:* ${scVol}%`; - } - } +async function volume(input, channel, userName) { + if (!input || input.length < 2) { + // Get current volume + try { + const vol = await sonos.getVolume(); + sendMessage(`πŸ”Š Current volume: *${vol}%* 🎡`, channel); + } catch (err) { + logger.error('Error getting volume: ' + err); + sendMessage('🚨 Error getting volume. Try again! πŸ”„', channel); } + return; + } - sendMessage(message, channel); + // Set volume + logUserAction(userName, 'volume'); + const newVolume = parseInt(input[1]); + if (isNaN(newVolume) || newVolume < 0 || newVolume > 100) { + sendMessage('πŸ”’ Volume must be a number between 0 and 100! 🎯', channel); + return; + } + + const config = getConfig(); + const maxVolume = parseInt((config.get ? config.get('maxVolume') : config.maxVolume) || 100); + + if (newVolume > maxVolume) { + sendMessage(`πŸ”Š Max volume is *${maxVolume}%*! I'm keeping it at that. 🎡`, channel); + try { + await sonos.setVolume(maxVolume); + } catch (err) { + logger.error('Error setting volume: ' + err); + } + return; + } + + try { + await sonos.setVolume(newVolume); + sendMessage(`πŸ”Š Volume set to *${newVolume}%*! 🎡`, channel); } catch (err) { - logger.error('Error occurred: ' + err); + logger.error('Error setting volume: ' + err); + sendMessage('🚨 Error setting volume. Try again! πŸ”„', channel); } } +// ========================================== +// INFO COMMANDS +// ========================================== + /** - * Set volume + * Show what's currently playing */ -function setVolume(input, channel, userName) { - logUserAction(userName, 'setVolume'); - const { maxVolume } = getConfig(); - - // Check if Soundcraft is enabled and if we have multiple arguments - if (soundcraft && soundcraft.isEnabled() && input.length >= 2) { - const channelNames = soundcraft.getChannelNames(); - - // Check if first argument is a Soundcraft channel name - const possibleChannelName = input[1]; - if (channelNames.includes(possibleChannelName)) { - // Syntax: setvolume - const vol = Number(input[2]); - - if (!input[2] || isNaN(vol)) { - sendMessage(`πŸ€” Usage: \`setvolume ${possibleChannelName} \`\n\nExample: \`setvolume ${possibleChannelName} 50\``, channel); - return; - } - - if (vol < 0 || vol > 100) { - sendMessage(`🚨 Volume must be between 0 and 100. You tried: ${vol}`, channel); - return; - } +async function current(channel) { + try { + const [track, state] = await Promise.all([ + sonos.currentTrack(), + sonos.getCurrentState() + ]); - // Convert 0-100 scale to dB - const minDB = -70; - const maxDB = 0; - const volDB = minDB + (maxDB - minDB) * (vol / 100); - - logger.info(`Setting Soundcraft channel '${possibleChannelName}' to ${vol}% (${volDB} dB)`); - - soundcraft.setVolume(possibleChannelName, volDB) - .then(success => { - if (success) { - sendMessage(`πŸ”Š Soundcraft channel *${possibleChannelName}* volume set to *${vol}%* (${volDB} dB)`, channel); - } else { - sendMessage(`❌ Failed to set Soundcraft volume. Check logs for details.`, channel); - } - }) - .catch(err => { - logger.error('Error setting Soundcraft volume: ' + err); - sendMessage(`❌ Error setting Soundcraft volume: ${err.message}`, channel); - }); + if (!track || !track.title) { + sendMessage('πŸ¦— Nothing is playing right now! Try `add ` to get started! 🎡', channel); return; } - } - - // Default behavior: Set Sonos volume - const vol = Number(input[1]); - if (isNaN(vol)) { - // If Soundcraft is enabled, show helpful message with available channels - if (soundcraft && soundcraft.isEnabled()) { - const channelNames = soundcraft.getChannelNames(); - const channelList = channelNames.map(c => `\`${c}\``).join(', '); - sendMessage( - `πŸ€” Invalid volume!\n\n` + - `*Sonos:* \`setvolume \`\n` + - `*Soundcraft:* \`setvolume \`\n\n` + - `Available Soundcraft channels: ${channelList}`, - channel - ); - } else { - sendMessage('πŸ€” That\'s not a number, that\'s... I don\'t even know what that is. Try again with actual digits!', channel); + const stateEmoji = state === 'playing' ? '▢️' : state === 'paused' ? '⏸️' : '⏹️'; + let message = `${stateEmoji} *Now Playing:*\n`; + message += `🎡 *${track.title}*\n`; + message += `πŸ‘€ _${track.artist}_\n`; + + if (track.duration && track.position) { + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + message += `⏱️ ${formatTime(track.position)} / ${formatTime(track.duration)}`; } - return; - } - logger.info('Volume is: ' + vol); - if (vol > (maxVolume || 100)) { - sendMessage('🚨 Whoa there, ' + userName + '! That\'s louder than a metal concert in a phone booth. Max is *' + (maxVolume || 100) + '*. Try again! 🎸', channel); - return; + sendMessage(message, channel); + } catch (err) { + logger.error('Error getting current track: ' + err); + sendMessage('🚨 Error getting current track info. Try again! πŸ”„', channel); } +} - setTimeout(() => { - sonos - .setVolume(vol) - .then(() => { - logger.info('The volume is set to: ' + vol); - getVolume(channel); - }) - .catch((err) => { - logger.error('Error occurred while setting volume: ' + err); - }); - }, 1000); +/** + * Get the current playback status + */ +async function status(channel) { + try { + const state = await sonos.getCurrentState(); + const stateMessages = { + 'playing': '▢️ *Playing* β€” Music is flowing! 🎢', + 'paused': '⏸️ *Paused* β€” Taking a breather. πŸ’¨', + 'stopped': '⏹️ *Stopped* β€” Silence reigns. πŸ”‡', + 'transitioning': '⏳ *Transitioning* β€” Hold on... πŸ”„' + }; + const message = stateMessages[state] || `❓ *Status:* ${state}`; + sendMessage(message, channel); + } catch (err) { + logger.error('Error getting status: ' + err); + sendMessage('🚨 Error getting playback status. Try again! πŸ”„', channel); + } } // ========================================== @@ -572,166 +473,208 @@ function setVolume(input, channel, userName) { // ========================================== /** - * Search for tracks + * Search for tracks on Spotify */ -async function search(input, channel, userName) { - logUserAction(userName, 'search'); - - if (!spotify) { - sendMessage('🎡 Spotify is not configured. Search is unavailable.', channel); +async function search(input, channel) { + if (!input || input.length < 2) { + sendMessage('πŸ” You gotta tell me what to search for! Use `search ` 🎡', channel); return; } - - const { searchLimit } = getConfig(); - - if (!input || input.length < 2) { - sendMessage('πŸ” What should I search for? Try `search ` 🎡', channel); + + if (!spotify) { + sendMessage('🚫 Spotify is not configured. Cannot search! 🎡', channel); return; } - const term = input.slice(1).join(' '); - logger.info('Track to search for: ' + term); + const query = input.slice(1).join(' '); + const config = getConfig(); + const searchLimit = parseInt((config.get ? config.get('searchLimit') : config.searchLimit) || 7); try { - const tracks = await spotify.searchTrackList(term, searchLimit || 10); + const tracks = await spotify.searchTrackList(query, searchLimit); if (!tracks || tracks.length === 0) { - sendMessage("🀷 Couldn't find anything matching that. Try different keywords or check the spelling! 🎡", channel); + sendMessage("🀷 Couldn't find anything matching that. Try different keywords! 🎡", channel); return; } - // Sort tracks by relevance using queue-utils - const sortedTracks = queueUtils.sortTracksByRelevance(tracks, term); - - let message = `🎡 Found *${sortedTracks.length} ${sortedTracks.length === 1 ? 'track' : 'tracks'}*:\n`; - sortedTracks.forEach((track, index) => { - message += `>${index + 1}. *${track.name}* by _${track.artists[0].name}_\n`; + let message = `*πŸ” Search results for "${query}":*\n`; + tracks.forEach((track, index) => { + message += `${index + 1}. *${track.name}* by _${track.artist}_\n`; }); + message += '\n_Use `add ` to add a track to the queue!_'; + sendMessage(message, channel); } catch (err) { - logger.error('Error searching for track: ' + err.message); - sendMessage('🚨 Couldn\'t search for tracks. Error: ' + err.message + ' Try again! πŸ”„', channel); + logger.error('Error searching tracks: ' + err); + sendMessage('🚨 Error searching Spotify. Try again! πŸ”„', channel); } } /** - * Search for albums + * Search for albums on Spotify */ -async function searchalbum(input, channel) { - if (!spotify) { - sendMessage('🎡 Spotify is not configured. Search is unavailable.', channel); +async function searchAlbum(input, channel) { + if (!input || input.length < 2) { + sendMessage('πŸ” You gotta tell me what album to search for! Use `searchalbum ` 🎡', channel); return; } - - const { searchLimit } = getConfig(); - - if (!input || input.length < 2) { - sendMessage('πŸ” You gotta tell me what album to search for! Try `searchalbum ` 🎢', channel); + + if (!spotify) { + sendMessage('🚫 Spotify is not configured. Cannot search! 🎡', channel); return; } - const album = input.slice(1).join(' '); - logger.info('Album to search for: ' + album); + + const query = input.slice(1).join(' '); + const config = getConfig(); + const searchLimit = parseInt((config.get ? config.get('searchLimit') : config.searchLimit) || 7); try { - const albums = await spotify.searchAlbumList(album, searchLimit || 10); + const albums = await spotify.searchAlbumList(query, searchLimit); if (!albums || albums.length === 0) { - sendMessage('πŸ€” Couldn\'t find that album. Try including the artist name or checking the spelling! 🎢', channel); + sendMessage("🀷 Couldn't find any albums matching that. Try different keywords! 🎡", channel); return; } - // Sort albums by relevance using queue-utils - const sortedAlbums = queueUtils.sortAlbumsByRelevance(albums, album); - - let message = `Found ${sortedAlbums.length} albums:\n`; - sortedAlbums.forEach((albumResult) => { - const trackInfo = albumResult.totalTracks - ? ` (${albumResult.totalTracks} ${albumResult.totalTracks === 1 ? 'track' : 'tracks'})` - : ''; - message += `> *${albumResult.name}* by _${albumResult.artist}_${trackInfo}\n`; + let message = `*πŸ” Album search results for "${query}":*\n`; + albums.forEach((album, index) => { + message += `${index + 1}. *${album.name}* by _${album.artist}_\n`; }); + message += '\n_Use `addalbum ` to add an album to the queue!_'; + sendMessage(message, channel); } catch (err) { - logger.error('Error searching for album: ' + err.message); - sendMessage('🚨 Couldn\'t search for albums. Error: ' + err.message + ' πŸ”„', channel); + logger.error('Error searching albums: ' + err); + sendMessage('🚨 Error searching Spotify. Try again! πŸ”„', channel); } } /** - * Search for playlists + * Search for playlists on Spotify */ -async function searchplaylist(input, channel, userName) { - logUserAction(userName, 'searchplaylist'); - - if (!spotify) { - sendMessage('🎡 Spotify is not configured. Search is unavailable.', channel); +async function searchPlaylist(input, channel) { + if (!input || input.length < 2) { + sendMessage('πŸ” You gotta tell me what playlist to search for! Use `searchplaylist ` 🎡', channel); return; } - - if (!input || input.length < 2) { - sendMessage('πŸ” Tell me which playlist to search for! `searchplaylist ` 🎢', channel); + + if (!spotify) { + sendMessage('🚫 Spotify is not configured. Cannot search! 🎡', channel); return; } - const playlist = input.slice(1).join(' '); - logger.info('Playlist to search for: ' + playlist); + + const query = input.slice(1).join(' '); + const config = getConfig(); + const searchLimit = parseInt((config.get ? config.get('searchLimit') : config.searchLimit) || 7); try { - const playlists = await spotify.searchPlaylistList(playlist, 10); + const playlists = await spotify.searchPlaylistList(query, searchLimit); if (!playlists || playlists.length === 0) { - sendMessage('🀷 Couldn\'t find that playlist. Check the spelling or try a different search! 🎢', channel); + sendMessage("🀷 Couldn't find any playlists matching that. Try different keywords! 🎡", channel); return; } - // Sort by relevance using queue-utils - const sortedPlaylists = queueUtils.sortPlaylistsByRelevance(playlists, playlist); - - // Show top 5 results - const topFive = sortedPlaylists.slice(0, 5); - let message = `Found ${sortedPlaylists.length} playlists:\n`; - topFive.forEach((result, index) => { - message += `>${index + 1}. *${result.name}* by _${result.owner}_ (${result.tracks} tracks)\n`; + let message = `*πŸ” Playlist search results for "${query}":*\n`; + playlists.forEach((playlist, index) => { + message += `${index + 1}. *${playlist.name}* by _${playlist.owner}_\n`; }); + message += '\n_Use `addplaylist ` to add a playlist to the queue!_'; sendMessage(message, channel); } catch (err) { - logger.error('Error searching for playlist: ' + err.message); - sendMessage('🚨 Couldn\'t search for playlists. Error: ' + err.message + ' πŸ”„', channel); + logger.error('Error searching playlists: ' + err); + sendMessage('🚨 Error searching Spotify. Try again! πŸ”„', channel); + } +} + +// ========================================== +// SOUNDCRAFT COMMANDS +// ========================================== + +/** + * Get or set Soundcraft mixer volume + */ +async function soundcraftVolume(input, channel, userName) { + if (!soundcraft || !soundcraft.isEnabled()) { + sendMessage('🚫 Soundcraft mixer is not enabled or configured.', channel); + return; + } + + if (!input || input.length < 2) { + // Show all channel volumes + try { + const volumes = await soundcraft.getAllVolumes(); + const channelNames = soundcraft.getChannelNames(); + let message = '*🎚️ Soundcraft Mixer Volumes:*\n'; + channelNames.forEach((name, index) => { + const vol = volumes[index] !== undefined ? volumes[index] : 'N/A'; + message += `${name}: *${vol}*\n`; + }); + sendMessage(message, channel); + } catch (err) { + logger.error('Error getting Soundcraft volumes: ' + err); + sendMessage('🚨 Error getting mixer volumes. Try again! πŸ”„', channel); + } + return; + } + + // Set volume for a channel + logUserAction(userName, 'soundcraft-volume'); + const channelName = input[1]; + const newVolume = parseFloat(input[2]); + + if (isNaN(newVolume)) { + sendMessage('πŸ”’ Volume must be a number! Use `soundcraft ` 🎯', channel); + return; + } + + try { + const success = await soundcraft.setVolume(channelName, newVolume); + if (success) { + sendMessage(`🎚️ Soundcraft channel *${channelName}* set to *${newVolume}*! 🎡`, channel); + } else { + sendMessage(`🚫 Could not find channel *${channelName}* on the mixer.`, channel); + } + } catch (err) { + logger.error('Error setting Soundcraft volume: ' + err); + sendMessage('🚨 Error setting mixer volume. Try again! πŸ”„', channel); } } // ========================================== -// EXPORTS +// MODULE EXPORTS // ========================================== module.exports = { - // Initialization initialize, - - // Playback commands + // Playback stop, play, pause, resume, flush, + clear: flush, // SLAC-10: 'clear' is an alias for 'flush' β€” same function reference shuffle, normal, nextTrack, previous, - - // Queue commands + // Queue removeTrack, purgeHalfQueue, showQueue, upNext, - countQueue, - - // Volume commands - getVolume, - setVolume, - - // Search commands + queueSize, + // Volume + volume, + // Info + current, + status, + // Search search, - searchalbum, - searchplaylist + searchAlbum, + searchPlaylist, + // Soundcraft + soundcraftVolume, }; diff --git a/templates/help/helpText.txt b/templates/help/helpText.txt index 2d0f035..3d7b2ac 100644 --- a/templates/help/helpText.txt +++ b/templates/help/helpText.txt @@ -24,6 +24,7 @@ > `vote [position]` - Move a track to the top. Needs *{{voteLimit}}* votes. ⬆️ > `votecheck` - Check the current vote counts. > `flushvote` - Vote to clear the entire queue. Needs *{{flushVoteLimit}}* votes within *{{voteTimeLimitMinutes}}* min. πŸ—‘οΈ +> `flush` (or `clear`) - Clear the entire Sonos queue immediately. 🚽 *πŸ“ Feedback:* > `featurerequest` (or `fr`) `` - Wish for what new feature this bot should have!!! ✨ diff --git a/test/clear-alias.test.mjs b/test/clear-alias.test.mjs new file mode 100644 index 0000000..fff141c --- /dev/null +++ b/test/clear-alias.test.mjs @@ -0,0 +1,472 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { readFileSync } from 'fs'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +/** + * SLAC-10 β€” "clear" as an alias for the "flush" command + * + * Test suites: + * 1. flush handler (core behaviour) β€” verifies the shared handler logic + * 2. "clear" alias contract β€” the primary SLAC-10 requirement + * 3. flush regression β€” existing flush behaviour is unchanged + * 4. Help text discoverability β€” "clear" appears in helpText.txt + * 5. Module export structure β€” structural / static contract + */ + +// --------------------------------------------------------------------------- +// Shared factory β€” builds a fresh, fully-initialised commandHandlers instance +// --------------------------------------------------------------------------- + +function buildHandlers() { + // Clear the module cache so module-level state (let variables) is reset + delete require.cache[require.resolve('../lib/command-handlers.js')]; + const commandHandlers = require('../lib/command-handlers.js'); + + const messages = []; + const userActions = []; + + const mockSonos = { + stop: sinon.stub().resolves(), + play: sinon.stub().resolves(), + pause: sinon.stub().resolves(), + next: sinon.stub().resolves(), + previous: sinon.stub().resolves(), + flush: sinon.stub().resolves(), + setPlayMode: sinon.stub().resolves(), + getVolume: sinon.stub().resolves(50), + setVolume: sinon.stub().resolves(), + getQueue: sinon.stub().resolves({ items: [], total: 0 }), + getCurrentState: sinon.stub().resolves('playing'), + currentTrack: sinon.stub().resolves({ + title: 'Track 1', artist: 'Artist 1', queuePosition: 1, duration: 180, position: 60 + }), + removeTracksFromQueue: sinon.stub().resolves(), + }; + + const mockLogger = { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + debug: sinon.stub(), + }; + + commandHandlers.initialize({ + logger: mockLogger, + sonos: mockSonos, + sendMessage: async (msg, ch) => messages.push({ msg, channel: ch }), + logUserAction: async (user, action) => userActions.push({ user, action }), + getConfig: () => ({ maxVolume: 80, searchLimit: 10 }), + }); + + return { commandHandlers, mockSonos, mockLogger, messages, userActions }; +} + +// --------------------------------------------------------------------------- +// Suite 1 β€” flush handler (core behaviour, no alias involved) +// --------------------------------------------------------------------------- + +describe('SLAC-10 β€” flush handler (core behaviour)', function () { + let commandHandlers, mockSonos, mockLogger, messages, userActions; + + beforeEach(function () { + ({ commandHandlers, mockSonos, mockLogger, messages, userActions } = buildHandlers()); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('calls sonos.flush() exactly once', function (done) { + commandHandlers.flush(['flush'], 'C001', 'alice'); + + setTimeout(() => { + expect(mockSonos.flush.callCount).to.equal(1); + done(); + }, 50); + }); + + it('sends a success message to the correct channel', function (done) { + commandHandlers.flush(['flush'], 'C001', 'alice'); + + setTimeout(() => { + expect(messages).to.have.lengthOf(1); + expect(messages[0].channel).to.equal('C001'); + done(); + }, 50); + }); + + it('success message communicates that the queue was cleared', function (done) { + commandHandlers.flush(['flush'], 'C001', 'alice'); + + setTimeout(() => { + const text = messages[0].msg.toLowerCase(); + expect(text).to.satisfy( + (t) => t.includes('queue') || t.includes('wipe') || t.includes('clean') || t.includes('flush'), + 'Expected success message to reference the queue being cleared' + ); + done(); + }, 50); + }); + + it('success message is a non-empty string', function (done) { + commandHandlers.flush(['flush'], 'C001', 'alice'); + + setTimeout(() => { + expect(messages[0].msg).to.be.a('string').and.to.have.length.greaterThan(0); + done(); + }, 50); + }); + + it('logs the user action with the correct user name', function () { + commandHandlers.flush(['flush'], 'C001', 'alice'); + + expect(userActions).to.have.lengthOf(1); + expect(userActions[0].user).to.equal('alice'); + }); + + it('logs the user action as "flush"', function () { + commandHandlers.flush(['flush'], 'C001', 'alice'); + + expect(userActions[0].action).to.equal('flush'); + }); + + it('logs an error when sonos.flush() rejects', function (done) { + mockSonos.flush.rejects(new Error('Sonos unavailable')); + + commandHandlers.flush(['flush'], 'C001', 'alice'); + + setTimeout(() => { + expect(mockLogger.error.called).to.be.true; + done(); + }, 50); + }); + + it('does NOT send a user-facing message when sonos.flush() rejects', function (done) { + mockSonos.flush.rejects(new Error('Sonos unavailable')); + + commandHandlers.flush(['flush'], 'C001', 'alice'); + + setTimeout(() => { + expect(messages).to.have.lengthOf(0); + done(); + }, 50); + }); + + it('error log contains relevant context when sonos.flush() rejects', function (done) { + mockSonos.flush.rejects(new Error('network timeout')); + + commandHandlers.flush(['flush'], 'C001', 'alice'); + + setTimeout(() => { + const errorArgs = mockLogger.error.args.flat().join(' ').toLowerCase(); + expect(errorArgs).to.satisfy( + (s) => s.includes('flush') || s.includes('queue') || s.includes('error'), + 'Expected error log to contain relevant context' + ); + done(); + }, 50); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 2 β€” "clear" alias contract (primary SLAC-10 requirement) +// --------------------------------------------------------------------------- + +describe('SLAC-10 β€” "clear" alias contract', function () { + afterEach(function () { + sinon.restore(); + }); + + // --- structural --- + + it('"clear" is exported as a top-level property of commandHandlers', function () { + const { commandHandlers } = buildHandlers(); + expect(commandHandlers).to.have.property('clear'); + }); + + it('"clear" is a callable function', function () { + const { commandHandlers } = buildHandlers(); + expect(commandHandlers.clear).to.be.a('function'); + }); + + it('"clear" and "flush" are the exact same function reference (shared by reference, no wrapper)', function () { + const { commandHandlers } = buildHandlers(); + expect(commandHandlers.clear).to.equal( + commandHandlers.flush, + '"clear" must be the exact same function reference as "flush" β€” not a copy or wrapper' + ); + }); + + it('"clear" has the same arity (parameter count) as "flush"', function () { + const { commandHandlers } = buildHandlers(); + expect(commandHandlers.clear.length).to.equal(commandHandlers.flush.length); + }); + + // --- behavioural --- + + it('"clear" calls sonos.flush() exactly once', function (done) { + const { commandHandlers, mockSonos } = buildHandlers(); + + commandHandlers.clear(['clear'], 'C002', 'bob'); + + setTimeout(() => { + expect(mockSonos.flush.callCount).to.equal(1); + done(); + }, 50); + }); + + it('"clear" sends a success message to the correct channel', function (done) { + const { commandHandlers, messages } = buildHandlers(); + + commandHandlers.clear(['clear'], 'C002', 'bob'); + + setTimeout(() => { + expect(messages).to.have.lengthOf(1); + expect(messages[0].channel).to.equal('C002'); + done(); + }, 50); + }); + + it('"clear" produces the identical response message as "flush"', function (done) { + // Use two independent module instances so state is fully isolated + const flushCtx = buildHandlers(); + const clearCtx = buildHandlers(); + + flushCtx.commandHandlers.flush(['flush'], 'C003', 'user1'); + clearCtx.commandHandlers.clear(['clear'], 'C003', 'user1'); + + setTimeout(() => { + expect(flushCtx.messages).to.have.lengthOf(1); + expect(clearCtx.messages).to.have.lengthOf(1); + expect(clearCtx.messages[0].msg).to.equal( + flushCtx.messages[0].msg, + 'Response message from "clear" must be identical to the response from "flush"' + ); + done(); + }, 50); + }); + + it('"clear" logs a user action', function () { + const { commandHandlers, userActions } = buildHandlers(); + + commandHandlers.clear(['clear'], 'C002', 'bob'); + + expect(userActions).to.have.lengthOf(1); + expect(userActions[0].user).to.equal('bob'); + expect(userActions[0].action).to.be.a('string').and.to.have.length.greaterThan(0); + }); + + it('"clear" does NOT call any Sonos method other than flush()', function (done) { + const { commandHandlers, mockSonos } = buildHandlers(); + + commandHandlers.clear(['clear'], 'C002', 'bob'); + + setTimeout(() => { + expect(mockSonos.stop.called, 'stop should not be called').to.be.false; + expect(mockSonos.play.called, 'play should not be called').to.be.false; + expect(mockSonos.pause.called, 'pause should not be called').to.be.false; + expect(mockSonos.setPlayMode.called, 'setPlayMode should not be called').to.be.false; + expect(mockSonos.flush.callCount).to.equal(1); + done(); + }, 50); + }); + + // --- error handling parity --- + + it('"clear" logs an error when sonos.flush() rejects', function (done) { + const { commandHandlers, mockSonos, mockLogger } = buildHandlers(); + mockSonos.flush.rejects(new Error('boom')); + + commandHandlers.clear(['clear'], 'C004', 'bob'); + + setTimeout(() => { + expect(mockLogger.error.called).to.be.true; + done(); + }, 50); + }); + + it('"clear" does NOT send a user-facing message when sonos.flush() rejects', function (done) { + const { commandHandlers, mockSonos, messages } = buildHandlers(); + mockSonos.flush.rejects(new Error('boom')); + + commandHandlers.clear(['clear'], 'C004', 'bob'); + + setTimeout(() => { + expect(messages).to.have.lengthOf(0); + done(); + }, 50); + }); + + it('"clear" and "flush" handle Sonos errors identically', function (done) { + const flushCtx = buildHandlers(); + const clearCtx = buildHandlers(); + + flushCtx.mockSonos.flush.rejects(new Error('network error')); + clearCtx.mockSonos.flush.rejects(new Error('network error')); + + flushCtx.commandHandlers.flush(['flush'], 'C005', 'user1'); + clearCtx.commandHandlers.clear(['clear'], 'C005', 'user1'); + + setTimeout(() => { + // Both log an error + expect(flushCtx.mockLogger.error.called).to.be.true; + expect(clearCtx.mockLogger.error.called).to.be.true; + // Neither sends a user-facing message + expect(flushCtx.messages).to.have.lengthOf(0); + expect(clearCtx.messages).to.have.lengthOf(0); + done(); + }, 50); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 3 β€” flush regression (SLAC-10 must not break existing behaviour) +// --------------------------------------------------------------------------- + +describe('SLAC-10 β€” flush regression (no existing behaviour broken)', function () { + let commandHandlers, mockSonos, mockLogger, messages, userActions; + + beforeEach(function () { + ({ commandHandlers, mockSonos, mockLogger, messages, userActions } = buildHandlers()); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('"flush" still calls sonos.flush() after the alias was added', function (done) { + commandHandlers.flush(['flush'], 'C010', 'carol'); + + setTimeout(() => { + expect(mockSonos.flush.calledOnce).to.be.true; + done(); + }, 50); + }); + + it('"flush" still sends its success message after the alias was added', function (done) { + commandHandlers.flush(['flush'], 'C010', 'carol'); + + setTimeout(() => { + expect(messages).to.have.lengthOf(1); + expect(messages[0].msg).to.be.a('string').and.to.have.length.greaterThan(0); + done(); + }, 50); + }); + + it('"flush" still logs the user action after the alias was added', function () { + commandHandlers.flush(['flush'], 'C010', 'carol'); + + expect(userActions.some((a) => a.user === 'carol')).to.be.true; + }); + + it('"flush" is still exported as a named export', function () { + expect(commandHandlers.flush).to.be.a('function'); + }); + + it('"flush" and "clear" can be called sequentially without interfering with each other', function (done) { + commandHandlers.flush(['flush'], 'C011', 'dave'); + commandHandlers.clear(['clear'], 'C011', 'eve'); + + setTimeout(() => { + // Each invocation triggers one sonos.flush() call β†’ 2 total + expect(mockSonos.flush.callCount).to.equal(2); + // Each invocation sends one message β†’ 2 total + expect(messages).to.have.lengthOf(2); + done(); + }, 50); + }); + + it('calling "clear" does not affect the behaviour of a subsequent "flush" call', function (done) { + commandHandlers.clear(['clear'], 'C012', 'frank'); + + setTimeout(() => { + // Reset call counts so we can isolate the flush call + mockSonos.flush.resetHistory(); + messages.length = 0; + + commandHandlers.flush(['flush'], 'C012', 'frank'); + + setTimeout(() => { + expect(mockSonos.flush.callCount).to.equal(1); + expect(messages).to.have.lengthOf(1); + done(); + }, 50); + }, 50); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 4 β€” Help text discoverability (SLAC-10 acceptance criterion) +// --------------------------------------------------------------------------- + +describe('SLAC-10 β€” help text contains "clear" alias', function () { + let helpText; + + before(function () { + helpText = readFileSync('templates/help/helpText.txt', 'utf8'); + }); + + it('helpText.txt contains the word "clear"', function () { + expect(helpText).to.include('clear'); + }); + + it('helpText.txt still contains the word "flush" (no regression)', function () { + expect(helpText).to.include('flush'); + }); + + it('"clear" and "flush" appear close together in the help text (same entry)', function () { + const flushIndex = helpText.indexOf('flush'); + const clearIndex = helpText.indexOf('clear'); + + expect(flushIndex).to.be.greaterThan(-1, '"flush" not found in help text'); + expect(clearIndex).to.be.greaterThan(-1, '"clear" not found in help text'); + + // They should be within 200 characters of each other (same line / same entry) + expect(Math.abs(flushIndex - clearIndex)).to.be.lessThan( + 200, + '"clear" and "flush" should appear close together in the help text (same entry)' + ); + }); + + it('help text is non-empty', function () { + expect(helpText.trim()).to.have.length.greaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 5 β€” Module export structure (static / structural contract) +// --------------------------------------------------------------------------- + +describe('SLAC-10 β€” module export structure', function () { + afterEach(function () { + sinon.restore(); + }); + + it('module exports both "flush" and "clear" as top-level properties', function () { + const { commandHandlers } = buildHandlers(); + + expect(commandHandlers).to.have.property('flush'); + expect(commandHandlers).to.have.property('clear'); + }); + + it('"clear" is not undefined after initialization', function () { + const { commandHandlers } = buildHandlers(); + + expect(commandHandlers.clear).to.not.be.undefined; + }); + + it('"clear" is not null after initialization', function () { + const { commandHandlers } = buildHandlers(); + + expect(commandHandlers.clear).to.not.be.null; + }); + + it('"flush" is not undefined after initialization', function () { + const { commandHandlers } = buildHandlers(); + + expect(commandHandlers.flush).to.not.be.undefined; + }); +});