├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── docker-image.yml │ ├── node.js.yml │ ├── setBuildNumber.yml │ ├── snyk-security.yml │ └── snyk_container-analysis.yml ├── .gitignore ├── .snyk ├── .travis.yml ├── Dockerfile ├── Dockerfile-local ├── INSTALL ├── Procfile ├── README.md ├── build.txt ├── config └── config.json.example ├── doc └── images │ ├── ExampleZenMusic.png │ ├── Screenshot.png │ └── ZenMusic.png ├── docker-compose-example.yml ├── gong.txt ├── helpText.txt ├── helpTextAdmin.txt ├── index.js ├── package-lock.json ├── package.json ├── sound └── gong.mp3 ├── spotify.js ├── test └── test.mjs ├── tts.txt ├── utils.js └── vote.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic `dependabot.yml` file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for npm 7 | - package-ecosystem: "npm" 8 | # Look for `package.json` and `lock` files in the `root` directory 9 | directory: "/" 10 | # Check the npm registry for updates every day (weekdays) 11 | schedule: 12 | interval: "daily" 13 | 14 | # Enable version updates for Docker 15 | - package-ecosystem: "docker" 16 | # Look for a `Dockerfile` in the `root` directory 17 | directory: "/" 18 | # Check for updates once a week 19 | schedule: 20 | interval: "weekly" 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Multi-Platform Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - master # Trigger on pushes to the master branch 7 | paths-ignore: 8 | - 'build.txt' # Exclude build.txt from triggering the workflow 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout the repository 16 | uses: actions/checkout@v4.1.7 17 | 18 | - name: Docker Setup QEMU 19 | uses: docker/setup-qemu-action@v3.2.0 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3.6.1 23 | 24 | - name: Log in to Docker Hub 25 | run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin 26 | 27 | - name: Build and push multi-platform Docker image 28 | run: | 29 | docker buildx build --platform linux/amd64,linux/arm64 --push \ 30 | -t ${{ secrets.DOCKERHUB_USERNAME }}/slackonos:latest . 31 | 32 | - name: Verify the platforms 33 | run: docker buildx inspect --bootstrap 34 | 35 | - name: Increment build number in build.txt 36 | run: | 37 | BUILD_NUMBER=$(cat build.txt) 38 | BUILD_NUMBER=$((BUILD_NUMBER + 1)) 39 | echo $BUILD_NUMBER > build.txt 40 | 41 | - name: Commit and push updated build.txt 42 | run: | 43 | git config --local user.name "github-actions[bot]" 44 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 45 | git add build.txt 46 | git commit -m "Increment build number to $BUILD_NUMBER" 47 | git push 48 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x, 22.x, 23.x, 24.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.github/workflows/setBuildNumber.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - run: | 11 | sed -i 's/\(buildNumber = (\)\(.*\)/\1'\'${{ github.run_number }}'''\'''\'')/' ./index.js 12 | git config user.name github-actions 13 | git config user.email github-actions@github.com 14 | git add . 15 | git commit -m "Updated build number" 16 | git push 17 | -------------------------------------------------------------------------------- /.github/workflows/snyk-security.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # A sample workflow which sets up Snyk to analyze the full Snyk platform (Snyk Open Source, Snyk Code, 7 | # Snyk Container and Snyk Infrastructure as Code) 8 | # The setup installs the Snyk CLI - for more details on the possible commands 9 | # check https://docs.snyk.io/snyk-cli/cli-reference 10 | # The results of Snyk Code are then uploaded to GitHub Security Code Scanning 11 | # 12 | # In order to use the Snyk Action you will need to have a Snyk API token. 13 | # More details in https://github.com/snyk/actions#getting-your-snyk-token 14 | # or you can signup for free at https://snyk.io/login 15 | # 16 | # For more examples, including how to limit scans to only high-severity issues 17 | # and fail PR checks, see https://github.com/snyk/actions/ 18 | 19 | name: Snyk Security 20 | 21 | on: 22 | push: 23 | branches: ["master" ] 24 | pull_request: 25 | branches: ["master"] 26 | 27 | permissions: 28 | contents: read 29 | 30 | jobs: 31 | snyk: 32 | permissions: 33 | contents: read # for actions/checkout to fetch code 34 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 35 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: Set up Snyk CLI to check for security issues 40 | # Snyk can be used to break the build when it detects security issues. 41 | # In this case we want to upload the SAST issues to GitHub Code Scanning 42 | uses: snyk/actions/setup@806182742461562b67788a64410098c9d9b96adb 43 | 44 | # For Snyk Open Source you must first set up the development environment for your application's dependencies 45 | # For example for Node 46 | #- uses: actions/setup-node@v4 47 | # with: 48 | # node-version: 20 49 | 50 | env: 51 | # This is where you will need to introduce the Snyk API token created with your Snyk account 52 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 53 | 54 | # Runs Snyk Code (SAST) analysis and uploads result into GitHub. 55 | # Use || true to not fail the pipeline 56 | - name: Snyk Code test 57 | run: snyk code test --sarif > snyk-code.sarif # || true 58 | 59 | # Runs Snyk Open Source (SCA) analysis and uploads result to Snyk. 60 | - name: Snyk Open Source monitor 61 | run: snyk monitor --all-projects 62 | 63 | # Runs Snyk Infrastructure as Code (IaC) analysis and uploads result to Snyk. 64 | # Use || true to not fail the pipeline. 65 | - name: Snyk IaC test and report 66 | run: snyk iac test --report # || true 67 | 68 | # Build the docker image for testing 69 | - name: Build a Docker image 70 | run: docker build -t htilly/slackonos_test . 71 | # Runs Snyk Container (Container and SCA) analysis and uploads result to Snyk. 72 | - name: Snyk Container monitor 73 | run: snyk container monitor htilly/slackonos_test --file=Dockerfile 74 | 75 | # Push the Snyk Code results into GitHub Code Scanning tab 76 | - name: Upload result to GitHub Code Scanning 77 | uses: github/codeql-action/upload-sarif@v3 78 | with: 79 | sarif_file: snyk-code.sarif 80 | -------------------------------------------------------------------------------- /.github/workflows/snyk_container-analysis.yml: -------------------------------------------------------------------------------- 1 | # A sample workflow which checks out the code, builds a container 2 | # image using Docker and scans that image for vulnerabilities using 3 | # Snyk. The results are then uploaded to GitHub Security Code Scanning 4 | # 5 | # For more examples, including how to limit scans to only high-severity 6 | # issues, monitor images for newly disclosed vulnerabilities in Snyk and 7 | # fail PR checks for new vulnerabilities, see https://github.com/snyk/actions/ 8 | 9 | name: Snyk Container 10 | on: push 11 | jobs: 12 | snyk: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Build a Docker image 17 | run: docker build -t htilly/test_slackonos . 18 | - name: Run Snyk to check Docker image for vulnerabilities 19 | # Snyk can be used to break the build when it detects vulnerabilities. 20 | # In this case we want to upload the issues to GitHub Code Scanning 21 | continue-on-error: true 22 | uses: snyk/actions/docker@master 23 | env: 24 | # In order to use the Snyk Action you will need to have a Snyk API token. 25 | # More details in https://github.com/snyk/actions#getting-your-snyk-token 26 | # or you can signup for free at https://snyk.io/login 27 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 28 | with: 29 | image: htilly/test_slackonos 30 | args: --file=Dockerfile 31 | - name: Upload result to GitHub Code Scanning 32 | uses: github/codeql-action/upload-sarif@v3 33 | with: 34 | sarif_file: snyk.sarif 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules/ 3 | config.json 4 | blacklist.txt 5 | .vscode/ 6 | *.log 7 | config/config.json 8 | config/userActions.json 9 | docker-compose.yml 10 | Dockerfile-local 11 | build.txt -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.14.1 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-LODASH-567746: 7 | - '@slack/client > lodash': 8 | patched: '2020-05-01T06:26:14.644Z' 9 | - eslint > lodash: 10 | patched: '2020-05-01T06:26:14.644Z' 11 | - eslint > inquirer > lodash: 12 | patched: '2020-05-01T06:26:14.644Z' 13 | - eslint > table > lodash: 14 | patched: '2020-05-01T06:26:14.644Z' 15 | - slack-mock > nock > lodash: 16 | patched: '2020-05-01T06:26:14.644Z' 17 | - winston > async > lodash: 18 | patched: '2020-05-01T06:26:14.644Z' -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 10.0 5 | - node 6 | 7 | install: 8 | - npm install 9 | 10 | script: 11 | - echo "Running tests against $(node -v) ..." 12 | 13 | # Send coverage data to Coveralls 14 | # after_script: "cat coverage/lcov.info | node_modules/coveralls/bin/coveralls.js" 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Node.js image based on Alpine Linux 2 | # The --platform flag is used here to make sure we use a multi-platform base image 3 | FROM --platform=$TARGETPLATFORM node:24.0-slim AS base 4 | 5 | # Update and install git (if needed for your application) 6 | #RUN apk update && \ 7 | # apk upgrade 8 | 9 | # Clear npm cache to reduce image size and avoid potential issues 10 | RUN npm cache clean --force 11 | 12 | # Set the working directory for your application 13 | WORKDIR /app 14 | 15 | # Copy package.json and package-lock.json first to leverage Docker cache 16 | COPY package*.json ./ 17 | 18 | # Install application dependencies 19 | RUN npm install --verbose 20 | 21 | # Copy the rest of your application files 22 | COPY . . 23 | 24 | # Ensure proper permissions (if needed, adjust as necessary) 25 | RUN chmod -R 755 /app 26 | 27 | # Command to run the application 28 | CMD ["node", "index.js"] 29 | -------------------------------------------------------------------------------- /Dockerfile-local: -------------------------------------------------------------------------------- 1 | # Use the official Node.js image based on Alpine Linux 2 | # The --platform flag is used here to make sure we use a multi-platform base image 3 | FROM --platform=$BUILDPLATFORM node:24.0-slim AS base 4 | 5 | # Update and install git (if needed for your application) 6 | #RUN apk update && \ 7 | # apk upgrade 8 | 9 | # Clear npm cache to reduce image size and avoid potential issues 10 | RUN npm cache clean --force 11 | 12 | # Set the working directory for your application 13 | WORKDIR /app 14 | 15 | # Copy package.json and package-lock.json first to leverage Docker cache 16 | COPY package*.json ./ 17 | 18 | # Install application dependencies 19 | RUN npm install --verbose 20 | 21 | # Copy the rest of your application files 22 | COPY . . 23 | 24 | # Ensure proper permissions (if needed, adjust as necessary) 25 | RUN chmod -R 755 /app 26 | 27 | # Command to run the application 28 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | ** Installation ** 2 | 3 | In order to get the bot up and running you need to do the following. 4 | 5 | * Pre-requisit: * 6 | 7 | 1: #Slack-group with admin rights 8 | 2: A server / machine that can run node.js 9 | 3: A working Sonos player configured with Spotify 10 | 4: You need to know the IP of your Sonos player. 11 | 12 | Note: In order to get Text-to-speach working you need to know the IP of the host running SlackONOS as well as having a TCP port (in setting) open for traffic from the Sonos to the device. 13 | 14 | #Slack 15 | 16 | Create a bot in #Slack. 17 | You need to give it a name, and write down the API Token. You will need this in your setting file later. 18 | Hit "Save Integration" and you are done in #Slack. 19 | 20 | Optional you can give the bot some nice namne, icon etc. 21 | 22 | For an icon, have a look in zenmusic/doc/images/ZenMusic.png 23 | 24 | 25 | Node.js - Slackonos! 26 | 27 | This guide assume you have git, node.js and NPM installed, and running linux =) 28 | 29 | Run the following commands in your terminal: 30 | 31 | cd /opt 32 | git clone https://github.com/htilly/zenmusic.git 33 | cd zenmusic 34 | npm install 35 | cp config.json.example config.json 36 | 37 | Almost there... some simple configuration first. 38 | Edit config.json with your favorit text editor. 39 | 40 | Replace: 41 | 42 | "music-admin" - with the #slack channel you want the bot to respond to admin commands.` 43 | "music" - with the #slack channel you want the bot to be a DJ in =)` 44 | "IP_TO_SONOS" - with the (static) IP of you sonos player / controller.` 45 | "SLACK:TOKEN" - with the token you got for the bot in "Slack.` 46 | "US" - with the country that you use Spotify. 47 | 75 - with the maximum volume you can set the Sonos to via "setvolume" in #Slack 48 | 49 | And last thing... start the bot! 50 | 51 | Type: 52 | node index.js 53 | 54 | You should see something like: 55 | 56 | [Sat Apr 30 2016 21:44:10 GMT+0200 (CEST)] INFO Connecting... Welcome to Slack. You are @zenmusicbot of Schibsted Media Group You are in: #music As well as: music-admin 57 | 58 | 59 | 60 | ** Known Issues ** 61 | 62 | If you for any reason get the following 500 error in the logs: 63 | 64 | Error: HTTP response code 500 for "urn:schemas-upnp-org:service:AVTransport:1#AddURIToQueue" 65 | 66 | Please try to change the following code in index.js. You need to change this in three places. 67 | Don´t know what this is happening, but it has been confirmed on at least two systems :/ 68 | 69 | // Old version.. New is supposed to fix 500 problem... 70 | // sonos.addSpotifyQueue(spid, function (err, res) { 71 | 72 | sonos.addSpotify(spid, function (err, res) { 73 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/htilly/zenmusic/workflows/Node.js%20CI/badge.svg)](https://github.com/htilly/zenmusic/actions?query=workflow%3A%22Node.js+CI%22) 2 | [![Known Vulnerabilities](https://snyk.io/test/github/htilly/zenmusic/badge.svg)](https://snyk.io/test/github/htilly/zenmusic) 3 | 4 | 5 | # SlackONOS 6 | **Slack / Sonos / Spotify / Node.js - Control Sonos through #Slack** 7 | 8 | 9 | *Screenshot* 10 | 11 | ![ScreenShot](http://raw.github.com/htilly/zenmusic/master/doc/images/Screenshot.png) 12 | 13 | 14 | 15 | (🔴) *** config.json MUST be moved to config folder.*** (🔴) 16 | 17 | **What is it?** 18 | 19 | It´s a #slack-bot that control Sonos (and spotify). Highly democratic bot :) 20 | Uses https://github.com/bencevans/node-sonos to controll Sonos. 21 | 22 | **What do I need in order to get it to work?** 23 | 24 | 1: A Sonos player (configured with Spotify). 25 | 2: A slack-bot configured in #Slack 26 | 3: A server running node.js 27 | 4: Know the IP of your Sonos. Preferably a static one. 28 | 5: A valid spotify account with Client ID & Client Secret. Head over to: https://developer.spotify.com/dashboard/applications to set it up. Enter the data in the config.json file. 29 | 30 | **Installation** 31 | 32 | DOCKER COMPOSE 33 | 34 | (you must point to the config.json, example can be found [here](https://github.com/htilly/zenmusic/blob/master/config/config.json.example)) 35 | 36 | ``` 37 | services: 38 | slackonos: 39 | container_name: slackonos 40 | image: htilly/slackonos:latest 41 | restart: unless-stopped 42 | volumes: 43 | - /PATH_TO_CONFIG_FILE_FOLDER:/app/config 44 | ``` 45 | 46 | 47 | **Firewall settings** 48 | 49 | Server running the index.js needs to be able to talk to the Sonos on port 1400 (TCP) 50 | Sonos needs to be configured and setup with Spotify and have access to internet. 51 | 52 | **Configuration** 53 | You must provide the token of your Slack bot and the IP of your Sonos in either config.json (see config.json.example), as arguments or as environment variables. 54 | Examples: 55 | ```bash 56 | node index.js --token "MySlackBotToken" --sonos "192.168.0.1" 57 | ``` 58 | or 59 | ```bash 60 | token="MySlackBotToken" sonos="192.168.0.1" node index.js 61 | ``` 62 | You can also provide any of the other variables from config.json.example as arguments or environment variables. 63 | The blacklist can be provided as either an array in config.json, or as a comma-separated string when using arguments or environment variables. 64 | 65 | Logo for the bot in #Slack can be found at "doc/images/ZenMusic.png 66 | 67 | **What can it do?** 68 | 69 | It will queue you requests and play it.. However if X amount of people for any strange reason doesn't like the current track, it will listen to the command "**gong**" and eventually skip to the next track. 70 | 71 | It also future some admin commands like "setvolume", "next", "stop" etc. 72 | 73 | List of commands (just type help in the channel) 74 | 75 | * `help` : this list 76 | * `current` : list current track 77 | * `search` _text_ : search for a track, does NOT add it to the queue 78 | * `add` _text_ : Add song to the queue and start playing if idle. 79 | * `append` _text_ : Append a song to the previous playlist and start playing the same list again. 80 | * `gong` : The current track is bad! Vote for skipping this track 81 | * `gongcheck` : How many gong votes there are currently, as well as who has GONGED. 82 | * `vote` _exactSongTitle_ : Vote for a specific song title in the queue. 83 | * `volume` : view current volume 84 | * `list` : list current queue 85 | * `status` : show the current status 86 | 87 | **ADMIN FUNCTIONS** 88 | 89 | * `flush` : flush the current queue 90 | * `setvolume` _number_ : sets volume 91 | * `play` : play track 92 | * `stop` : stop life 93 | * `next` : play next track 94 | * `previous` : play previous track 95 | * `shuffle` : shuffles playlist 96 | 97 | **Info** 98 | 99 | Please use it to get some music in the office / home. 100 | 101 | We would appreciate if you drop a comment or send a pm... and please feel free to add / change stuff!! Much appreciated! 102 | 103 | **Installation** 104 | 105 | For installation, see the file INSTALL. 106 | 107 | Or have a look at the Wiki. 108 | https://github.com/htilly/zenmusic/wiki 109 | 110 | 111 | **KnownBugs** 112 | 113 | ~~* Validate add / unique track doesn´t work. I.e - You can add same track 10 times in a row.~~ 114 | ~~* Vote does not move track in queue.~~ 115 | 116 | **ToDo** 117 | 118 | * Code cleaning! =) 119 | * Simple "view" window of what is happening in the channel. I.e. - Put on big-screen of what is happening in #music 120 | * Backend DB 121 | * Text-to-speech. 122 | * Now playing. Announce when starting a new song. 123 | * When asking for "Stat" show most played songs and most active users. 124 | * When local playlist is empty -> fallback and start playing "$playlist", i.e. Spotify topp 100. 125 | * Limit consecutive song additions by non-admin 126 | * Delete range of songs from queue 127 | * Implement some code-testing 128 | 129 | **DONE** 130 | * Vote to flush entire queue 131 | * New vote system including votecheck 132 | * Restrict songs already in the queue 133 | * Now works with latest async version of node-sonos. 134 | * Add spotify playlist 135 | * Added "bestof" - Add the topp 10 tracks by selected artist. 136 | * Added gongcheck - Thanks to "Warren Harding" 137 | * Added blacklist function. Enter usernames in "blacklist.txt". 138 | * Updated 'node-sonos' with getQueue and addSpotify. See: https://github.com/bencevans/node-sonos/commit/bfb995610c8aa20bda09e370b0f5d31ba0caa6a0 139 | * Added new function, search. 140 | * Added new function, Append. Reuse the old queue and add new track to the end of it. 141 | * Admin: Delete entire queue. 142 | * Regularly delete the entries from the queue when the song has been played. 143 | * When adding a new track, do the following logic: 144 | * Check "status". (fixed.. sort of..) 145 | * If "playing", do a "list". Delete all songs in the queue with lower number than the current track. Then add song to queue. 146 | * If "sleep" clear queue, add song to queue and do "play". 147 | * Add clear-queue functionality. 148 | * Fix queue function. 149 | * Fix GONG function. If X Gongs within X sec then next. 150 | * Admin commands from i.e."swe-music-admin". 151 | * Vote - If +1 in slack then move in queue. (sort of) 152 | * Ask "what is playing". 153 | * 154 | -------------------------------------------------------------------------------- /build.txt: -------------------------------------------------------------------------------- 1 | 54 2 | -------------------------------------------------------------------------------- /config/config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "adminChannel" : "music-admin", 3 | "standardChannel" : "music", 4 | "gongLimit": 3, 5 | "voteImmuneLimit": 6, 6 | "voteLimit": 6, 7 | "voteTimeLimitMinutes" : 2, 8 | "flushVoteLimit" : 6, 9 | "sonos" : "IP_TO_SONOS", 10 | "token" : "SLACK:TOKEN", 11 | "webPort" : "8080", 12 | "ipAddress" : "IP_HOST", 13 | "market" : "US", 14 | "maxVolume" : 75, 15 | "blacklist" : "@justin.bieber, @one.direction", 16 | "spotifyClientId": "", 17 | "spotifyClientSecret": "", 18 | "logLevel": "info" 19 | } 20 | -------------------------------------------------------------------------------- /doc/images/ExampleZenMusic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htilly/SlackONOS/3d75335cb829d59f76624865ce768940b84e190e/doc/images/ExampleZenMusic.png -------------------------------------------------------------------------------- /doc/images/Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htilly/SlackONOS/3d75335cb829d59f76624865ce768940b84e190e/doc/images/Screenshot.png -------------------------------------------------------------------------------- /doc/images/ZenMusic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htilly/SlackONOS/3d75335cb829d59f76624865ce768940b84e190e/doc/images/ZenMusic.png -------------------------------------------------------------------------------- /docker-compose-example.yml: -------------------------------------------------------------------------------- 1 | services: 2 | slackonos: 3 | container_name: slackonos 4 | image: slackonos:latest 5 | restart: unless-stopped 6 | volumes: 7 | - PATH_TO_CONFIG_FOLDER/slackonos/config:/app/config 8 | ports: 9 | - "8080:8080" # Needed for TTS -------------------------------------------------------------------------------- /gong.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Oops, my finger slipped! 3 | I’m doing this for everyone’s sake. 4 | Let’s pretend that never happened. 5 | Next! Because we deserve better. 6 | Even silence is better than this. 7 | I’m saving your ears from bleeding. 8 | We’ll just call that a momentary lapse in judgment. 9 | Who hurt you? Because this song did. 10 | What was that noise? Oh, just this song. 11 | Next song, please. My dignity demands it. 12 | Nobody needs to suffer through that. 13 | Wow, that was aggressively mediocre. 14 | Why does this even exist? 15 | Do we really need to subject ourselves to this? 16 | Goodbye, forever! 17 | What kind of monster added this? 18 | That was a cry for help in song form. 19 | Did a cat walk across the keyboard to make that? 20 | For the love of music, let's move on. 21 | That was less music and more torture. 22 | I’m sorry, I can’t do that to us. 23 | Music shouldn’t feel like a punishment. 24 | This song just committed a crime against humanity. 25 | Can we just skip the part where we suffer? 26 | Please tell me that was a joke. 27 | I refuse to acknowledge this as music. 28 | This is why aliens won’t visit us. 29 | Whoever added this owes us an apology. 30 | Sorry, my ears have standards. 31 | This song belongs in the bin. 32 | That was… unpleasant. 33 | Consider that song permanently banned. 34 | What in the name of bad taste was that? 35 | Even the skip button winced. 36 | Why does this song exist to torment us? 37 | Is this some kind of cruel joke? 38 | We deserve better. Skipping. 39 | No one asked for that. 40 | That was more painful than stepping on a LEGO. 41 | This song is now dead to me. 42 | Just doing my duty as a responsible human. 43 | Let’s spare everyone the misery. 44 | I’m not enduring that for another second. 45 | And we’re moving on… quickly. 46 | Who greenlit this atrocity? 47 | Let’s never speak of this again. 48 | Someone, somewhere owes me an apology. 49 | I’d rather listen to a dial-up modem. 50 | I refuse to put up with this. 51 | This song is a violation of the Geneva Convention. 52 | Consider this a mercy kill. 53 | This song belongs on a Do Not Play list. 54 | It’s not you, it’s… actually, it’s you. 55 | Next track, for the love of all things decent. 56 | That was a crime against eardrums. 57 | Why do bad songs happen to good people? 58 | Skipping for the sake of humanity. 59 | Let's not subject ourselves to that. 60 | That song was pure suffering. 61 | Is there a fine for playing this? 62 | I'd rather endure a root canal. 63 | Moving on before I lose faith in music. 64 | That was less music, more noise pollution. 65 | We are NOT doing that again. 66 | Let’s pretend this never happened. 67 | My soul just cringed. 68 | I think I just aged ten years. 69 | Deleting that from my memory. 70 | Why is this even an option? 71 | This song is an offense to all senses. 72 | That was almost impressively bad. 73 | Can we all agree to never play that again? 74 | That song is now on my enemies list. 75 | Let's not put ourselves through that. 76 | My ears are officially in protest. 77 | Whoever added that should be ashamed. 78 | Why does this feel like a personal attack? 79 | Skipping this before it does more damage. 80 | That was not a vibe. 81 | Did a toddler compose this? 82 | This is why we can’t have nice things. 83 | I’d rather sit in silence. 84 | Who’s idea was this? 85 | That was more annoying than a mosquito. 86 | Music should make you feel, not suffer. 87 | We’re better than this. 88 | I didn't sign up for this. 89 | Skipping for the sake of my sanity. 90 | That song just ruined my mood. 91 | Let’s not do that again. 92 | Why does this feel like a punishment? 93 | That song just tried to kill the vibe. 94 | Even my skip button is disgusted. 95 | Is this supposed to be music? 96 | Skipping before it gets worse. 97 | Let's all agree to forget that happened. 98 | That was not worth the trauma. 99 | This is why I have trust issues. 100 | I’d rather hear nails on a chalkboard. 101 | Skipping this travesty. 102 | ] -------------------------------------------------------------------------------- /helpText.txt: -------------------------------------------------------------------------------- 1 | Current commands! 2 | === === === === === === === 3 | `add` *text* : add song to the queue and start playing if idle. Will start with a fresh queue. 4 | `addalbum` *text* : add an album to the queue and start playing if idle. Will start with a fresh queue. 5 | `bestof` : *text* : add top 10 tracks by the artist 6 | `status` : show current status of Sonos 7 | `current` : list current track 8 | `search` *text* : search for a track, does NOT add it to the queue 9 | `searchalbum` *text* : search for an album, does NOT add it to the queue 10 | `searchplaylist` *text* : search for a playlist, does NOT add it to the queue 11 | `addplaylist` *text* : add a playlist to the queue and start playing if idle. Will start with a fresh queue. 12 | `append` *text* : append a song to the previous playlist and start playing the same list again. 13 | `vote` *number* : vote for a track to be played next!!! Votes neeed *{{voteLimit}}* :rocket: 14 | `votecheck` : how many votes there are currently, as well as who has voted. 15 | `gong` : current track is bad! *{{gongLimit}}* gongs will skip the track 16 | `gongcheck` : how many gong votes there are currently, as well as who has gonged. 17 | `voteimmune` *number* : vote to make the current track immune to gong. *{{voteLimit}}* votes will make it immune 18 | `flushvote` : vote to flush the queue. *{{flushVoteLimit}}* votes will flush the queue :toilet: 19 | `upnext` : show the next track to be played 20 | `volume` : view current volume 21 | `list` : list current queue -------------------------------------------------------------------------------- /helpTextAdmin.txt: -------------------------------------------------------------------------------- 1 | *------ ADMIN FUNCTIONS ------* 2 | `debug` : show debug info for Spotify, Node and Sonos 3 | `flush` : flush the current queue 4 | `remove` *number* : removes the track in the queue 5 | `move` *number* *number* : move the given track to desired position in the queue 6 | `setvolume` *number* : sets volume 7 | `play` : play track 8 | `stop` : stop life 9 | `say` *text* : text to speech 10 | `pause` : pause life 11 | `resume` : resume after pause 12 | `next` : play next track 13 | `previous` : play previous track 14 | `shuffle` : set playmode to shuffle 15 | `normal` : set playmode to normal 16 | `blacklist` : show users on blacklist 17 | `blacklist add @username` : add `@username` to the blacklist 18 | `blacklist del @username` : remove `@username` from the blacklist 19 | `stats` : show statisics for users and commands. Add username as input for specific users stats. 20 | === === === SlackONOS@GitHub === === === -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const os = require('os'); 3 | const mp3Duration = require('mp3-duration'); 4 | const path = require('path'); 5 | const GTTS = require('gtts'); // Import the gtts library 6 | const config = require('nconf') 7 | const winston = require('winston') 8 | const Spotify = require('./spotify') 9 | const utils = require('./utils') 10 | const process = require('process') 11 | const parseString = require('xml2js').parseString 12 | const http = require('http') 13 | const gongMessage = fs.readFileSync('gong.txt', 'utf8').split('\n').filter(Boolean); 14 | const voteMessage = fs.readFileSync('vote.txt', 'utf8').split('\n').filter(Boolean); 15 | const ttsMessage = fs.readFileSync('tts.txt', 'utf8').split('\n').filter(Boolean); 16 | const buildNumber = Number(fs.readFileSync('build.txt', 'utf8').split('\n').filter(Boolean)[0]); 17 | const { execSync } = require('child_process'); 18 | const gongBannedTracks = {}; 19 | const SLACK_API_URL_LIST = 'https://slack.com/api/conversations.list'; 20 | const userActionsFile = path.join(__dirname, 'config/userActions.json'); 21 | 22 | 23 | 24 | config.argv() 25 | .env() 26 | .file({ 27 | file: 'config/config.json' 28 | }) 29 | .defaults({ 30 | adminChannel: 'music-admin', 31 | standardChannel: 'music', 32 | gongLimit: 3, 33 | voteImmuneLimit: 3, 34 | voteLimit: 3, 35 | flushVoteLimit: 6, 36 | maxVolume: '75', 37 | market: 'US', 38 | blacklist: [], 39 | searchLimit: 7, 40 | webPort: 8181, 41 | logLevel: 'info' 42 | }) 43 | 44 | // const adminChannel = config.get('adminChannel'); 45 | const gongLimit = config.get('gongLimit') 46 | const voteImmuneLimit = config.get('voteImmuneLimit') 47 | const voteLimit = config.get('voteLimit') 48 | const flushVoteLimit = config.get('flushVoteLimit') 49 | const token = config.get('token') 50 | const maxVolume = config.get('maxVolume') 51 | const market = config.get('market') 52 | const voteTimeLimitMinutes = config.get('voteTimeLimitMinutes') 53 | const clientId = config.get('spotifyClientId') 54 | const clientSecret = config.get('spotifyClientSecret') 55 | const searchLimit = config.get('searchLimit') 56 | const logLevel = config.get('logLevel') 57 | const sonosIp = config.get('sonos') 58 | const webPort = config.get('webPort') 59 | let ipAddress = config.get('ipAddress') 60 | 61 | 62 | 63 | 64 | 65 | let blacklist = config.get('blacklist') 66 | if (!Array.isArray(blacklist)) { 67 | blacklist = blacklist.replace(/\s*(,|^|$)\s*/g, '$1').split(/\s*,\s*/) 68 | } 69 | 70 | /* Initialize Logger */ 71 | const logger = winston.createLogger({ 72 | level: logLevel, 73 | format: winston.format.combine( 74 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // Add timestamp 75 | winston.format.json() 76 | ), 77 | transports: [ 78 | new winston.transports.Console({ 79 | format: winston.format.combine( 80 | winston.format.colorize(), 81 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // Add timestamp to console logs 82 | winston.format.printf(({ timestamp, level, message }) => { 83 | return `[${timestamp}] ${level}: ${message}`; 84 | }) 85 | ) 86 | }) 87 | ] 88 | }); 89 | 90 | /* Initialize Sonos */ 91 | const SONOS = require('sonos') 92 | const Sonos = SONOS.Sonos 93 | const sonos = new Sonos(sonosIp) 94 | 95 | if (market !== 'US') { 96 | sonos.setSpotifyRegion(SONOS.SpotifyRegion.EU) 97 | logger.info('Setting Spotify region to EU...') 98 | logger.info('Market is: ' + market) 99 | } 100 | 101 | /* Initialize Spotify instance */ 102 | const spotify = Spotify({ 103 | clientId: clientId, 104 | clientSecret: clientSecret, 105 | market: market, 106 | logger: logger 107 | }) 108 | 109 | let gongCounter = 0; 110 | let gongScore = {}; 111 | const gongLimitPerUser = 1; 112 | 113 | let voteImmuneCounter = 0; 114 | const voteImmuneLimitPerUser = 1; 115 | let voteImmuneUsers = {}; // Track users who have voted for each track for vote immune 116 | 117 | 118 | let voteImmuneScore = {}; 119 | let gongBanned = false; 120 | let gongTrack = ''; // What track was a GONG called on 121 | 122 | let voteCounter = 0; 123 | const voteLimitPerUser = 4; 124 | let voteScore = {}; 125 | 126 | let flushVoteCounter = 0; 127 | const flushVoteLimitPerUser = 1; 128 | let flushVoteScore = {}; 129 | 130 | let trackVoteCount = {}; // Initialize vote count object 131 | let trackVoteUsers = {}; // Track users who have voted for each track 132 | 133 | 134 | if (!token) { 135 | throw new Error('SLACK_API_TOKEN is not set'); 136 | } 137 | 138 | const { RTMClient } = require('@slack/rtm-api'); 139 | const { WebClient } = require('@slack/web-api'); 140 | const rtm = new RTMClient(token, { 141 | logLevel: 'error', 142 | dataStore: false, 143 | autoReconnect: true, 144 | autoMark: true 145 | }); 146 | const web = new WebClient(token); 147 | 148 | let botUserId; 149 | 150 | (async () => { 151 | try { 152 | // Fetch the bot's user ID 153 | const authResponse = await web.auth.test(); 154 | botUserId = authResponse.user_id; 155 | 156 | await rtm.start(); 157 | } catch (error) { 158 | logger.error(`Error starting RTMClient: ${error}`); 159 | } 160 | })(); 161 | 162 | rtm.on('message', (event) => { 163 | // Ignore messages from the bot itself 164 | if (event.user === botUserId) { 165 | return; 166 | } 167 | 168 | const { type, ts, text, channel, user } = event; 169 | 170 | logger.info(event.text); 171 | logger.info(event.channel); 172 | logger.info(event.user); 173 | 174 | logger.info(`Received: ${type} ${channel} <@${user}> ${ts} "${text}"`); 175 | 176 | if (type !== 'message' || !text || !channel) { 177 | const errors = [ 178 | type !== 'message' ? `unexpected type ${type}.` : null, 179 | !text ? 'text was undefined.' : null, 180 | !channel ? 'channel was undefined.' : null 181 | ].filter(Boolean).join(' '); 182 | 183 | logger.error(`Could not respond. ${errors}`); 184 | return false; 185 | } 186 | 187 | processInput(text, channel, `<@${user}>`); 188 | }); 189 | 190 | rtm.on('error', (error) => { 191 | logger.error(`RTMClient error: ${error}`); 192 | }); 193 | 194 | 195 | function delay(ms) { 196 | return new Promise(resolve => setTimeout(resolve, ms)); 197 | } 198 | 199 | 200 | // Proper delay function 201 | const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 202 | 203 | // Function to fetch the channel IDs 204 | async function _lookupChannelID() { 205 | let allChannels = []; 206 | let nextCursor; 207 | let retryAfter = 0; 208 | let backoff = 1; // Exponential backoff starts at 1 second 209 | 210 | try { 211 | do { 212 | // Wait if rate limited 213 | if (retryAfter > 0) { 214 | logger.warn(`Rate limit hit! Retrying after ${retryAfter} seconds...`); 215 | logger.info(`Wait start: ${new Date().toISOString()}`); 216 | await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); 217 | retryAfter = 0; // Reset retryAfter 218 | } 219 | 220 | // Fetch channels 221 | const url = `${SLACK_API_URL_LIST}?limit=1000&types=public_channel,private_channel${nextCursor ? `&cursor=${nextCursor}` : ''}`; 222 | const response = await fetch(url, { 223 | method: 'GET', 224 | headers: { 225 | 'Authorization': `Bearer ${token}`, 226 | 'Content-Type': 'application/json', 227 | }, 228 | }); 229 | 230 | logger.info(`Response status for fetching channels: ${response.status}`); 231 | 232 | if (response.status === 429) { 233 | retryAfter = parseInt(response.headers.get('retry-after')) || backoff; 234 | backoff = Math.min(backoff * 2, 60); // Exponential backoff up to 60s 235 | continue; 236 | } 237 | 238 | const data = await response.json(); 239 | if (!data.ok) throw new Error(`Slack API Error: ${data.error}`); 240 | 241 | // Extract and add channels 242 | if (data.channels) allChannels = allChannels.concat(data.channels); 243 | 244 | nextCursor = data.response_metadata?.next_cursor; 245 | 246 | // Reset backoff after successful response 247 | backoff = 1; 248 | 249 | } while (nextCursor); 250 | 251 | logger.info('Fetched channels: ' + allChannels.map(channel => channel.name).join(', ')); 252 | 253 | // Fetch Admin and Standard channel IDs 254 | const adminChannelName = config.get('adminChannel').replace('#', ''); 255 | const standardChannelName = config.get('standardChannel').replace('#', ''); 256 | 257 | logger.info('Admin channel (in config): ' + adminChannelName); 258 | logger.info('Standard channel (in config): ' + standardChannelName); 259 | 260 | const adminChannelInfo = allChannels.find(channel => channel.name === adminChannelName); 261 | if (!adminChannelInfo) throw new Error(`Admin channel "${adminChannelName}" not found`); 262 | 263 | const standardChannelInfo = allChannels.find(channel => channel.name === standardChannelName); 264 | if (!standardChannelInfo) throw new Error(`Standard channel "${standardChannelName}" not found`); 265 | 266 | // Set the global variables 267 | global.adminChannel = adminChannelInfo.id; 268 | global.standardChannel = standardChannelInfo.id; 269 | 270 | logger.info('Admin channelID: ' + global.adminChannel); 271 | logger.info('Standard channelID: ' + global.standardChannel); 272 | 273 | } catch (error) { 274 | logger.error(`Error fetching channels: ${error.message}`); 275 | } 276 | } 277 | 278 | // Call the function to lookup channel IDs 279 | _lookupChannelID(); 280 | 281 | 282 | // TEST CODE: Force Slack API Rate Limit 283 | //async function testRateLimit() { 284 | // const requests = 500; // Adjust the number of simultaneous requests 285 | // 286 | // logger.info(`Starting ${requests} parallel API calls to test rate limit...`); 287 | // 288 | // const promises = []; 289 | // for (let i = 0; i < requests; i++) { 290 | // promises.push(_lookupChannelID()); 291 | // } 292 | 293 | // await Promise.all(promises); 294 | // logger.info('Finished parallel API calls.'); 295 | //} 296 | 297 | // Run the rate limit test 298 | //(async () => { 299 | // logger.info('Starting API rate limit test...'); 300 | // await testRateLimit(); 301 | // logger.info('Rate limit test completed.'); 302 | //})(); 303 | 304 | 305 | 306 | function processInput(text, channel, userName) { 307 | var input = text.split(' ') 308 | var term = input[0].toLowerCase() 309 | var matched = true 310 | logger.info('term: ' + term) 311 | 312 | switch (term) { 313 | case 'add': 314 | _add(input, channel, userName) 315 | break 316 | case 'addalbum': 317 | _addalbum(input, channel, userName) 318 | break 319 | case 'bestof': 320 | _bestof(input, channel, userName) 321 | break 322 | case 'append': 323 | _append(input, channel, userName) 324 | break 325 | case 'searchplaylist': 326 | _searchplaylist(input, channel, userName) 327 | break 328 | case 'searchalbum': 329 | _searchalbum(input, channel) 330 | break 331 | case 'addplaylist': 332 | _addplaylist(input, channel, userName) 333 | break 334 | case 'search': 335 | _search(input, channel, userName) 336 | break 337 | case 'current': 338 | case 'wtf': 339 | _currentTrack(channel) 340 | break 341 | case 'dong': 342 | case ':gong:': 343 | case ':gun:': 344 | case 'gong': 345 | _gong(channel, userName) 346 | break 347 | case 'gongcheck': 348 | _gongcheck(channel, userName) 349 | break 350 | case 'voteimmune': 351 | _voteImmune(input, channel, userName) 352 | break 353 | case 'vote': 354 | case ':star:': 355 | _vote(input, channel, userName) 356 | break 357 | case 'voteimmunecheck': 358 | _voteImmunecheck(channel, userName) 359 | break 360 | case 'votecheck': 361 | _votecheck(channel, userName) 362 | break 363 | case 'list': 364 | case 'ls': 365 | case 'playlist': 366 | _showQueue(channel) 367 | break 368 | case 'upnext': 369 | _upNext(channel) 370 | break 371 | case 'volume': 372 | _getVolume(channel) 373 | break 374 | case 'flushvote': 375 | _flushvote(channel, userName) 376 | break 377 | case 'size': 378 | case 'count': 379 | case 'count(list)': 380 | _countQueue(channel) 381 | break 382 | case 'status': 383 | _status(channel) 384 | break 385 | case 'help': 386 | _help(input, channel) 387 | break 388 | default: 389 | matched = false 390 | break 391 | case 'flush': 392 | _flush(input, channel, userName) 393 | break 394 | } 395 | 396 | if (!matched && channel === global.adminChannel) { 397 | switch (term) { 398 | case 'debug': 399 | _debug(channel, userName) 400 | break 401 | case 'next': 402 | _nextTrack(channel, userName) 403 | break 404 | case 'stop': 405 | _stop(input, channel, userName) 406 | break 407 | case 'flush': 408 | _flush(input, channel, userName) 409 | break 410 | case 'play': 411 | _play(input, channel, userName) 412 | break 413 | case 'pause': 414 | _pause(input, channel, userName) 415 | break 416 | case 'playpause': 417 | case 'resume': 418 | _resume(input, channel, userName) 419 | break 420 | case 'previous': 421 | _previous(input, channel, userName) 422 | break 423 | case 'shuffle': 424 | _shuffle(input, channel, userName) 425 | break 426 | case 'normal': 427 | _normal(input, channel, userName) 428 | break 429 | case 'setvolume': 430 | _setVolume(input, channel, userName) 431 | break 432 | case 'blacklist': 433 | _blacklist(input, channel, userName) 434 | break 435 | case 'test': 436 | _addToSpotifyPlaylist(input, channel) 437 | break 438 | case 'remove': 439 | _removeTrack(input, channel) 440 | break 441 | case 'thanos': 442 | case 'snap': 443 | _purgeHalfQueue(input, channel) 444 | break 445 | case 'listimmune': 446 | _listImmune(channel) 447 | break 448 | case 'tts': 449 | case 'say': 450 | _tts(input, channel) 451 | break 452 | case 'move': 453 | case 'mv': 454 | _moveTrackAdmin(input, channel, userName) 455 | break 456 | case 'stats': 457 | _stats(input, channel, userName) 458 | break 459 | default: 460 | } 461 | } 462 | } 463 | 464 | 465 | 466 | 467 | function _slackMessage(message, id) { 468 | if (rtm.connected) { 469 | rtm.sendMessage(message, id) 470 | } else { 471 | logger.info(message) 472 | } 473 | } 474 | 475 | const userCache = {}; 476 | 477 | async function _checkUser(userId) { 478 | try { 479 | // Clean the userId if wrapped in <@...> 480 | userId = userId.replace(/[<@>]/g, ""); 481 | 482 | // Check if user info is already in cache 483 | if (userCache[userId]) { 484 | return userCache[userId]; 485 | } 486 | 487 | // Fetch user info from Slack API 488 | const result = await web.users.info({ user: userId }); 489 | if (result.ok && result.user) { 490 | userCache[userId] = result.user.name; // Cache the user info 491 | return result.user.name; 492 | } else { 493 | logger.error('User not found: ' + userId); 494 | return null; 495 | } 496 | } catch (error) { 497 | if (error.data && error.data.error === 'user_not_found') { 498 | logger.error('User not found: ' + userId); 499 | } else { 500 | logger.error('Error fetching user info: ' + error); 501 | } 502 | return null; 503 | } 504 | } 505 | 506 | 507 | 508 | 509 | function _getVolume(channel) { 510 | sonos.getVolume().then(vol => { 511 | logger.info('The volume is: ' + vol); 512 | _slackMessage('Currently blasting at ' + vol + ' dB _(ddB)_', channel); 513 | }).catch(err => { 514 | logger.error('Error occurred: ' + err); 515 | }); 516 | } 517 | 518 | function _setVolume(input, channel, userName) { 519 | _logUserAction(userName, 'setVolume'); 520 | if (channel !== global.adminChannel) { 521 | return; 522 | } 523 | 524 | const vol = Number(input[1]); 525 | 526 | if (isNaN(vol)) { 527 | _slackMessage('Nope.', channel); 528 | return; 529 | } 530 | 531 | logger.info('Volume is: ' + vol); 532 | if (vol > maxVolume) { 533 | _slackMessage("That's a bit extreme, " + userName + '... lower please.', channel); 534 | return; 535 | } 536 | 537 | setTimeout(() => { 538 | sonos.setVolume(vol).then(() => { 539 | logger.info('The volume is set to: ' + vol); 540 | _getVolume(channel); 541 | }).catch(err => { 542 | logger.error('Error occurred while setting volume: ' + err); 543 | }); 544 | }, 1000); 545 | } 546 | 547 | 548 | function _countQueue(channel, cb) { 549 | sonos.getQueue().then(result => { 550 | if (cb) { 551 | return cb(result.total) 552 | } 553 | _slackMessage(`${result.total} songs in the queue`, channel) 554 | }).catch(err => { 555 | logger.error(err) 556 | if (cb) { 557 | return cb(null, err) 558 | } 559 | _slackMessage('Error getting queue length', channel) 560 | }) 561 | } 562 | 563 | async function _showQueue(channel) { 564 | try { 565 | const result = await sonos.getQueue(); 566 | // logger.info('Current queue: ' + JSON.stringify(result, null, 2)) 567 | _status(channel, function (state) { 568 | logger.info('_showQueue, got state = ' + state) 569 | }); 570 | _currentTrack(channel, function (err, track) { 571 | if (!result) { 572 | logger.debug(result); 573 | _slackMessage('Seems like the queue is empty... Have you tried adding a song?!', channel); 574 | } 575 | if (err) { 576 | logger.error(err); 577 | } 578 | var message = 'Total tracks in queue: ' + result.total + '\n====================\n'; 579 | logger.info('Total tracks in queue: ' + result.total); 580 | const tracks = []; 581 | 582 | result.items.map( 583 | function (item, i) { 584 | let trackTitle = item.title; 585 | if (_isTrackGongBanned(item.title)) { 586 | tracks.push(':lock: ' + '_#' + i + '_ ' + trackTitle + ' by ' + item.artist); 587 | // trackTitle = ':lock:' + trackTitle; 588 | } else if (item.title === track.title) { 589 | trackTitle = '*' + trackTitle + '*'; 590 | } else { 591 | trackTitle = '_' + trackTitle + '_'; 592 | } 593 | 594 | if (item.title === track.title) { 595 | tracks.push(':notes: ' + '_#' + i + '_ ' + trackTitle + ' by ' + item.artist); 596 | } else { 597 | tracks.push('>_#' + i + '_ ' + trackTitle + ' by ' + item.artist); 598 | } 599 | } 600 | ); 601 | for (var i in tracks) { 602 | message += tracks[i] + '\n'; 603 | if (i > 0 && Math.floor(i % 100) === 0) { 604 | _slackMessage(message, channel); 605 | message = ''; 606 | } 607 | } 608 | if (message) { 609 | _slackMessage(message, channel); 610 | } 611 | }); 612 | } catch (err) { 613 | logger.error('Error fetching queue: ' + err); 614 | } 615 | } 616 | 617 | function _upNext(channel) { 618 | sonos.getQueue().then(result => { 619 | // logger.debug('Current queue: ' + JSON.stringify(result, null, 2)); 620 | 621 | _currentTrack(channel, function (err, track) { 622 | if (!result || !result.items || result.items.length === 0) { 623 | logger.debug('Queue is empty or undefined'); 624 | _slackMessage('Seems like the queue is empty... Have you tried adding a song?!', channel); 625 | return; 626 | } 627 | if (err) { 628 | logger.error('Error getting current track: ' + err); 629 | return; 630 | } 631 | if (!track) { 632 | logger.debug('Current track is undefined'); 633 | _slackMessage('No current track is playing.', channel); 634 | return; 635 | } 636 | 637 | // logger.info('Got current track: ' + JSON.stringify(track, null, 2)); 638 | 639 | var message = 'Upcoming tracks\n====================\n'; 640 | let tracks = []; 641 | let currentIndex = track.queuePosition; 642 | 643 | // Add current track and upcoming tracks to the tracks array 644 | result.items.forEach((item, i) => { 645 | if (i >= currentIndex && i <= currentIndex + 5) { 646 | tracks.push('_#' + i + '_ ' + "_" + item.title + "_" + ' by ' + item.artist); 647 | } 648 | }); 649 | 650 | for (var i in tracks) { 651 | message += tracks[i] + '\n'; 652 | } 653 | 654 | if (message) { 655 | _slackMessage(message, channel); 656 | } 657 | }); 658 | }).catch(err => { 659 | logger.error('Error fetching queue: ' + err); 660 | }); 661 | } 662 | 663 | 664 | 665 | 666 | // Vote section. All function related to voting. 667 | 668 | let voteTimer = null; 669 | const voteTimeLimit = voteTimeLimitMinutes * 60 * 1000; // Convert minutes to milliseconds 670 | 671 | function _flushvote(channel, userName) { 672 | _logUserAction(userName, 'flushvote'); 673 | logger.info('_flushvote...'); 674 | 675 | if (!(userName in flushVoteScore)) { 676 | flushVoteScore[userName] = 0; 677 | } 678 | 679 | if (flushVoteScore[userName] >= flushVoteLimitPerUser) { 680 | _slackMessage('Are you trying to cheat, ' + userName + '? DENIED!', channel); 681 | } else { 682 | flushVoteScore[userName] += 1; 683 | flushVoteCounter++; 684 | logger.info('flushVoteCounter: ' + flushVoteCounter); 685 | 686 | if (flushVoteCounter === 1) { 687 | // Start the timer on the first vote 688 | voteTimer = setTimeout(() => { 689 | flushVoteCounter = 0; 690 | flushVoteScore = {}; 691 | _slackMessage('Voting period ended.', channel); 692 | logger.info('Voting period ended... Guess the playlist isn´t that bad after all!!'); 693 | }, voteTimeLimit); 694 | _slackMessage("Voting period started for a flush of the queue... You have " + voteTimeLimitMinutes + " minutes to gather " + flushVoteLimit + " votes !!", channel); 695 | logger.info('Voting period started!!'); 696 | } 697 | 698 | _slackMessage('This is VOTE ' + "*" + flushVoteCounter + "*" + '/' + flushVoteLimit + ' for a full flush of the playlist!!', channel); 699 | 700 | if (flushVoteCounter >= flushVoteLimit) { 701 | clearTimeout(voteTimer); // Clear the timer if the vote limit is reached 702 | _slackMessage('The votes have spoken! Flushing the queue...:toilet:', channel); 703 | try { 704 | sonos.flush(); 705 | } catch (error) { 706 | logger.error('Error flushing the queue: ' + error); 707 | } 708 | flushVoteCounter = 0; 709 | flushVoteScore = {}; 710 | } 711 | } 712 | } 713 | 714 | function _gong(channel, userName) { 715 | _logUserAction(userName, 'gong'); 716 | logger.info('_gong...') 717 | _currentTrackTitle(channel, function (err, track) { 718 | if (err) { 719 | logger.error(err) 720 | } 721 | logger.info('_gong > track: ' + track) 722 | 723 | if (_isTrackGongBanned(track)) { 724 | logger.info('Track is gongBanned: ' + track); 725 | _slackMessage('Sorry ' + userName + ', the people have voted and this track cannot be gonged...', channel) 726 | return 727 | } 728 | 729 | var randomMessage = gongMessage[Math.floor(Math.random() * gongMessage.length)]; 730 | logger.info('gongMessage: ' + randomMessage) 731 | 732 | if (!(userName in gongScore)) { 733 | gongScore[userName] = 0 734 | } 735 | 736 | if (gongScore[userName] >= gongLimitPerUser) { 737 | _slackMessage('Are you trying to cheat, ' + userName + '? DENIED!', channel) 738 | } else { 739 | if (userName in voteImmuneScore) { 740 | _slackMessage('Having regrets, ' + userName + "? We're glad you came to your senses...", channel) 741 | } 742 | 743 | gongScore[userName] += 1 744 | gongCounter++ 745 | _slackMessage(randomMessage + ' This is GONG ' + gongCounter + '/' + gongLimit + ' for ' + "*" + track + "*", channel) 746 | if (gongCounter >= gongLimit) { 747 | _slackMessage('The music got GONGED!!', channel) 748 | _gongplay('play', channel) 749 | gongCounter = 0 750 | gongScore = {} 751 | } 752 | } 753 | }) 754 | } 755 | 756 | 757 | function _voteImmune(input, channel, userName) { 758 | var voteNb = Number(input[1]); // Use the input number directly 759 | logger.info('voteNb: ' + voteNb); 760 | 761 | sonos.getQueue().then(result => { 762 | logger.info('Current queue: ' + JSON.stringify(result, null, 2)); 763 | let trackFound = false; 764 | let voteTrackName = null; 765 | 766 | for (var i in result.items) { 767 | var queueTrack = parseInt(result.items[i].id.split('/')[1]) - 1; // Adjust for 0-based index 768 | if (voteNb === queueTrack) { 769 | voteTrackName = result.items[i].title; 770 | trackFound = true; 771 | break; 772 | } 773 | } 774 | 775 | if (trackFound) { 776 | if (!(userName in voteImmuneScore)) { 777 | voteImmuneScore[userName] = 0; 778 | } 779 | 780 | if (voteImmuneScore[userName] >= voteImmuneLimitPerUser) { 781 | _slackMessage('Are you trying to cheat, ' + userName + '? DENIED!', channel); 782 | } else { 783 | if (!(voteNb in voteImmuneUsers)) { 784 | voteImmuneUsers[voteNb] = new Set(); 785 | } 786 | 787 | if (voteImmuneUsers[voteNb].has(userName)) { 788 | _slackMessage('You have already voted for this track, ' + userName + '.', channel); 789 | } else { 790 | voteImmuneScore[userName] += 1; 791 | voteImmuneCounter++; 792 | voteImmuneUsers[voteNb].add(userName); 793 | 794 | _slackMessage('This is VOTE ' + voteImmuneCounter + '/' + voteImmuneLimit + ' for ' + "*" + voteTrackName + "*", channel); 795 | if (voteImmuneCounter >= voteImmuneLimit) { 796 | _slackMessage('This track is now immune to GONG! (just this once)', channel); 797 | voteImmuneCounter = 0; 798 | voteImmuneScore = {}; 799 | voteImmuneUsers[voteNb].clear(); // Clear the users who voted for this track 800 | gongBannedTracks[voteTrackName] = true; // Mark the track as gongBanned 801 | } 802 | } 803 | } 804 | } else { 805 | _slackMessage('Track not found in the queue.', channel); 806 | } 807 | }).catch(err => { 808 | logger.error('Error occurred while fetching the queue: ' + err); 809 | }); 810 | } 811 | 812 | 813 | function _isTrackGongBanned(trackName) { 814 | return gongBannedTracks[trackName] === true; 815 | } 816 | 817 | function _listImmune(channel) { 818 | const gongBannedTracksList = Object.keys(gongBannedTracks); 819 | if (gongBannedTracksList.length === 0) { 820 | _slackMessage('No tracks are currently immune.', channel); 821 | } else { 822 | const message = 'Immune Tracks:\n' + gongBannedTracksList.join('\n'); 823 | _slackMessage(message, channel); 824 | } 825 | } 826 | 827 | 828 | 829 | function _vote(input, channel, userName) { 830 | _logUserAction(userName, 'vote'); 831 | 832 | var randomMessage = voteMessage[Math.floor(Math.random() * voteMessage.length)]; 833 | logger.info('voteMessage: ' + randomMessage); 834 | 835 | var voteNb = Number(input[1]); // Use the input number directly 836 | logger.info('voteNb: ' + voteNb); 837 | 838 | sonos.getQueue().then(result => { 839 | logger.info('Current queue: ' + JSON.stringify(result, null, 2)); 840 | let trackFound = false; 841 | let voteTrackName = null; 842 | 843 | for (var i in result.items) { 844 | var queueTrack = parseInt(result.items[i].id.split('/')[1]) - 1; // Adjust for 0-based index 845 | if (voteNb === queueTrack) { 846 | voteTrackName = result.items[i].title; 847 | trackFound = true; 848 | break; 849 | } 850 | } 851 | 852 | if (trackFound) { 853 | if (!(userName in voteScore)) { 854 | voteScore[userName] = 0; 855 | } 856 | 857 | if (voteScore[userName] >= voteLimitPerUser) { 858 | _slackMessage('Are you trying to cheat, ' + userName + '? DENIED!', channel); 859 | } else { 860 | if (!(voteNb in trackVoteUsers)) { 861 | trackVoteUsers[voteNb] = new Set(); 862 | } 863 | 864 | if (trackVoteUsers[voteNb].has(userName)) { 865 | _slackMessage('You have already voted for this track, ' + userName + '.', channel); 866 | } else { 867 | voteScore[userName] += 1; 868 | voteCounter++; 869 | trackVoteUsers[voteNb].add(userName); 870 | 871 | if (!(voteNb in trackVoteCount)) { 872 | trackVoteCount[voteNb] = 0; 873 | } 874 | trackVoteCount[voteNb] += 1; 875 | 876 | logger.info('Track ' + voteTrackName + ' has received ' + trackVoteCount[voteNb] + ' votes.'); 877 | 878 | _slackMessage('This is VOTE ' + trackVoteCount[voteNb] + '/' + voteLimit + ' for ' + "*" + voteTrackName + "*", channel); 879 | if (trackVoteCount[voteNb] >= voteLimit) { 880 | logger.info('Track ' + voteTrackName + ' has reached the vote limit.'); 881 | _slackMessage(randomMessage, channel); 882 | 883 | voteCounter = 0; 884 | voteScore = {}; 885 | trackVoteUsers[voteNb].clear(); // Clear the users who voted for this track 886 | 887 | sonos.currentTrack().then(track => { 888 | var currentTrackPosition = track.queuePosition; 889 | var trackPosition = voteNb; 890 | 891 | const startingIndex = trackPosition; // No need to adjust for 0-based index here 892 | const numberOfTracks = 1; 893 | const insertBefore = currentTrackPosition + 1; 894 | const updateId = 0; 895 | 896 | sonos.reorderTracksInQueue(startingIndex, numberOfTracks, insertBefore, updateId).then(success => { 897 | logger.info('Moved track to position: ' + insertBefore); 898 | }).catch(err => { 899 | logger.error('Error occurred: ' + err); 900 | }); 901 | }).catch(err => { 902 | logger.error('Error occurred: ' + err); 903 | }); 904 | } 905 | } 906 | } 907 | } else { 908 | _slackMessage('Track not found in the queue.', channel); 909 | } 910 | }).catch(err => { 911 | logger.error('Error occurred while fetching the queue: ' + err); 912 | }); 913 | } 914 | 915 | 916 | 917 | 918 | 919 | function _votecheck(channel) { 920 | logger.info('Checking vote status for all tracks:'); 921 | sonos.getQueue().then(result => { 922 | const trackNames = {}; 923 | for (var i in result.items) { 924 | var queueTrack = result.items[i].id.split('/')[1]; 925 | var trackName = result.items[i].title; 926 | trackNames[queueTrack] = trackName; 927 | } 928 | 929 | for (const trackId in trackVoteCount) { 930 | if (trackVoteCount.hasOwnProperty(trackId)) { 931 | const voteCount = trackVoteCount[trackId]; 932 | const trackName = trackNames[trackId] || 'Unknown Track'; 933 | const voters = Object.keys(voteScore).filter(user => voteScore[user] > 0 && voteScore[user] < voteLimitPerUser); 934 | const votedBy = voters.map(user => `${user}`).join(', '); 935 | _slackMessage("*" + trackName + "*" + ' has received ' + voteCount + ' votes. Voted by: ' + votedBy, channel); 936 | } 937 | } 938 | }).catch(err => { 939 | logger.error('Error occurred while fetching the queue: ' + err); 940 | }); 941 | } 942 | 943 | function _voteImmunecheck(channel, userName) { 944 | logger.info('_voteImmunecheck...') 945 | 946 | _currentTrackTitle(channel, function (err, track) { 947 | logger.info('_voteImmunecheck > track: ' + track) 948 | 949 | _slackMessage('VOTE is currently ' + voteImmuneCounter + '/' + voteImmuneLimit + ' for ' + track, channel) 950 | var voters = Object.keys(voteImmuneScore) 951 | if (voters.length > 0) { 952 | _slackMessage('Voted by ' + voters.join(','), channel) 953 | } 954 | if (err) { 955 | logger.error(err) 956 | } 957 | }) 958 | } 959 | 960 | function _gongcheck(channel, userName) { 961 | logger.info('_gongcheck...') 962 | 963 | _currentTrackTitle(channel, function (err, track) { 964 | if (err) { 965 | logger.error(err) 966 | } 967 | logger.info('_gongcheck > track: ' + track) 968 | 969 | _slackMessage('GONG is currently ' + gongCounter + '/' + gongLimit + ' for ' + track, channel) 970 | var gongers = Object.keys(gongScore) 971 | if (gongers.length > 0) { 972 | _slackMessage('Gonged by ' + gongers.join(','), channel) 973 | } 974 | }) 975 | } 976 | 977 | function _voteImmunecheck(channel, userName) { 978 | logger.info('_voteImmunecheck...') 979 | 980 | _currentTrackTitle(channel, function (err, track) { 981 | logger.info('_voteImmunecheck > track: ' + track) 982 | 983 | _slackMessage('VOTE is currently ' + voteImmuneCounter + '/' + voteImmuneLimit + ' for ' + track, channel) 984 | var voters = Object.keys(voteImmuneScore) 985 | if (voters.length > 0) { 986 | _slackMessage('Voted by ' + voters.join(','), channel) 987 | } 988 | if (err) { 989 | logger.error(err) 990 | } 991 | }) 992 | } 993 | 994 | 995 | //End of vote section 996 | 997 | 998 | 999 | 1000 | async function _moveTrackAdmin(input, channel, userName) { 1001 | if (channel !== global.adminChannel) { 1002 | _slackMessage('You do not have permission to move tracks.', channel); 1003 | return; 1004 | } 1005 | 1006 | if (input.length < 3) { 1007 | _slackMessage('Please provide both the track number and the desired position.', channel); 1008 | return; 1009 | } 1010 | 1011 | const trackNb = parseInt(input[1], 10); // Use the original input value 1012 | const desiredPosition = parseInt(input[2], 10); // Use the original input value 1013 | 1014 | if (isNaN(trackNb) || isNaN(desiredPosition)) { 1015 | _slackMessage('Invalid track number or desired position.', channel); 1016 | return; 1017 | } 1018 | 1019 | logger.info(`_moveTrackAdmin: Moving track ${trackNb} to position ${desiredPosition}`); 1020 | 1021 | try { 1022 | const queue = await sonos.getQueue(); 1023 | logger.info('Current queue: ' + JSON.stringify(queue, null, 2)); 1024 | 1025 | const track = queue.items.find(item => item.id.split('/')[1] === String(trackNb + 1)); // Adjust for 1-based index 1026 | if (!track) { 1027 | _slackMessage(`Track number ${trackNb} not found in the queue.`, channel); 1028 | return; 1029 | } 1030 | 1031 | const currentTrackPosition = parseInt(track.id.split('/')[1], 10); 1032 | logger.info('Current track position: ' + currentTrackPosition); 1033 | 1034 | // Define the parameters 1035 | const startingIndex = currentTrackPosition; // Current position of the track 1036 | const numberOfTracks = 1; // Moving one track 1037 | const insertBefore = desiredPosition + 1; // Adjust for 1-based index 1038 | 1039 | // Move the track to the desired position using reorderTracksInQueue 1040 | await sonos.reorderTracksInQueue(startingIndex, numberOfTracks, insertBefore, 0); 1041 | logger.info(`Moved track ${trackNb} to position ${desiredPosition}`); 1042 | _slackMessage(`Moved track ${trackNb} to position ${desiredPosition}`, channel); 1043 | } catch (err) { 1044 | logger.error('Error occurred: ' + err); 1045 | _slackMessage('Error moving track. Please try again later.', channel); 1046 | } 1047 | } 1048 | 1049 | 1050 | 1051 | 1052 | function _previous(input, channel, userName) { 1053 | _logUserAction(userName, 'previous'); 1054 | if (channel !== global.adminChannel) { 1055 | return 1056 | } 1057 | sonos.previous(function (err, previous) { 1058 | logger.error(err + ' ' + previous) 1059 | }) 1060 | } 1061 | 1062 | function _help(input, channel) { 1063 | const helpTextPath = path.join(__dirname, 'helpText.txt'); 1064 | const helpTextPathAdmin = path.join(__dirname, 'helpTextAdmin.txt'); 1065 | const adminMessage = fs.readFileSync(helpTextPathAdmin, 'utf8'); 1066 | let message = fs.readFileSync(helpTextPath, 'utf8'); 1067 | 1068 | // Read configuration values 1069 | const gongLimit = config.get('gongLimit'); 1070 | const voteLimit = config.get('voteLimit'); 1071 | const flushVoteLimit = config.get('flushVoteLimit'); 1072 | const maxVolume = config.get('maxVolume'); 1073 | 1074 | // Replace placeholders in help text 1075 | message = message.replace(/{{gongLimit}}/g, gongLimit); 1076 | message = message.replace(/{{voteLimit}}/g, voteLimit); 1077 | message = message.replace(/{{flushVoteLimit}}/g, flushVoteLimit); 1078 | message = message.replace(/{{maxVolume}}/g, maxVolume); 1079 | 1080 | if (channel === global.adminChannel) { 1081 | message += '\n' + adminMessage; 1082 | } 1083 | _slackMessage(message, channel); 1084 | } 1085 | 1086 | function _play(input, channel, userName, state) { 1087 | _logUserAction(userName, 'play'); 1088 | if (channel !== global.adminChannel) { 1089 | return 1090 | } 1091 | sonos.selectQueue() 1092 | sonos.play().then(result => { 1093 | _status(channel, state) 1094 | logger.info('Started playing - ' + result) 1095 | }).catch(err => { 1096 | logger.info('Error occurred: ' + err) 1097 | }) 1098 | } 1099 | 1100 | function _playInt(input, channel) { 1101 | sonos.selectQueue() 1102 | sonos.play().then(result => { 1103 | _status(channel, state) 1104 | logger.info('Started playing - ' + result) 1105 | }).catch(err => { 1106 | logger.info('Error occurred: ' + err) 1107 | }) 1108 | } 1109 | 1110 | 1111 | 1112 | function _stop(input, channel, userName, state) { 1113 | _logUserAction(userName, 'stop'); 1114 | if (channel !== global.adminChannel) { 1115 | return 1116 | } 1117 | sonos.stop().then(result => { 1118 | _status(channel, state) 1119 | logger.info('Stoped playing - ' + result) 1120 | }).catch(err => { 1121 | logger.error('Error occurred: ' + err) 1122 | }) 1123 | } 1124 | 1125 | function _pause(input, channel, state) { 1126 | if (channel !== global.adminChannel) { 1127 | return 1128 | } 1129 | sonos.pause().then(result => { 1130 | _status(channel, state) 1131 | logger.info('Pause playing - ' + result) 1132 | }).catch(err => { 1133 | logger.error('Error occurred: ' + err) 1134 | }) 1135 | } 1136 | 1137 | function _resume(input, channel, userName, state) { 1138 | _logUserAction(userName, 'resume'); 1139 | if (channel !== global.adminChannel) { 1140 | return 1141 | } 1142 | sonos.play().then(result => { 1143 | setTimeout(() => _status(channel, state), 500) 1144 | logger.info('Resume playing - ' + result) 1145 | }).catch(err => { 1146 | logger.error('Error occurred: ' + err) 1147 | }) 1148 | } 1149 | 1150 | function _flush(input, channel, userName) { 1151 | _logUserAction(userName, 'flush'); 1152 | if (channel !== global.adminChannel) { 1153 | _slackMessage('Where you supposed to type _flushvote_?', channel) 1154 | return 1155 | } 1156 | sonos.flush().then(result => { 1157 | logger.info('Flushed queue: ' + JSON.stringify(result, null, 2)) 1158 | _slackMessage('Sonos queue is clear.', channel) 1159 | }).catch(err => { 1160 | logger.error('Error flushing queue: ' + err) 1161 | }) 1162 | } 1163 | 1164 | function _flushInt(input, channel) { 1165 | sonos.flush().then(result => { 1166 | logger.info('Flushed queue: ' + JSON.stringify(result, null, 2)) 1167 | }).catch(err => { 1168 | logger.error('Error flushing queue: ' + err) 1169 | }) 1170 | } 1171 | 1172 | function _shuffle(input, channel, byPassChannelValidation) { 1173 | if (channel !== global.adminChannel && !byPassChannelValidation) { 1174 | return 1175 | } 1176 | sonos.setPlayMode('SHUFFLE').then(success => { 1177 | console.log('Changed playmode to shuffle') 1178 | _slackMessage('Changed the playmode to shuffle....', channel) 1179 | }).catch(err => { 1180 | console.log('Error occurred %s', err) 1181 | }) 1182 | } 1183 | 1184 | function _normal(input, channel, byPassChannelValidation) { 1185 | if (channel !== global.adminChannel && !byPassChannelValidation) { 1186 | return 1187 | } 1188 | sonos.setPlayMode('NORMAL').then(success => { 1189 | console.log('Changed playmode to normal') 1190 | _slackMessage('Changed the playmode to normal....', channel) 1191 | }).catch(err => { 1192 | console.log('Error occurred %s', err) 1193 | }) 1194 | } 1195 | 1196 | 1197 | async function _gongplay() { 1198 | try { 1199 | const mediaInfo = await sonos.avTransportService().GetMediaInfo(); 1200 | // logger.info('Current mediaInfo: ' + JSON.stringify(mediaInfo)); 1201 | 1202 | const positionInfo = await sonos.avTransportService().GetPositionInfo(); 1203 | // logger.info('Current positionInfo: ' + JSON.stringify(positionInfo)); 1204 | logger.info('Current Position: ' + JSON.stringify(positionInfo.Track)); 1205 | 1206 | // await delay(2000); // Ensure delay is awaited 1207 | 1208 | await sonos.play('https://github.com/htilly/zenmusic/raw/master/sound/gong.mp3') 1209 | .then(() => { 1210 | logger.info('Playing notification...'); 1211 | }) 1212 | .catch(error => { 1213 | console.error('Error occurred: ' + error); 1214 | }); 1215 | 1216 | const nextToPlay = positionInfo.Track + 1; 1217 | logger.info('Next to play: ' + nextToPlay); 1218 | 1219 | await delay(4000); // Ensure delay is awaited 1220 | 1221 | try { 1222 | await sonos.selectTrack(nextToPlay); 1223 | logger.info('Track selected successfully.'); 1224 | } catch (error) { 1225 | logger.info('Jumping to next track failed: ' + error); 1226 | } 1227 | 1228 | // Add a one-second delay before playing 1229 | // await delay(1000); 1230 | 1231 | await sonos.play(); 1232 | } catch (error) { 1233 | logger.error('Error in _gongplay: ' + error); 1234 | } finally { 1235 | 1236 | 1237 | 1238 | await sonos.getQueue().then(result => { 1239 | logger.info('Total tracks in queue: ' + result.total) 1240 | let removeGong = result.total 1241 | 1242 | sonos.removeTracksFromQueue([removeGong], 1).then(success => { 1243 | logger.info('Removed track with index: ', removeGong) 1244 | }).catch(err => { 1245 | logger.error('Error occurred ' + err) 1246 | }) 1247 | }) 1248 | } 1249 | 1250 | } 1251 | 1252 | function _removeTrack(input, channel, byPassChannelValidation) { 1253 | if (channel !== global.adminChannel && !byPassChannelValidation) { 1254 | return 1255 | } 1256 | var trackNb = parseInt(input[1]) + 1 1257 | sonos.removeTracksFromQueue(trackNb, 1).then(success => { 1258 | logger.info('Removed track with index: ', trackNb) 1259 | }).catch(err => { 1260 | logger.error('Error occurred ' + err) 1261 | }) 1262 | var message = 'Removed track with index: ' + input[1] 1263 | _slackMessage(message, channel) 1264 | } 1265 | 1266 | function _nextTrack(channel, byPassChannelValidation) { 1267 | if (channel !== global.adminChannel && !byPassChannelValidation) { 1268 | return 1269 | } 1270 | sonos.next().then(success => { 1271 | logger.info('_nextTrack > Playing Netx track.. ') 1272 | }).catch(err => { 1273 | logger.error('Error occurred', err) 1274 | }) 1275 | } 1276 | 1277 | function _currentTrack(channel, cb, err) { 1278 | sonos.currentTrack().then(track => { 1279 | logger.info('Got current track: ' + track) 1280 | if (err) { 1281 | logger.error(err + ' ' + track) 1282 | if (cb) { 1283 | return cb(err, null) 1284 | } 1285 | } else { 1286 | if (cb) { 1287 | return cb(null, track) 1288 | } 1289 | 1290 | logger.info(track) 1291 | var fmin = '' + Math.floor(track.duration / 60) 1292 | fmin = fmin.length === 2 ? fmin : '0' + fmin 1293 | var fsec = '' + track.duration % 60 1294 | fsec = fsec.length === 2 ? fsec : '0' + fsec 1295 | 1296 | var pmin = '' + Math.floor(track.position / 60) 1297 | pmin = pmin.length === 2 ? pmin : '0' + pmin 1298 | var psec = '' + track.position % 60 1299 | psec = psec.length === 2 ? psec : '0' + psec 1300 | 1301 | var message = `We're rocking out to *${track.artist}* - *${track.title}* (${pmin}:${psec}/${fmin}:${fsec})` 1302 | _slackMessage(message, channel) 1303 | } 1304 | }).catch(err => { 1305 | logger.error('Error occurred ' + err) 1306 | }) 1307 | } 1308 | 1309 | function _currentTrackTitle(channel, cb) { 1310 | sonos.currentTrack().then(track => { 1311 | logger.info('Got current track ' + track) 1312 | 1313 | var _track = '' 1314 | 1315 | _track = track.title 1316 | logger.info('_currentTrackTitle > title: ' + _track) 1317 | logger.info('_currentTrackTitle > gongTrack: ' + gongTrack) 1318 | 1319 | if (gongTrack !== '') { 1320 | if (gongTrack !== _track) { 1321 | logger.info('_currentTrackTitle > different track, reset!') 1322 | gongCounter = 0 1323 | gongScore = {} 1324 | gongBanned = false 1325 | voteImmuneCounter = 0 1326 | voteImmuneScore = {} 1327 | } else { 1328 | logger.info('_currentTrackTitle > gongTrack is equal to _track') 1329 | } 1330 | } else { 1331 | logger.info('_currentTrackTitle > gongTrack is empty') 1332 | } 1333 | gongTrack = _track 1334 | logger.info('_currentTrackTitle > last step, got _track as: ' + _track) 1335 | 1336 | cb(null, _track) 1337 | }).catch(err => { 1338 | logger.error('Error occurred: ' + err) 1339 | }) 1340 | } 1341 | 1342 | async function _add(input, channel, userName) { 1343 | _logUserAction(userName, 'add'); 1344 | 1345 | logger.info('userName = ' + userName); 1346 | 1347 | // Fetch the actual userName using the _checkUser function 1348 | const actualUserName = await _checkUser(userName.replace(/[<@>]/g, '')); 1349 | 1350 | // Format the userName to match the blacklist entries 1351 | const formattedUserName = actualUserName ? actualUserName.toLowerCase() : userName.toLowerCase(); 1352 | 1353 | logger.info('Checking if user is blacklisted: ' + formattedUserName); 1354 | 1355 | // Check if the user is on the blacklist 1356 | logger.info('Checking the following user: ' + formattedUserName); 1357 | logger.info('Current blacklist: ' + JSON.stringify(blacklist, null, 2)); 1358 | if (blacklist.includes(formattedUserName)) { 1359 | logger.info('User is on the blacklist: ' + formattedUserName); 1360 | _slackMessage("Well... this is awkward.. U're *blacklisted*! ", channel); 1361 | return; 1362 | } 1363 | 1364 | let data, message; 1365 | try { 1366 | [data, message] = await spotify.searchSpotify(input, channel, formattedUserName, 1); 1367 | } catch (error) { 1368 | logger.error('Error searching Spotify: ' + error); 1369 | _slackMessage('Error searching Spotify.', channel); 1370 | return; 1371 | } 1372 | 1373 | if (!data || !data.tracks || !data.tracks.items || data.tracks.items.length === 0) { 1374 | _slackMessage('No tracks found for the given input.', channel); 1375 | return; 1376 | } 1377 | if (message) { 1378 | _slackMessage(message, channel); 1379 | } 1380 | if (!data) { 1381 | return; 1382 | } 1383 | 1384 | // Define the URI, album image, and track name before using them 1385 | let trackItem = data.tracks.items[0]; 1386 | let uri = trackItem.uri; 1387 | let albumImg = trackItem.album.images[0].url; 1388 | let trackName = trackItem.artists[0].name + ' - ' + trackItem.name; 1389 | let titleName = trackItem.name; 1390 | 1391 | // Check Sonos state 1392 | sonos.getCurrentState().then(async state => { 1393 | logger.info('Got current state: ' + state); 1394 | 1395 | if (state === 'stopped') { 1396 | try { 1397 | await sonos.flush(); 1398 | logger.info('Flushed queue'); 1399 | await _addToSpotify(formattedUserName, uri, albumImg, trackName, channel); 1400 | logger.info('Adding track:' + trackName); 1401 | setTimeout(async () => { 1402 | await _playInt('play', channel); 1403 | logger.info('Started playing'); 1404 | }, 500); 1405 | } catch (err) { 1406 | logger.error('Error during flush or add to Spotify: ' + err); 1407 | } 1408 | } else if (state === 'playing') { 1409 | try { 1410 | const result = await sonos.getQueue(); 1411 | logger.info('Searching for duplicated track:' + titleName); 1412 | let trackFound = false; 1413 | for (var i in result.items) { 1414 | var queueTrack = result.items[i].title; 1415 | if (titleName === queueTrack) { 1416 | trackFound = true; 1417 | break; 1418 | } 1419 | } 1420 | if (trackFound) { 1421 | _slackMessage("Track already in queue.. letting it go this time " + formattedUserName + "....", channel); 1422 | } else { 1423 | logger.info('State: ' + state + ' - playing...'); 1424 | await _addToSpotify(formattedUserName, uri, albumImg, trackName, channel); 1425 | } 1426 | } catch (err) { 1427 | logger.error('Error fetching queue: ' + err); 1428 | } 1429 | } else if (state === 'paused') { 1430 | _slackMessage("SlackONOS is currently paused.. ask an admin to resume...", channel); 1431 | } 1432 | }).catch(err => { 1433 | logger.error('Error getting current state: ' + err); 1434 | }); 1435 | } 1436 | 1437 | function _addalbum(input, channel, userName) { 1438 | _logUserAction(userName, 'addalbum'); 1439 | 1440 | var [data, message] = spotify.searchSpotifyAlbum(input, channel, userName, 1) 1441 | if (message) { 1442 | _slackMessage(message, channel) 1443 | } 1444 | if (!data) { 1445 | return 1446 | } 1447 | 1448 | var uri = data.albums.items[0].uri 1449 | var trackName = data.albums.items[0].artists[0].name + ' - ' + data.albums.items[0].name 1450 | var albumImg = data.albums.items[0].images[2].url 1451 | 1452 | logger.info('Adding album: ' + trackName + ' with UID:' + uri) 1453 | 1454 | sonos.getCurrentState().then(state => { 1455 | logger.info('Got current state: ' + state) 1456 | 1457 | if (state === 'stopped') { 1458 | _flushInt(input, channel) 1459 | _addToSpotify(userName, uri, albumImg, trackName, channel) 1460 | logger.info('Adding album:' + trackName) 1461 | // Start playing the queue automatically. 1462 | setTimeout(() => _playInt('play', channel), 1000) 1463 | } else if (state === 'playing') { 1464 | // Add the track to playlist... 1465 | _addToSpotify(userName, uri, albumImg, trackName, channel) 1466 | } else if (state === 'paused') { 1467 | _addToSpotify(userName, uri, albumImg, trackName, channel, function () { 1468 | if (channel === global.adminChannel) { 1469 | _slackMessage('Sonos is currently PAUSED. Type `resume` to start playing...', channel) 1470 | } 1471 | }) 1472 | } else if (state === 'transitioning') { 1473 | _slackMessage("Sonos says it is 'transitioning'. We've got no idea what that means either...", channel) 1474 | } else if (state === 'no_media') { 1475 | _slackMessage("Sonos reports 'no media'. Any idea what that means?", channel) 1476 | } else { 1477 | _slackMessage("Sonos reports its state as '" + state + "'. Any idea what that means? I've got nothing.", channel) 1478 | } 1479 | }).catch(err => { 1480 | logger.error('Error occurred ' + err) 1481 | }) 1482 | } 1483 | 1484 | function _append(input, channel, userName) { 1485 | var [data, message] = spotify.searchSpotify(input, channel, userName, 1) 1486 | if (message) { 1487 | _slackMessage(message, channel) 1488 | } 1489 | if (!data) { 1490 | return 1491 | } 1492 | 1493 | var uri = data.tracks.items[0].uri 1494 | var albumImg = data.tracks.items[0].album.images[2].url 1495 | var trackName = data.tracks.items[0].artists[0].name + ' - ' + data.tracks.items[0].name 1496 | 1497 | logger.info('Adding track: ' + trackName + ' with UID:' + uri) 1498 | 1499 | sonos.getCurrentState().then(state => { 1500 | logger.info('Got current state: ' + state) 1501 | 1502 | if (state === 'stopped') { 1503 | logger.info('State: ' + state + ' - apending') 1504 | _addToSpotify(userName, uri, albumImg, trackName, channel) 1505 | logger.info('Adding track:' + trackName) 1506 | setTimeout(() => _playInt('play', channel), 1000) 1507 | } else if (state === 'playing') { 1508 | logger.info('State: ' + state + ' - adding...') 1509 | // Add the track to playlist... 1510 | _addToSpotify(userName, uri, albumImg, trackName, channel) 1511 | } else if (state === 'paused') { 1512 | logger.info('State: ' + state + ' - telling them no...') 1513 | _addToSpotify(userName, uri, albumImg, trackName, channel, function () { 1514 | if (channel === global.adminChannel) { 1515 | _slackMessage('Sonos is currently PAUSED. Type `resume` to start playing...', channel) 1516 | } 1517 | }) 1518 | } else if (state === 'transitioning') { 1519 | logger.info('State: ' + state + ' - no idea what to do') 1520 | _slackMessage("Sonos says it is 'transitioning'. We've got no idea what that means either...", channel) 1521 | } else if (state === 'no_media') { 1522 | _slackMessage("Sonos reports 'no media'. Any idea what that means?", channel) 1523 | } else { 1524 | _slackMessage("Sonos reports its state as '" + state + "'. Any idea what that means? I've got nothing.", channel) 1525 | } 1526 | }).catch(err => { 1527 | logger.error('Error occurred' + err) 1528 | }) 1529 | } 1530 | 1531 | async function _search(input, channel, userName) { 1532 | _logUserAction(userName, 'search'); 1533 | logger.info('_search ' + input) 1534 | var [data, message] = spotify.searchSpotify(input, channel, userName, searchLimit) 1535 | 1536 | if (message) { 1537 | _slackMessage(message, channel) 1538 | } 1539 | if (!data) { 1540 | return 1541 | } 1542 | 1543 | var trackNames = [] 1544 | for (var i = 1; i <= data.tracks.items.length; i++) { 1545 | var trackName = data.tracks.items[i - 1].name + ' - ' + data.tracks.items[i - 1].artists[0].name 1546 | trackNames.push(trackName) 1547 | } 1548 | 1549 | // Print the result... 1550 | message = userName + 1551 | ', I found the following track(s):\n```\n' + 1552 | trackNames.join('\n') + 1553 | '\n```\nIf you want to play it, use the `add` command..\n' 1554 | 1555 | _slackMessage(message, channel) 1556 | } 1557 | 1558 | async function _searchplaylist(input, channel, userName) { 1559 | _logUserAction(userName, 'searchplaylist'); 1560 | logger.info('_searchplaylist ' + input); 1561 | 1562 | // Ensure userName is defined; set a fallback if it’s undefined 1563 | userName = userName || "User"; 1564 | 1565 | const [data, message] = await spotify.searchSpotifyPlaylist(input, channel, userName, searchLimit); 1566 | 1567 | if (message) { 1568 | _slackMessage(message, channel); 1569 | } 1570 | 1571 | // Log the full response from Spotify API 1572 | // logger.info('Spotify API response: ' + JSON.stringify(data, null, 2)); 1573 | 1574 | if (!data || !data.playlists || !data.playlists.items || data.playlists.items.length === 0) { 1575 | logger.info('No playlists found for the given input.'); 1576 | _slackMessage('No playlists found for the given input.', channel); 1577 | return; 1578 | } 1579 | 1580 | // Filter out null items 1581 | const validPlaylists = data.playlists.items.filter(playlist => playlist !== null); 1582 | 1583 | // logger.info('Valid playlists found: ' + JSON.stringify(validPlaylists, null, 2)); 1584 | 1585 | var playlistNames = []; 1586 | for (let i = 0; i < validPlaylists.length; i++) { 1587 | const playlist = validPlaylists[i]; 1588 | const playlistName = playlist.name; 1589 | playlistNames.push(playlistName); 1590 | } 1591 | 1592 | // Print the result... 1593 | const resultMessage = userName + 1594 | ', I found the following playlist(s):\n```\n' + 1595 | playlistNames.join('\n') + 1596 | '\n```\nIf you want to play it, use the `addplaylist` command..\n'; 1597 | _slackMessage(resultMessage, channel); 1598 | } 1599 | 1600 | 1601 | 1602 | // FIXME - misnamed s/ add to sonos, appears funcionally identical to _addToSpotifyPlaylist 1603 | // function _addToSpotify (userName, uri, albumImg, trackName, channel, cb) { 1604 | function _addToSpotify(userName, uri, albumImg, trackName, channel, cb) { 1605 | logger.info('_addToSpotify ' + uri) 1606 | sonos.queue(uri).then(result => { 1607 | logger.info('Queued the following: ' + result) 1608 | 1609 | logger.info('queue:') 1610 | var queueLength = result.FirstTrackNumberEnqueued 1611 | logger.info('queueLength' + queueLength) 1612 | var message = 'Sure ' + 1613 | userName + 1614 | ', Added ' + 1615 | trackName + 1616 | ' to the queue!\n' + 1617 | albumImg + 1618 | '\nPosition in queue is ' + 1619 | queueLength 1620 | 1621 | _slackMessage(message, channel) 1622 | }).catch(err => { 1623 | _slackMessage('Error! No spotify account?', channel) 1624 | logger.error('Error occurred: ' + err) 1625 | }) 1626 | } 1627 | 1628 | function _addToSpotifyPlaylist(userName, uri, trackName, channel, cb) { 1629 | logger.info('TrackName:' + trackName) 1630 | logger.info('URI:' + uri) 1631 | sonos.queue(uri).then(result => { 1632 | logger.info('Queued the following: ' + result) 1633 | 1634 | var queueLength = result.FirstTrackNumberEnqueued 1635 | var message = 'Sure ' + 1636 | userName + 1637 | ', Added "' + 1638 | trackName + 1639 | '" to the queue!\n' + 1640 | '\nPosition in queue is ' + 1641 | queueLength 1642 | 1643 | _slackMessage(message, channel) 1644 | }).catch(err => { 1645 | _slackMessage('Error! No spotify account?', channel) 1646 | logger.error('Error occurred: ' + err) 1647 | }) 1648 | } 1649 | 1650 | function _addToSpotifyArtist(userName, trackName, spid, channel) { 1651 | logger.info('_addToSpotifyArtist spid:' + spid) 1652 | logger.info('_addToSpotifyArtist trackName:' + trackName) 1653 | 1654 | var uri = 'spotify:artistTopTracks:' + spid 1655 | sonos.queue(uri).then(result => { 1656 | logger.info('Queued the following: ' + result) 1657 | 1658 | var queueLength = result.FirstTrackNumberEnqueued 1659 | logger.info('queueLength' + queueLength) 1660 | var message = 'Sure ' + 1661 | userName + 1662 | ' Added 10 most popular tracks by "' + 1663 | trackName + 1664 | '" to the queue!\n' + 1665 | '\nPosition in queue is ' + 1666 | queueLength 1667 | 1668 | _slackMessage(message, channel) 1669 | }).catch(err => { 1670 | _slackMessage('Error! No spotify account?', channel) 1671 | logger.error('Error occurred: ' + err) 1672 | }) 1673 | } 1674 | 1675 | async function _addplaylist(input, channel, userName) { 1676 | _logUserAction(userName, 'addplaylist'); 1677 | logger.info('_addplaylist ' + input); 1678 | const [data, message] = await spotify.searchSpotifyPlaylist(input, channel, userName, searchLimit); 1679 | 1680 | if (message) { 1681 | _slackMessage(message, channel); 1682 | } 1683 | 1684 | if (!data || !data.playlists || !data.playlists.items || data.playlists.items.length === 0) { 1685 | _slackMessage('No playlists found for the given input.', channel); 1686 | return; 1687 | } 1688 | 1689 | // logger.info('Playlists found: ' + JSON.stringify(data.playlists.items, null, 2)); 1690 | 1691 | // Select the first playlist from the search results 1692 | const playlist = data.playlists.items[0]; 1693 | if (!playlist) { 1694 | logger.error('Playlist item is null or undefined at index: 0'); 1695 | _slackMessage('No valid playlists found for the given input.', channel); 1696 | return; 1697 | } 1698 | 1699 | const uri = playlist.uri; 1700 | const albumImg = playlist.images[2]?.url || 'No image available'; 1701 | const playlistName = playlist.name; 1702 | 1703 | logger.info('Adding playlist: ' + playlistName + ' with URI: ' + uri); 1704 | 1705 | sonos.getCurrentState().then(state => { 1706 | logger.info('Got current state: ' + state); 1707 | 1708 | if (state === 'stopped') { 1709 | _flushInt(input, channel); 1710 | logger.info('State: ' + state + ' - appending'); 1711 | _addToSpotify(userName, uri, albumImg, playlistName, channel); 1712 | logger.info('Adding playlist: ' + playlistName); 1713 | setTimeout(() => _playInt('play', channel), 1000); 1714 | } else if (state === 'playing') { 1715 | logger.info('State: ' + state + ' - adding...'); 1716 | _addToSpotify(userName, uri, albumImg, playlistName, channel); 1717 | } else if (state === 'paused') { 1718 | logger.info('State: ' + state + ' - telling them no...'); 1719 | _addToSpotify(userName, uri, albumImg, playlistName, channel, function () { 1720 | if (channel === global.adminChannel) { 1721 | _slackMessage('Sonos is currently PAUSED. Type `resume` to start playing...', channel); 1722 | } 1723 | }); 1724 | } 1725 | }).catch(err => { 1726 | logger.error('Error getting current state: ' + err); 1727 | }); 1728 | } 1729 | 1730 | 1731 | async function _bestof(input, channel, userName) { 1732 | _logUserAction(userName, 'bestof'); 1733 | 1734 | try { 1735 | const [data, message] = await spotify.searchSpotifyArtist(input, channel, userName, 1); 1736 | if (message) { 1737 | _slackMessage(message, channel); 1738 | return; 1739 | } 1740 | if (!data) { 1741 | logger.warn('No data returned from Spotify search.'); 1742 | return; 1743 | } 1744 | 1745 | logger.debug('Result in _bestof: ' + JSON.stringify(data, null, 2)); 1746 | 1747 | if (!data.artists || !data.artists.items || data.artists.items.length === 0) { 1748 | _slackMessage('No artists found matching the input.', channel); 1749 | return; 1750 | } 1751 | 1752 | const spid = data.artists.items[0].id; 1753 | const trackName = data.artists.items[0].name; 1754 | 1755 | logger.info('_bestof spid: ' + spid); 1756 | logger.info('_bestof trackName: ' + trackName); 1757 | 1758 | const state = await sonos.getCurrentState(); 1759 | logger.info('Got current Sonos state: ' + state); 1760 | 1761 | switch (state) { 1762 | case 'stopped': 1763 | logger.info('Sonos is stopped. Flushing queue and adding new artist.'); 1764 | await _flushInt(input, channel); 1765 | logger.info('Queue flushed successfully.'); 1766 | await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for 2 seconds 1767 | await _addToSpotifyArtist(userName, trackName, spid, channel); 1768 | logger.info('Artist added successfully. Starting playback...'); 1769 | setTimeout(() => _playInt('play', channel), 2000); // Slight delay to ensure queue updates 1770 | break; 1771 | case 'playing': 1772 | logger.info('Sonos is playing. Adding artist to the current queue.'); 1773 | await _addToSpotifyArtist(userName, trackName, spid, channel); 1774 | break; 1775 | case 'paused': 1776 | logger.info('Sonos is paused. Adding artist to queue.'); 1777 | await _addToSpotifyArtist(userName, trackName, spid, channel); 1778 | if (channel === global.adminChannel) { 1779 | _slackMessage('Sonos is currently paused. Type `resume` to start playing.', channel); 1780 | } 1781 | break; 1782 | default: 1783 | logger.warn(`Sonos state '${state}' is unknown or unsupported.`); 1784 | _slackMessage(`Sonos state '${state}' is not supported. Unable to process the request.`, channel); 1785 | break; 1786 | } 1787 | } catch (err) { 1788 | if (err.message.includes('upnp: statusCode 500')) { 1789 | logger.error('UPnP Error in _bestof function: ' + err.message); 1790 | _slackMessage('Failed to process the request due to a UPnP error. Please try again later.', channel); 1791 | } else { 1792 | logger.error('Error in _bestof function: ' + err.message); 1793 | _slackMessage('Failed to process the request. Please try again later.', channel); 1794 | } 1795 | } 1796 | } 1797 | 1798 | 1799 | 1800 | 1801 | 1802 | function _status(channel, state) { 1803 | sonos.getCurrentState().then(state => { 1804 | logger.info('Got current state: ' + state) 1805 | _slackMessage("Sonos state is '" + state + "'", channel) 1806 | }).catch(err => { 1807 | logger.error('Error occurred ' + err) 1808 | }) 1809 | } 1810 | 1811 | function getIPAddress() { 1812 | const interfaces = os.networkInterfaces(); 1813 | for (const name of Object.keys(interfaces)) { 1814 | for (const iface of interfaces[name]) { 1815 | if (iface.family === 'IPv4' && !iface.internal) { 1816 | return iface.address; 1817 | } 1818 | } 1819 | } 1820 | return 'IP address not found'; 1821 | } 1822 | 1823 | 1824 | 1825 | 1826 | 1827 | 1828 | 1829 | 1830 | async function _debug(channel, userName) { 1831 | _logUserAction(userName, 'debug'); 1832 | var url = 'http://' + sonosIp + ':1400/xml/device_description.xml'; 1833 | 1834 | function maskSensitiveInfo(value) { 1835 | if (typeof value === 'string' && value.length > 6) { 1836 | return value.slice(0, 3) + '--xxx-MASKED-xxx--' + value.slice(-3); 1837 | } 1838 | return value; 1839 | } 1840 | 1841 | function hexToIp(hex) { 1842 | return [ 1843 | parseInt(hex.slice(6, 8), 16), 1844 | parseInt(hex.slice(4, 6), 16), 1845 | parseInt(hex.slice(2, 4), 16), 1846 | parseInt(hex.slice(0, 2), 16) 1847 | ].join('.'); 1848 | } 1849 | 1850 | const isRunningInDocker = () => { 1851 | try { 1852 | const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8'); 1853 | if (cgroup.includes('docker')) { 1854 | return true; 1855 | } 1856 | } catch (err) {} 1857 | 1858 | try { 1859 | if (fs.existsSync('/.dockerenv')) { 1860 | return true; 1861 | } 1862 | } catch (err) {} 1863 | 1864 | return false; 1865 | }; 1866 | 1867 | const isDocker = isRunningInDocker(); 1868 | const dockerStatus = isDocker ? 'Running in Docker' : 'Not running in Docker'; 1869 | 1870 | let ipAddress = 'IP address not found'; 1871 | 1872 | ipAddress = getIPAddress(); 1873 | 1874 | const nodeVersion = JSON.stringify(process.versions); 1875 | 1876 | xmlToJson(url, async function (err, data) { 1877 | if (err) { 1878 | logger.error('Error occurred ' + err); 1879 | _slackMessage('SONOS device is offline or not responding.', channel); 1880 | return; 1881 | } 1882 | 1883 | // Log the full XML response for debugging 1884 | // logger.info('Full XML response: ' + JSON.stringify(data, null, 2)); 1885 | 1886 | const device = data.root.device[0]; 1887 | const sonosInfo = 1888 | '\n*Sonos Info*' + 1889 | `\nFriendly Name: ${device.friendlyName[0]}` + 1890 | `\nRoom Name: ${device.roomName[0]}` + 1891 | `\nDisplay Name: ${device.displayName[0]}` + 1892 | `\nModel Description: ${device.modelDescription[0]}` + 1893 | `\nModelNumber: ${device.modelNumber[0]}` + 1894 | `\nSerial Number: ${device.serialNum[0]}` + 1895 | `\nMAC Address: ${device.MACAddress[0] || 'undefined'}` + 1896 | `\nSW Version: ${device.softwareVersion[0] || 'undefined'}` + 1897 | `\nHW Version: ${device.hardwareVersion[0] || 'undefined'}` + 1898 | `\nAPI Version: ${device.apiVersion[0] || 'undefined'}`; 1899 | 1900 | const memoryUsage = process.memoryUsage(); 1901 | const formattedMemoryUsage = `\n*Memory Usage*:\n RSS: ${Math.round(memoryUsage.rss / 1024 / 1024)} MB`; 1902 | 1903 | const envVars = `\n*Environment Variables*:\n NODE_VERSION: ${process.env.NODE_VERSION || 'not set'}\n HOSTNAME: ${process.env.HOSTNAME || 'not set'}\n YARN_VERSION: ${process.env.YARN_VERSION || 'not set'}`; 1904 | 1905 | const sensitiveKeys = ['token', 'spotifyClientId', 'spotifyClientSecret', 'openaiApiKey']; 1906 | const configKeys = Object.keys(config.stores.file.store); 1907 | const configValues = configKeys 1908 | .map(key => { 1909 | const value = config.get(key); 1910 | return sensitiveKeys.includes(key) ? `${key}: ${maskSensitiveInfo(value)}` : `${key}: ${value}`; 1911 | }) 1912 | .join('\n'); 1913 | 1914 | // Identify missing configuration values 1915 | const defaultConfig = config.stores.defaults.store; 1916 | const missingConfigValues = Object.keys(defaultConfig) 1917 | .filter(key => !configKeys.includes(key)) 1918 | .map(key => { 1919 | const value = defaultConfig[key].value || defaultConfig[key]; 1920 | return value === 'literal' ? null : `${key}: ${value}`; 1921 | }) 1922 | .filter(line => line !== null) // Remove excluded entries 1923 | .join('\n'); 1924 | 1925 | const blocks = [ 1926 | { 1927 | type: 'section', 1928 | text: { 1929 | type: 'mrkdwn', 1930 | text: '*SlackONOS Info*\nBuildNumber: ' + buildNumber 1931 | } 1932 | }, 1933 | { 1934 | type: 'divider' 1935 | }, 1936 | { 1937 | type: 'section', 1938 | text: { 1939 | type: 'mrkdwn', 1940 | text: '*Spotify Info*\nMarket: ' + market 1941 | } 1942 | }, 1943 | { 1944 | type: 'divider' 1945 | }, 1946 | { 1947 | type: 'section', 1948 | text: { 1949 | type: 'mrkdwn', 1950 | text: sonosInfo 1951 | } 1952 | }, 1953 | { 1954 | type: 'divider' 1955 | }, 1956 | { 1957 | type: 'section', 1958 | text: { 1959 | type: 'mrkdwn', 1960 | text: formattedMemoryUsage 1961 | } 1962 | }, 1963 | { 1964 | type: 'divider' 1965 | }, 1966 | { 1967 | type: 'section', 1968 | text: { 1969 | type: 'mrkdwn', 1970 | text: envVars 1971 | } 1972 | }, 1973 | { 1974 | type: 'divider' 1975 | }, 1976 | { 1977 | type: 'section', 1978 | text: { 1979 | type: 'mrkdwn', 1980 | text: '*Configuration Values*\n' + configValues 1981 | } 1982 | }, 1983 | { 1984 | type: 'divider' 1985 | }, 1986 | { 1987 | type: 'section', 1988 | text: { 1989 | type: 'mrkdwn', 1990 | text: `*Docker Status*\n${dockerStatus}` 1991 | } 1992 | }, 1993 | { 1994 | type: 'divider' 1995 | }, 1996 | { 1997 | type: 'section', 1998 | text: { 1999 | type: 'mrkdwn', 2000 | text: `*IP Address*\n${ipAddress}` 2001 | } 2002 | } 2003 | ]; 2004 | 2005 | if (missingConfigValues) { 2006 | blocks.push( 2007 | { 2008 | type: 'divider' 2009 | }, 2010 | { 2011 | type: 'section', 2012 | text: { 2013 | type: 'mrkdwn', 2014 | text: '*Missing Configuration Values*\n' + missingConfigValues 2015 | } 2016 | } 2017 | ); 2018 | } 2019 | 2020 | const message = { 2021 | channel: channel, 2022 | blocks: blocks 2023 | }; 2024 | 2025 | try { 2026 | await web.chat.postMessage(message); 2027 | } catch (err) { 2028 | logger.error('Error sending debug message: ' + err); 2029 | _slackMessage('Error sending debug message.', channel); 2030 | } 2031 | }); 2032 | } 2033 | 2034 | 2035 | 2036 | 2037 | 2038 | 2039 | 2040 | 2041 | 2042 | async function _blacklist(input, channel, userName) { 2043 | _logUserAction(userName, 'blacklist'); 2044 | if (channel !== global.adminChannel) { 2045 | return; 2046 | } 2047 | 2048 | const action = input[1] ? input[1] : ''; 2049 | const slackUser = input[2] ? input[2].replace(/[<@>]/g, '') : ''; 2050 | 2051 | let message = ''; 2052 | 2053 | if (slackUser !== '') { 2054 | var userNameToCheck = await _checkUser(slackUser); 2055 | } 2056 | 2057 | if (action === '') { 2058 | // Fetch usernames for each user ID in the blacklist 2059 | const usernames = await Promise.all(blacklist.map(async (userName) => { 2060 | return `@${userName}`; 2061 | })); 2062 | 2063 | message = 'The following users are blacklisted:\n```\n' + usernames.join('\n') + '\n```'; 2064 | } else if (typeof userNameToCheck !== 'undefined') { 2065 | const displayName = `@${userNameToCheck}`; 2066 | 2067 | if (action === 'add') { 2068 | if (!blacklist.includes(userNameToCheck)) { 2069 | blacklist.push(userNameToCheck); 2070 | message = `The user ${displayName} has been added to the blacklist.`; 2071 | } else { 2072 | message = `The user ${displayName} is already on the blacklist.`; 2073 | } 2074 | } else if (action === 'del') { 2075 | const index = blacklist.indexOf(userNameToCheck); 2076 | if (index !== -1) { 2077 | blacklist.splice(index, 1); 2078 | message = `The user ${displayName} has been removed from the blacklist.`; 2079 | } else { 2080 | message = `The user ${displayName} is not on the blacklist.`; 2081 | } 2082 | } else { 2083 | message = 'Usage: `blacklist add|del @username`'; 2084 | } 2085 | } else { 2086 | message = 'Usage: `blacklist add|del @username`'; 2087 | } 2088 | 2089 | if (message) { 2090 | _slackMessage(message, channel); 2091 | } else { 2092 | logger.error('No message to send to Slack.'); 2093 | } 2094 | } 2095 | 2096 | let serverInstance = null; 2097 | 2098 | async function _tts(input, channel) { 2099 | 2100 | // Get random message 2101 | logger.info('ttsMessage.length: ' + ttsMessage.length) 2102 | var ran = Math.floor(Math.random() * ttsMessage.length) 2103 | var ttsSayMessage = ttsMessage[ran] 2104 | logger.info('ttsMessage: ' + ttsSayMessage) 2105 | message = input.slice(1).join(' ') 2106 | 2107 | const text = ttsSayMessage + "... Message as follows... " + message + ".... I repeat... " + message; // Remove the leading "say" 2108 | const filePath = path.join(__dirname, 'tts.mp3'); 2109 | _slackMessage(':mega:' + ' ' + ttsSayMessage + ': ' + '*' + message + '*', standardChannel); 2110 | _slackMessage('I will post the message in the music channel: ' + standardChannel + ', for you', channel); 2111 | logger.info('Generating TTS for text: ' + text); 2112 | 2113 | try { 2114 | const positionInfo = await sonos.avTransportService().GetPositionInfo(); 2115 | logger.info('Current Position: ' + JSON.stringify(positionInfo.Track)); 2116 | 2117 | // Ensure previous TTS file is deleted 2118 | if (fs.existsSync(filePath)) { 2119 | fs.unlinkSync(filePath); // Synchronous cleanup of previous file before creating a new one 2120 | logger.info('Old TTS file deleted before generating a new one.'); 2121 | } 2122 | 2123 | // Stop any curre nt playback before generating new TTS 2124 | try { 2125 | await sonos.stop(); // Stop any current playback before starting a new one 2126 | logger.info('Previous track stopped.'); 2127 | } catch (error) { 2128 | logger.warn('No track was playing or error stopping the track: ' + error.message); 2129 | } 2130 | 2131 | 2132 | 2133 | // Generate the TTS file using gtts 2134 | await new Promise((resolve, reject) => { 2135 | const gtts = new GTTS(text, 'en'); // 'en' stands for English language 2136 | gtts.save(filePath, (err) => { 2137 | if (err) { 2138 | reject('TTS generation error: ' + err.message); 2139 | } else { 2140 | logger.info('TTS file generated successfully'); 2141 | resolve(); 2142 | } 2143 | }); 2144 | }); 2145 | 2146 | // If the server is running, close and reset it 2147 | if (serverInstance) { 2148 | serverInstance.close(() => { 2149 | logger.info('Previous server instance closed.'); 2150 | }); 2151 | serverInstance = null; // Reset the instance 2152 | } 2153 | 2154 | // Create the server to serve the TTS file 2155 | serverInstance = http.createServer((req, res) => { 2156 | if (req.url === '/tts.mp3') { 2157 | fs.readFile(filePath, (err, data) => { 2158 | if (err) { 2159 | res.writeHead(500); 2160 | res.end('Internal Server Error'); 2161 | } else { 2162 | res.writeHead(200, { 2163 | 'Content-Type': 'audio/mpeg', 2164 | 'Content-Disposition': 'attachment; filename="tts.mp3"', 2165 | }); 2166 | res.end(data); 2167 | } 2168 | }); 2169 | } else { 2170 | res.writeHead(404); 2171 | res.end('Not Found'); 2172 | } 2173 | }); 2174 | 2175 | serverInstance.listen(webPort, () => { 2176 | logger.info('Server is listening on port ' + webPort); 2177 | }); 2178 | 2179 | serverInstance.on('error', (err) => { 2180 | if (err.code === 'EADDRINUSE') { 2181 | logger.warn('Port ' + webPort + ' is already in use. Reusing the existing server.'); 2182 | } else { 2183 | logger.error('Server error: ' + err); 2184 | } 2185 | }); 2186 | 2187 | process.on('SIGTERM', () => { 2188 | if (serverInstance) { 2189 | serverInstance.close(() => { 2190 | logger.info('Server closed gracefully.'); 2191 | process.exit(0); 2192 | }); 2193 | } 2194 | }); 2195 | 2196 | // Wait for the server to be ready before playing 2197 | await delay(2000); // Increased delay 2198 | 2199 | // Play the TTS file 2200 | await sonos.play('http://' + ipAddress + ':' + webPort + '/tts.mp3') 2201 | .then(() => { 2202 | logger.info('Playing notification...'); 2203 | }) 2204 | .catch((error) => { 2205 | logger.error('Error occurred during playback: ' + JSON.stringify(error)); 2206 | }); 2207 | 2208 | // Determine the duration of the MP3 file 2209 | const mp3Length = await new Promise((resolve, reject) => { 2210 | mp3Duration(filePath, (err, duration) => { 2211 | if (err) { 2212 | reject('Error fetching MP3 duration: ' + err.message); 2213 | } else { 2214 | logger.info('MP3 duration: ' + duration + ' seconds'); 2215 | resolve(duration); 2216 | } 2217 | }); 2218 | }); 2219 | 2220 | // Delay based on the actual MP3 length 2221 | await delay(mp3Length * 1000); // Convert seconds to milliseconds 2222 | 2223 | const nextToPlay = positionInfo.Track + 1; 2224 | logger.info('Next to play: ' + nextToPlay); 2225 | 2226 | try { 2227 | await sonos.selectTrack(nextToPlay); 2228 | logger.info('Track selected successfully.'); 2229 | } catch (error) { 2230 | logger.info('Jumping to next track failed: ' + error); 2231 | } 2232 | 2233 | await sonos.play(); 2234 | 2235 | } finally { 2236 | // Cleanup queue and remove the track 2237 | await sonos.getQueue().then((result) => { 2238 | const removeGong = result.total; 2239 | sonos.removeTracksFromQueue([removeGong], 1) 2240 | .then(() => { 2241 | logger.info('Removed track with index: ' + removeGong); 2242 | }) 2243 | .catch((err) => { 2244 | logger.error('Error occurred while removing track: ' + err); 2245 | }); 2246 | }); 2247 | 2248 | // Remove the TTS file 2249 | fs.unlink(filePath, (err) => { 2250 | if (err) { 2251 | logger.error('Error deleting the TTS file: ' + err.message); 2252 | } else { 2253 | logger.info('TTS file deleted successfully'); 2254 | } 2255 | }); 2256 | } 2257 | } 2258 | 2259 | 2260 | 2261 | 2262 | function _purgeHalfQueue(input, channel) { 2263 | sonos.getQueue().then(result => { 2264 | let maxQueueIndex = parseInt(result.total) 2265 | const halfQueueSize = Math.floor(maxQueueIndex / 2) 2266 | for (let i = 0; i < halfQueueSize; i++) { 2267 | const rand = utils.getRandomInt(0, maxQueueIndex) 2268 | _removeTrack(rand, channel, function (success) { 2269 | if (success) { 2270 | maxQueueIndex-- 2271 | } 2272 | }) 2273 | } 2274 | const snapUrl = 'https://cdn3.movieweb.com/i/article/61QmlwoK2zbKcbLyrLncM3gPrsjNIb/738:50/Avengers-Infinity-War-Facebook-Ar-Mask-Thanos-Snap.jpg' 2275 | _slackMessage(snapUrl + '\nThanos has restored balance to the playlist', channel) 2276 | }).catch(err => { 2277 | logger.error(err) 2278 | }) 2279 | } 2280 | 2281 | async function _stats(input, channel, userName) { 2282 | let userActions = {}; 2283 | 2284 | logger.info('Starting _stats function'); 2285 | logger.info(`Input received: ${JSON.stringify(input)}, Channel: ${channel}, UserName: ${userName}`); 2286 | 2287 | // Ensure the file exists 2288 | if (!fs.existsSync(userActionsFile)) { 2289 | logger.warn('userActionsFile does not exist.'); 2290 | _slackMessage('No statistics available.', channel); 2291 | return; 2292 | } 2293 | 2294 | // Read existing user actions from the file 2295 | try { 2296 | const data = fs.readFileSync(userActionsFile, 'utf8'); 2297 | logger.info('Successfully read userActionsFile.'); 2298 | userActions = JSON.parse(data); 2299 | logger.info(`Loaded userActions: ${JSON.stringify(userActions, null, 2)}`); 2300 | } catch (err) { 2301 | logger.error('Error reading or parsing userActions.json: ' + err); 2302 | _slackMessage('Error reading statistics.', channel); 2303 | return; 2304 | } 2305 | 2306 | const userId = input[1] ? input[1].replace(/[<@>]/g, '') : null; 2307 | const userKey = input[1]; // Use the full "<@U03JJ5LN4>" format from the input 2308 | logger.info(`Extracted userId: ${userId}, userKey: ${userKey}`); 2309 | 2310 | const blocks = [ 2311 | { 2312 | type: 'section', 2313 | text: { 2314 | type: 'mrkdwn', 2315 | text: '*User Statistics*' 2316 | } 2317 | }, 2318 | { 2319 | type: 'divider' 2320 | } 2321 | ]; 2322 | 2323 | if (userKey) { 2324 | // Show stats for the specific user 2325 | const userStats = userActions[userKey]; // Use userKey which matches the stored key format 2326 | logger.info(`Found stats for userKey: ${userKey}: ${JSON.stringify(userStats)}`); 2327 | if (!userStats) { 2328 | _slackMessage(`No statistics available for user: ${userKey}`, channel); 2329 | return; 2330 | } 2331 | 2332 | const fields = Object.entries(userStats) 2333 | .slice(0, 10) // Limit to the first 10 entries 2334 | .map(([action, count]) => ({ 2335 | type: 'mrkdwn', 2336 | text: `*${action}*: ${count}` 2337 | })); 2338 | 2339 | blocks.push({ 2340 | type: 'section', 2341 | text: { 2342 | type: 'mrkdwn', 2343 | text: `*${userKey}*` 2344 | }, 2345 | fields: fields 2346 | }); 2347 | } else { 2348 | // Show stats for the top 10 users 2349 | logger.info('Fetching stats for the top 10 users.'); 2350 | const topUsers = Object.entries(userActions) 2351 | .sort(([, a], [, b]) => Object.values(b).reduce((sum, count) => sum + count, 0) - Object.values(a).reduce((sum, count) => sum + count, 0)) 2352 | .slice(0, 10); 2353 | 2354 | logger.info(`Top 10 users: ${JSON.stringify(topUsers)}`); 2355 | 2356 | topUsers.forEach(([userKey, userStats]) => { 2357 | const fields = Object.entries(userStats) 2358 | .slice(0, 10) // Limit to the first 10 entries 2359 | .map(([action, count]) => ({ 2360 | type: 'mrkdwn', 2361 | text: `*${action}*: ${count}` 2362 | })); 2363 | 2364 | blocks.push({ 2365 | type: 'section', 2366 | text: { 2367 | type: 'mrkdwn', 2368 | text: `*${userKey}*` 2369 | }, 2370 | fields: fields 2371 | }); 2372 | blocks.push({ 2373 | type: 'divider' 2374 | }); 2375 | }); 2376 | } 2377 | 2378 | // Validate the channel ID 2379 | if (!channel || typeof channel !== 'string') { 2380 | logger.error('Invalid channel ID'); 2381 | _slackMessage('Error: Invalid channel ID.', channel); 2382 | return; 2383 | } 2384 | 2385 | // Send the formatted message to the specified Slack channel 2386 | const message = { 2387 | channel: channel, 2388 | text: 'User Statistics', // Add text argument 2389 | blocks: blocks 2390 | }; 2391 | 2392 | try { 2393 | await web.chat.postMessage(message); 2394 | logger.info('Successfully sent statistics message.'); 2395 | } catch (err) { 2396 | logger.error('Error sending statistics message: ' + err); 2397 | _slackMessage('Error sending statistics message.', channel); 2398 | } 2399 | } 2400 | 2401 | 2402 | 2403 | function _logUserAction(userName, action) { 2404 | let userActions = {}; 2405 | 2406 | // Ensure the file exists 2407 | if (!fs.existsSync(userActionsFile)) { 2408 | fs.writeFileSync(userActionsFile, JSON.stringify({}), 'utf8'); 2409 | } 2410 | 2411 | // Read existing user actions from the file 2412 | try { 2413 | const data = fs.readFileSync(userActionsFile, 'utf8'); 2414 | userActions = JSON.parse(data); 2415 | } catch (err) { 2416 | logger.error('Error reading or parsing userActions.json: ' + err); 2417 | userActions = {}; 2418 | } 2419 | 2420 | // Initialize user actions if not already present 2421 | if (!userActions[userName]) { 2422 | userActions[userName] = {}; 2423 | } 2424 | 2425 | // Initialize action count if not already present 2426 | if (!userActions[userName][action]) { 2427 | userActions[userName][action] = 0; 2428 | } 2429 | 2430 | // Increment the action count 2431 | userActions[userName][action] += 1; 2432 | 2433 | // Write updated user actions back to the file 2434 | try { 2435 | fs.writeFileSync(userActionsFile, JSON.stringify(userActions, null, 2), 'utf8'); 2436 | } catch (err) { 2437 | logger.error('Error writing to userActions.json: ' + err); 2438 | } 2439 | 2440 | logger.info(`Logged action: ${action} for user: ${userName}`); 2441 | } 2442 | 2443 | 2444 | 2445 | // Function to parse XML to JSON 2446 | 2447 | function xmlToJson(url, callback) { 2448 | var req = http.get(url, function (res) { 2449 | logger.info(req) 2450 | var xml = '' 2451 | res.on('data', function (chunk) { xml += chunk }) 2452 | res.on('error', function (e) { callback(e, null) }) 2453 | res.on('timeout', function (e) { callback(e, null) }) 2454 | res.on('end', function () { parseString(xml, function (e, result) { callback(null, result) }) }) 2455 | }) 2456 | } 2457 | 2458 | // Travis. 2459 | // Just making sure that is at least will build... 2460 | 2461 | module.exports = function (number, locale) { 2462 | return number.toLocaleString(locale) 2463 | } 2464 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenmusic", 3 | "version": "1.0.0", 4 | "description": "Zen sonos project", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretest": "if [ ! -f 'config/config.json' ]; then cp 'config/config.json.example' 'config/config.json'; fi", 8 | "test": "mocha --reporter spec" 9 | }, 10 | "keywords": [ 11 | "sonos", 12 | "slack", 13 | "spotify", 14 | "slackonos" 15 | ], 16 | "repository": { 17 | "type:": "git", 18 | "url": "git@github.com:htilly/zenmusic.git" 19 | }, 20 | "devDependencies": { 21 | "mocha": "^11.5.0", 22 | "chai": "5.2.0" 23 | }, 24 | "author": "", 25 | "license": "ISC", 26 | "dependencies": { 27 | "@slack/rtm-api": "^7.0.2", 28 | "@slack/web-api": "^7.9.2", 29 | "nconf": "^0.13.0", 30 | "sonos": "^1.14.1", 31 | "urlencode": "^2.0.0", 32 | "@jsfeb26/urllib-sync": "^1.1.4", 33 | "winston": "^3.17.0", 34 | "xml2js": "^0.6.2", 35 | "gtts": "^0.2.1", 36 | "mp3-duration": "^1.1.0" 37 | }, 38 | "engines": { 39 | "node": ">=7.5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sound/gong.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htilly/SlackONOS/3d75335cb829d59f76624865ce768940b84e190e/sound/gong.mp3 -------------------------------------------------------------------------------- /spotify.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require('nconf') 4 | const urllibsync = require('@jsfeb26/urllib-sync') 5 | const winston = require('winston') 6 | 7 | config.argv() 8 | .env() 9 | .file({ file: 'config.json' }) 10 | .defaults({ 11 | 'logLevel': 'info', 12 | }) 13 | 14 | const logLevel = config.get('logLevel') 15 | 16 | module.exports = function (config) { 17 | if (module.exports.instance) { 18 | return module.exports.instance 19 | } 20 | 21 | config = config || {} 22 | let accessToken 23 | let accessTokenExpires 24 | 25 | /* Initialize Logger */ 26 | const logger = winston.createLogger({ 27 | level: logLevel, 28 | format: winston.format.json(), 29 | transports: [ 30 | new winston.transports.Console({format: winston.format.combine(winston.format.colorize(), winston.format.simple())}) 31 | ] 32 | }); 33 | 34 | function _getAccessToken() { 35 | if (accessToken && accessTokenExpires > new Date().getTime()) { 36 | return accessToken 37 | } 38 | 39 | let getToken = urllibsync.request('https://accounts.spotify.com/api/token', { 40 | method: 'POST', 41 | data: {'grant_type': 'client_credentials'}, 42 | headers: {'Authorization': 'Basic ' + (Buffer.from(config.clientId + ':' + config.clientSecret).toString('base64'))} 43 | }) 44 | let tokendata = JSON.parse(getToken.data.toString()) 45 | accessTokenExpires = new Date().getTime() + (tokendata.expires_in - 10) * 1000 46 | accessToken = tokendata.access_token 47 | return accessToken 48 | } 49 | 50 | module.exports.instance = { 51 | 52 | // TODO - refactor duplicate boilerplate below 53 | // TODO - move messaging to index, get rid of channel/username args 54 | searchSpotify: function (input, channel, userName, limit) { 55 | let accessToken = _getAccessToken() 56 | if (!accessToken) { 57 | return false 58 | } 59 | 60 | var query = '' 61 | for (var i = 1; i < input.length; i++) { 62 | query += encodeURIComponent(input[i]) 63 | // TODO - join 64 | if (i < input.length - 1) { 65 | query += ' ' 66 | } 67 | } 68 | 69 | var getapi = urllibsync.request( 70 | 'https://api.spotify.com/v1/search?q=' + 71 | query + 72 | '&type=track&limit=' + 73 | limit + 74 | '&market=' + 75 | config.market + 76 | '&access_token=' + 77 | accessToken 78 | ) 79 | 80 | var data = JSON.parse(getapi.data.toString()) 81 | 82 | config.logger.debug(data) 83 | if (!data.tracks || !data.tracks.items || data.tracks.items.length === 0) { 84 | var message = 'Sorry ' + userName + ', I could not find that track :(' 85 | data = null 86 | } 87 | 88 | return [data, message] 89 | }, 90 | 91 | searchSpotifyPlaylist: function (input, channel, userName, limit) { 92 | let accessToken = _getAccessToken() 93 | if (!accessToken) { 94 | return false 95 | } 96 | 97 | var query = '' 98 | for (var i = 1; i < input.length; i++) { 99 | query += encodeURIComponent(input[i]) 100 | if (i < input.length - 1) { 101 | query += ' ' 102 | } 103 | } 104 | 105 | var getapi = urllibsync.request( 106 | 'https://api.spotify.com/v1/search?q=' + 107 | query + 108 | '&type=playlist&limit=' + 109 | limit + 110 | '&market=' + 111 | config.market + 112 | '&access_token=' + 113 | accessToken 114 | ) 115 | 116 | var data = JSON.parse(getapi.data.toString()) 117 | logger.debug(data) 118 | if (!data.playlists || !data.playlists.items || data.playlists.items.length === 0) { 119 | var message = 'Sorry ' + userName + ', I could not find that playlist :(' 120 | data = null 121 | } 122 | 123 | return [data, message] 124 | }, 125 | 126 | searchSpotifyAlbum: function (input, channel, userName, limit) { 127 | let accessToken = _getAccessToken() 128 | if (!accessToken) { 129 | return false 130 | } 131 | 132 | var query = '' 133 | for (var i = 1; i < input.length; i++) { 134 | query += encodeURIComponent(input[i]) 135 | if (i < input.length - 1) { 136 | query += ' ' 137 | } 138 | } 139 | 140 | var getapi = urllibsync.request( 141 | 'https://api.spotify.com/v1/search?q=' + 142 | query + 143 | '&type=album&limit=' + 144 | limit + 145 | '&market=' + 146 | config.market + 147 | '&access_token=' + 148 | accessToken 149 | ) 150 | 151 | var data = JSON.parse(getapi.data.toString()) 152 | config.logger.debug(data) 153 | if (!data.albums || !data.albums.items || data.albums.items.length === 0) { 154 | var message = 'Sorry ' + userName + ', I could not find that album :(' 155 | data = null 156 | } 157 | 158 | return [data, message] 159 | }, 160 | 161 | searchSpotifyArtist: function (input, channel, userName, limit) { 162 | let accessToken = _getAccessToken() 163 | if (!accessToken) { 164 | return false 165 | } 166 | 167 | var query = '' 168 | for (var i = 1; i < input.length; i++) { 169 | query += encodeURIComponent(input[i]) 170 | if (i < input.length - 1) { 171 | query += ' ' 172 | } 173 | } 174 | 175 | var getapi = urllibsync.request( 176 | 'https://api.spotify.com/v1/search?q=' + 177 | query + 178 | '&type=artist&limit=' + 179 | limit + 180 | '&market=' + 181 | config.market + 182 | '&access_token=' + 183 | accessToken 184 | ) 185 | 186 | var data = JSON.parse(getapi.data.toString()) 187 | config.logger.debug(data) 188 | if (!data.artists || !data.artists.items || data.artists.items.length === 0) { 189 | var message = 'Sorry ' + userName + ', I could not find that artist :(' 190 | data = null 191 | } 192 | 193 | return [data, message] 194 | } 195 | } 196 | 197 | return module.exports.instance 198 | } -------------------------------------------------------------------------------- /test/test.mjs: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import numFormatter from '../index.js'; // Ensure the path and extension are correct 3 | 4 | describe('#numFormatter', function() { 5 | 6 | 7 | it('should convert single digits', function() { 8 | var result = numFormatter(1); 9 | expect(result).to.equal('1'); 10 | }); 11 | 12 | it('should convert double digits', function() { 13 | var result = numFormatter(12); 14 | expect(result).to.equal('12'); 15 | }); 16 | 17 | it('should convert triple digits', function() { 18 | var result = numFormatter(123); 19 | expect(result).to.equal('123'); 20 | }); 21 | 22 | it('should convert 4 digits', function() { 23 | var result = numFormatter(1234); 24 | expect(result).to.equal('1,234'); 25 | }); 26 | 27 | it('should convert 5 digits', function() { 28 | var result = numFormatter(12345); 29 | expect(result).to.equal('12,345'); 30 | }); 31 | 32 | it('should convert 6 digits', function() { 33 | var result = numFormatter(123456); 34 | expect(result).to.equal('123,456'); 35 | }); 36 | 37 | it('should convert 7 digits', function() { 38 | var result = numFormatter(1234567); 39 | expect(result).to.equal('1,234,567'); 40 | }); 41 | 42 | it('should convert 8 digits', function() { 43 | var result = numFormatter(12345678); 44 | expect(result).to.equal('12,345,678'); 45 | }); 46 | 47 | // Other test cases... 48 | }); 49 | -------------------------------------------------------------------------------- /tts.txt: -------------------------------------------------------------------------------- 1 | Hate to interrupt, but this moment is about to get even better 2 | Just a quick pause, I promise this is worth it 3 | Excuse me for a second, you're going to want to hear this 4 | Hold up, I’ve got something important to say 5 | Time for a little break; trust me, it’s worth it 6 | I know you're vibing, but this is too good not to share 7 | This won't take long, but you’ll be glad I stopped by 8 | Pardon the interruption, but here’s something you need to know 9 | Quick break for something that’s going to make your day even better 10 | Stopping the music for just a second, because this is big 11 | Sorry to break in, but I have something really exciting to share 12 | Just a quick pause—this is worth your time 13 | Before the next beat drops, here's something amazing 14 | A brief pause for something awesome 15 | This will just take a moment, but it’s important 16 | Hold on, I've got something you'll love 17 | A little break to make this party even better 18 | I promise this is worth the interruption 19 | Taking a quick pause to share something epic 20 | You won't mind this break once you hear what’s coming 21 | Brief interruption, but you’re going to thank me later 22 | This quick pause is going to be legendary 23 | A quick detour for something special 24 | I promise, this is the kind of interruption you’ll enjoy 25 | You’re going to love what’s coming next 26 | Just a quick second, but this is going to be great 27 | Before we go any further, you’ll want to hear this 28 | A quick pause for something worth celebrating 29 | I hate to stop the music, but trust me, this is good 30 | This is one interruption you won’t regret 31 | The music will be back in a moment, but this is too good not to share 32 | Just a little pause for a big announcement 33 | I’ll be brief, but this is exciting 34 | Hold on for just a second—something special is coming 35 | This interruption is going to be worth it, I promise 36 | Let’s hit pause for something truly awesome 37 | You’re going to want to hear this before we keep going 38 | I’m stopping the music, but only for something amazing 39 | Brief break for something you’re going to love 40 | I wouldn’t interrupt unless it was important, trust me 41 | A little pause for something that will make your day 42 | Stopping for just a moment, but it’s going to be great 43 | Before we keep the party going, here’s something cool 44 | This is worth hitting pause for, trust me 45 | Just a quick moment to make things even better 46 | I promise, you won’t mind this quick interruption 47 | A quick pause to make your day a little brighter 48 | Before the next track, here’s something epic 49 | You’re going to love what’s coming after this short break 50 | Brief interruption, but it’s going to be awesome 51 | I promise this pause will be worth it 52 | Just a quick moment to make things even more amazing 53 | You’re going to want to hear this before we continue 54 | I wouldn’t stop the music unless it was something great 55 | A little pause for something you’ll appreciate 56 | Hold up, I’ve got something exciting to tell you 57 | Just a brief pause for something incredible 58 | Before the music picks up again, here’s something important 59 | Quick interruption, but you’re going to love this 60 | This pause is going to be totally worth it 61 | I hate to stop the vibe, but this is big 62 | You’re going to thank me for this quick pause 63 | Just a second—what’s coming is worth the wait 64 | A little break for something worth hearing 65 | I promise, this quick interruption will be worth it 66 | Just a quick pause for something that’ll make you smile 67 | Before the next song, here’s something exciting 68 | A brief pause for something important 69 | This won’t take long, but it’s something cool 70 | I’m hitting pause, but only for something epic 71 | You’re going to love this after the short interruption 72 | Just a quick break for something exciting 73 | I know you’re enjoying this, but trust me, you’ll want to hear this 74 | A quick pause for something that’s going to make your day 75 | I wouldn’t stop the music unless this was amazing 76 | You’ll be glad I paused for this, trust me 77 | This is one interruption you won’t mind 78 | Before we go on, here’s something incredible 79 | Quick break for something awesome 80 | I promise this pause will make your day better 81 | Just a quick moment to share something exciting 82 | You’re going to love what comes after this 83 | Brief interruption, but it’s going to be great 84 | Let’s take a moment for something you’re going to enjoy 85 | Just a quick second to share something amazing 86 | Before the next track, here’s something cool 87 | This won’t take long, but it’s going to be worth it 88 | I’m stopping the music, but for something good 89 | You’ll be glad I paused after hearing this 90 | Just a brief break for something special 91 | Before the next beat, here’s something incredible 92 | A quick pause for something you won’t want to miss 93 | This interruption is going to be totally worth it 94 | I wouldn’t stop the music if it wasn’t important, trust me 95 | You’ll be glad I interrupted for this 96 | Just a little pause for something that’ll make your day 97 | Hold on, I’ve got something great to share 98 | This break is going to be worth it, I promise 99 | Before we continue, here’s something awesome 100 | You won’t mind this quick pause, I guarantee it 101 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | getRandomInt: function(min, max) { 5 | min = Math.ceil(min); 6 | max = Math.floor(max); 7 | return Math.floor(Math.random() * (max - min + 1)) + min; 8 | }, 9 | } -------------------------------------------------------------------------------- /vote.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Sweet! This track just leveled up :rocket: 3 | Heck yes! Jamming to this one next :guitar: 4 | Boom! Instant classic right here :fire: 5 | Feeling the vibe, moving on up! :chart_with_upwards_trend: 6 | On point! Queue this banger up :headphones: 7 | Yesssss! This tune deserves all the love :musical_note: 8 | That's what I'm talking about! :clap: 9 | Straight fire! Keep 'em coming :fire_engine: 10 | This one hits different! Adding to the top :arrow_up: 11 | Next stop, greatness! :upvote: 12 | Oh yeah! That’s the good stuff :sunglasses: 13 | Now we’re talking, this tune SLAPS :loud_sound: 14 | A winner is here! :trophy: 15 | Turn it up! This track is fire :firecracker: 16 | Let’s ride this wave, tune’s up! :surfing_man: 17 | Instant vibe! Time to jam :musical_score: 18 | That’s some ear candy right there :candy: 19 | YAS! This track is pure gold :moneybag: 20 | Absolute banger alert! :rotating_light: 21 | Up, up, and away! :rocket: 22 | Feel that beat! Keep ‘em coming :heartbeat: 23 | THIS is what we needed! :100: 24 | Moving up the ranks! :top: 25 | Ready to blast this one on repeat :repeat: 26 | This just made my day! :sunny: 27 | Playlist level = UP! :arrow_double_up: 28 | Tunes like this deserve an encore! :microphone: 29 | Keep the heat coming! :fire: 30 | Solid gold! Adding this gem :gem: 31 | Straight to the top with this one! :top: 32 | Instant mood booster :sunrise_over_mountains: 33 | And we have a winner! :trophy: 34 | This one’s got the vibe! :call_me_hand: 35 | YES! This is an absolute jam :musical_keyboard: 36 | Groove mode activated! :dancer: 37 | Cranking up the volume for this one :sound: 38 | Time to vibe with this one :vibration_mode: 39 | Rocketing to the top! :rocket: 40 | Pure magic, this song’s going places :sparkles: 41 | Upvote city! This tune’s unstoppable :cityscape: 42 | Certified fresh beats! :seedling: 43 | Bringing the heat! :fire: 44 | Turn it up! This song is going places :high_brightness: 45 | That’s a straight-up vibe :v: 46 | So good! Deserves all the love :hearts: 47 | Time to get groovy! :saxophone: 48 | No brainer, that’s a hit! :brain: 49 | That bass though :boom: 50 | This track is a straight-up mood :sunflower: 51 | Upvotes raining down on this one! :cloud_with_rain: 52 | Straight to the top with this vibe :arrow_up: 53 | Feeling this! Time to jam :saxophone: 54 | Bringing the vibes, keep it up :fire: 55 | Yessss! Play it loud! :mega: 56 | We’ve got a banger on our hands! :raised_hands: 57 | This one’s a whole vibe :ocean: 58 | Straight to the top! :arrow_double_up: 59 | Let’s take this one to the next level :upwards_trend: 60 | Loving this energy! :zap: 61 | Let’s hear it LOUDER! :speaker: 62 | On fire! Time to jam out :fire_extinguisher: 63 | Instant playlist material :sparkles: 64 | This just hits right! :heavy_check_mark: 65 | Full volume for this one! :loud_sound: 66 | Feels like a win! :trophy: 67 | Straight to the top! :rocket: 68 | Upvoting this masterpiece :tada: 69 | High fives all around for this track :raised_hands: 70 | Keep this one on repeat! :repeat_one: 71 | Absolutely vibing! :heartpulse: 72 | Bring it on! Time to rock :guitar: 73 | This one deserves some serious love :heart: 74 | Total vibe booster :arrow_up: 75 | Now that’s what I call music! :headphones: 76 | Certified hit! :white_check_mark: 77 | Play that funky music! :notes: 78 | Pure fire, no question! :fire: 79 | Let’s pump up the jam :muscle: 80 | This one’s unstoppable! :oncoming_fist: 81 | Energy levels off the charts! :bar_chart: 82 | Time to blast this one! :mega: 83 | It’s got that X factor :sparkles: 84 | This one’s a playlist must-have! :bookmark_tabs: 85 | That’s the vibe right there! :smiley: 86 | Feeling this all the way! :100: 87 | Straight to the top! :chart_with_upwards_trend: 88 | Jammin’ out to this one! :notes: 89 | Let’s go! This tune is it! :arrow_double_up: 90 | Pure gold! This one’s a keeper :gem: 91 | Upvoting this forever :infinity: 92 | On fire! Turn it up :firecracker: 93 | This beat’s unstoppable! :boom: 94 | Playlist upgrade in progress! :top: 95 | All aboard the vibe train! :train: 96 | Turn this one up to 11! :loud_sound: 97 | Loving this jam! :musical_note: 98 | Let’s crank this up! :muscle: 99 | Vibes are strong with this one :star2: 100 | A total game-changer! :crown: 101 | Upvote army, let’s go! :musical_keyboard: 102 | ] --------------------------------------------------------------------------------