Skip to content
This repository has been archived by the owner on Feb 17, 2022. It is now read-only.

Commit

Permalink
Initial public commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dkoch committed Sep 11, 2014
0 parents commit 5356a8a
Show file tree
Hide file tree
Showing 21 changed files with 914 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
cache/*
settings/*
node_modules/*
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<p align="center">
<img src="http://res.cloudinary.com/hlwjgyj8f/image/upload/v1410430228/h71mqvptn5t1yc9pdjuf.png" alt="CrispyFi" />
</p>
<hr />
*CrispyFi* is a local pseudo-bot for [Slack](http://slack.com) that controls what the people in your office have to listen to. It's comprised of a small Node.js-based server that interfaces with Spotify and is controlled by chat commands in your Slack chatroom of choice. It's designed to run on a Raspberry Pi connected to your sound system, but should work on any system that supports [node-spotify](http://node-spotify.com) and volume control using `amixer` (not required, though). Sound is streamed to your device's default audio output and thus can work with the integrated audio jack, HDMI output or additional sound boards like the HiFiBerry.

## Local pseudo-bot?
In contrast to many existing Slack integrations, you're supposed to run CrispyFi locally in your office where it can access your sound hardware. Also, it's not really an IRC-style bot since it doesn't connect to your chat rooms itself, but rather listens to simple HTTP requests and replies accordingly. If you issue these commands from Slack, you'll even get comments back.

## Requirements
There are three main prerequisites for CrispyFi to do his job:

* Node.js installed on your Raspberry. [This link](http://joshondesign.com/2013/10/23/noderpi) should get you there in a few minutes.
* A Spotify account. CrispyFi doesn't care for your MP3s.
* A Spotify API-Key. You need to apply for one, which you can do [here](https://devaccount.spotify.com/my-account/keys/).

## Installation
1. Once you have everything you need, clone this repo into a directory of your choice.
2. Copy your `spotify_appkey.key` into the installation directory.
3. Edit `config.json` according to your needs. Most of it is self-explanatory, but we'll go into details later on.
4. `npm install`
5. `npm start` or `node index`, whatever works for you.

That's it, your personal CrispyFi instance is now ready to serve requests. There's some additional steps you might want to take, though, (e.g. integrating it with Slack), so read on!

## Configuration
All the important bits are controlled by `config.json` in the project's root path. Below is a code example with comments which *you need to remove* when you copy/pase it, since comments are not allowed in JSON files.

```
{
"spotify": {
"username": "Spotify Username",
"password": "Spotify Password",
// Playlists need to be pre-configured for use here.
// Create playlists using the Spotify app, then right-click
// them and copy their URIs. The names in here don't have to
// match the actual Playlist's name but are used as arguments,
// so you might want to keep them short and simple.
// A playlist named "default" is required!
"playlists": {
"default": "spotify:user:crispymtn:playlist:1xUTFaq10nzODhwkwLMo2l",
"metal": "spotify:user:crispymtn:playlist:6IkIC1Rq6bMkhsO0sj9GGs",
"hiphop": "spotify:user:crispymtn:playlist:71SBbMfSyMHRAIxl8fKEbI"
}
},
"auth": {
// Slack generates one token per integration, so you can just put them
// all in here. We don't check the token per integraion, but rather
// just whether it's included in this list.
"tokens": [
"slack-token-one",
"slack-token-two"
]
},
}
```

## How does it work?
By default, CrispyFi listens on port 8000 and provides a single HTTP endpoint. To issue orders to it, you have to POST to the endpoint and provide an authentication token as well as a command. The format of this is a bit non-standard (i.e. no token in the header) since we built it to be mainly used in combination with Slack's outgoing webhooks. You should probably create an outgoing webhook first and familiarize yourself with the semantics, but the short version is you need a POST body with the following fields:

```
token=<your auth thoken>
text=<trigger_word> <argument>
```

Currently the following trigger words are available:

* `play [Spotify URI]` - Starts/resumes playback if no URI is provided. If a URI is given, immedtiately switches to the linked track.
* `pause` - Pauses playback at the current time.
* `stop` - Stops playback and resets to the beginning of the current track.
* `skip` - Skips (or shuffles) to the next track in the playlist.
* `shuffle` - Toggles shuffle on or off.
* `vol [up|down|0..10]` Turns the volume either up/down one notch or directly to a step between 0 (mute) and 10 (full blast). Also goes to eleven.
* `list [list name]` - When provided with an argument, switches to this playlist. Otherwise, shows all available playlists.
* `status` - Shows the currently playing song, playlist and whether you're shuffling or not.
* `help` - Shows a list of commands with a short explaantion.

If you're using Slack integrations, simply create an outgoing webhook to `http://your-crispyfi-url/handle` that listens to the appropriate trigger words. See below for an example screenshot of our setup. To disable certain funtions, just remove the trigger word.

![Slack integration](http://i.imgur.com/Tye5R2W.png)

## Getting your groove on(line)
Since your Pi will probably be behind a firewall/some sort of NAT or have a dynamic IP, you'll have difficulties tying Slack's webhooks to its IP address. We're currently using [ngrok](http://ngrok.com) to get around that, mainly because it's awesome and makes developing web services much easier. Also, using ngrok you avoid the hassle of updating some other service's configuration whenever your IP changes, instead you have to run a small binary all the time. YMMV, so use whatever you're comfortable with (but give ngrok a try).

## Used Software
CrispyFi builds upon [ApiServer](https://github.com/kilianc/node-apiserver) by killianc and includes a pre-compiled version of FrontierPsychiatrist's [node-spotify](https://github.com/FrontierPsychiatrist/node-spotify) since our Raspberry Pi stoically refused to compile the extension itself. The according license is redistributed as per the terms of the MIT License and can be found in the file `licenses/node-spotify` in the project's root directory.

We extend our everlasting gratitude to both of you!

## License
This software is released under the MIT license, which can be found under `licenses/crispyfi`.
17 changes: 17 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"spotify": {
"username": "Spotify Username",
"password": "Spotify Password",
"playlists": {
"default": "spotify:user:crispymtn:playlist:1xUTFaq10nzODhwkwLMo2l",
"metal": "spotify:user:crispymtn:playlist:6IkIC1Rq6bMkhsO0sj9GGs",
"hiphop": "spotify:user:crispymtn:playlist:71SBbMfSyMHRAIxl8fKEbI"
}
},
"auth": {
"tokens": [
"slack-token-one",
"slack-token-two"
]
}
}
156 changes: 156 additions & 0 deletions examples/init-d.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/bin/bash
#
# An init.d script for running a Node.js process as a service using Forever as
# the process monitor. For more configuration options associated with Forever,
# see: https://github.com/nodejitsu/forever
#
# This was written for Debian distributions such as Ubuntu, but should still
# work on RedHat, Fedora, or other RPM-based distributions, since none of the
# built-in service functions are used. So information is provided for both.
#
### BEGIN INIT INFO
# Provides: crispyfi
# Required-Start: $network
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: CrispyFi - The crispymtn music player for your office.
# Description: CrispyFi - The crispymtn music player for your office.
### END INIT INFO
#
# Based on:
# https://gist.github.com/3748766
# https://github.com/hectorcorrea/hectorcorrea.com/blob/master/etc/forever-initd-hectorcorrea.sh
# https://www.exratione.com/2011/07/running-a-nodejs-server-as-a-service-using-forever/
#
# The example environment variables below assume that Node.js is installed by
# building from source with the standard settings.
#
# It should be easy enough to adapt to the paths to be appropriate to a package
# installation, but note that the packages available in the default repositories
# are far behind the times. Most users will be building from source to get a
# suitably recent Node.js version.
#
# An application name to display in echo text.
# NAME="My Application"
# The full path to the directory containing the node and forever binaries.
# NODE_BIN_DIR="/usr/local/node/bin"
# Set the NODE_PATH to the Node.js main node_modules directory.
# NODE_PATH="/usr/local/lib/node_modules"
# The application startup Javascript file path.
# APPLICATION_PATH="/home/user/my-application/start-my-application.js"
# Process ID file path.
# PIDFILE="/var/run/my-application.pid"
# Log file path.
# LOGFILE="/var/log/my-application.log"
# Forever settings to prevent the application spinning if it fails on launch.
# MIN_UPTIME="5000"
# SPIN_SLEEP_TIME="2000"

NAME="crispyfi"
NODE_BIN_DIR="/usr/bin/node/bin"
NODE_PATH="/usr/bin/node/lib/node_modules"
APPLICATION_PATH="/opt/crispyfi/index.js"
PIDFILE="/var/run/crispyfi.pid"
LOGFILE="/var/log/crispyfi.log"
MIN_UPTIME="5000"
SPIN_SLEEP_TIME="2000"

# Add node to the path for situations in which the environment is passed.
PATH=$NODE_BIN_DIR:$PATH
# Export all environment variables that must be visible for the Node.js
# application process forked by Forever. It will not see any of the other
# variables defined in this script.
export NODE_PATH=$NODE_PATH

start() {
echo "Starting $NAME"
# We're calling forever directly without using start-stop-daemon for the
# sake of simplicity when it comes to environment, and because this way
# the script will work whether it is executed directly or via the service
# utility.
#
# The minUptime and spinSleepTime settings stop Forever from thrashing if
# the application fails immediately on launch. This is generally necessary to
# avoid loading development servers to the point of failure every time
# someone makes an error in application initialization code, or bringing down
# production servers the same way if a database or other critical service
# suddenly becomes inaccessible.
#
# The pidfile contains the child process pid, not the forever process pid.
# We're only using it as a marker for whether or not the process is
# running.
#
# Note that redirecting the output to /dev/null (or anywhere) is necessary
# to make this script work if provisioning the service via Chef.
forever \
--pidFile $PIDFILE \
-a \
-l $LOGFILE \
--minUptime $MIN_UPTIME \
--spinSleepTime $SPIN_SLEEP_TIME \
start $APPLICATION_PATH 2>&1 > /dev/null &
RETVAL=$?
}

stop() {
if [ -f $PIDFILE ]; then
echo "Shutting down $NAME"
# Tell Forever to stop the process.
forever stop $APPLICATION_PATH 2>&1 > /dev/null
# Get rid of the pidfile, since Forever won't do that.
rm -f $PIDFILE
RETVAL=$?
else
echo "$NAME is not running."
RETVAL=0
fi
}

restart() {
stop
start
}

status() {
# On Ubuntu this isn't even necessary. To find out whether the service is
# running, use "service my-application status" which bypasses this script
# entirely provided you used the service utility to start the process.
#
# The commented line below is the obvious way of checking whether or not a
# process is currently running via Forever, but in recent Forever versions
# when the service is started during Chef provisioning a dead pipe is left
# behind somewhere and that causes an EPIPE exception to be thrown.
# forever list | grep -q "$APPLICATION_PATH"
#
# So instead we add an extra layer of indirection with this to bypass that
# issue.
echo `forever list` | grep -q "$APPLICATION_PATH"
if [ "$?" -eq "0" ]; then
echo "$NAME is running."
RETVAL=0
else
echo "$NAME is not running."
RETVAL=3
fi
}

case "$1" in
start)
start
;;
stop)
stop
;;
status)
status
;;
restart)
restart
;;
*)
echo "Usage: {start|stop|status|restart}"
exit 1
;;
esac
exit $RETVAL
24 changes: 24 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Enable CoffeeScript
require('coffee-script/register');
// Application config
var Config = require('./config.json');

// The Server
var ApiServer = require('apiserver');
var server = new ApiServer({ port: 8000 });
// Control modules used by the ApiServer
var SlackInterface = require('./lib/slack_interface/index')();

// Parse POST Payloads
server.use(ApiServer.payloadParser());
// Add control modules
server.addModule('1', 'slack_interface', SlackInterface);
// Routing
server.router.addRoutes([
["/handle", "1/slack_interface#handle"]
]);


// Let's go
server.listen();
console.info('Server running. Yay!');
23 changes: 23 additions & 0 deletions lib/auth_handler.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class AuthHandler
constructor: (auth_data) ->
@auth_data = auth_data

validate: (request, response) ->
@command = null
@argument = null
return false unless (request.body?.text? && request.body?.token?)

if request.body.token in @auth_data.tokens
parts = request.body.text.split ' '
if parts.length > 0
@command = parts[0]
@argument = parts[1] if parts.length > 1
return true

response.serveJSON null, {
httpStatusCode: 401,
}
return false

module.exports = (auth_data) ->
return new AuthHandler(auth_data)
61 changes: 61 additions & 0 deletions lib/slack_interface/handler.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
class SlackInterfaceHandler
constructor: (auth, spotify, volume) ->
@auth = auth
@spotify = spotify
@volume = volume

@endpoints =
handle:
post: (request, response) =>
request.resume()
request.once "end", =>
return if !@auth.validate(request, response)

# console.info "Received command: #{@auth.command} with argument: #{@auth.argument}"

reply_data = { ok: true }

switch @auth.command
when 'play' then @spotify.play @auth.argument
when 'pause' then @spotify.pause()
when 'stop' then @spotify.stop()
when 'skip' then @spotify.skip()
when 'reconnect' then @spotify.reconnect()

when 'shuffle'
@spotify.toggle_shuffle()
reply_data['text'] = if @spotify.shuffle then "ERRYDAY I'M SHUFFLING." else "I am no longer shuffling. Thanks for ruining my fun."

when 'vol'
switch @auth.argument
when "up" then @volume.up()
when "down" then @volume.down()
else @volume.set @auth.argument

when 'list'
if @auth.argument?
@spotify.set_playlist @auth.argument
else
str = 'Currently available playlists:'
for key of @spotify.config.playlists
str += "\n*#{key}*"
reply_data['text'] = str

when 'status'
shuffleword = if @spotify.shuffle then '' else ' not'
reply_data['text'] = "You are currently letting your ears feast on the beautiful tunes titled *#{@spotify.current_track_name}* from *#{@spotify.current_track_artists}*.\nYour currently selected playlist, which you are#{shuffleword} shuffling through, is named *#{@spotify.current_playlist.name}*."

when 'help'
reply_data['text'] = "You seem lost. Maybe trying one of these commands will help you out:\n*play* [Spotify-URI] - Starts or resumes playback. If you provide a Spotify-URI it will be played immediately.\n*stop* - Stops playback.\n*pause* - Pauses playback (can be resumed using *play*).\n*skip*: Skips to the next track.\n*list* [listname] - Switches to the specified Spotify-Playlist. If no list name is provided, all available lists will be shown. Playlists need to be configured beforehand, please check the project's readme for details.\n*vol* [up|down|0-10] - Sets the output volume. Either goes up or down one notch or directly to a level ranging from 0 to 10 (inclusive). 0 is mute."


response.serveJSON reply_data
return
return



module.exports = (auth, spotify, volume) ->
handler = new SlackInterfaceHandler(auth, spotify, volume)
return handler.endpoints

Loading

0 comments on commit 5356a8a

Please sign in to comment.