Browse Source

Merge branch 'total_rework'
Reworked totally w/ new packages, approach to deployment, etc

tarfeef101 4 years ago
parent
commit
b831f3cf14
8 changed files with 746 additions and 2 deletions
  1. 1 0
      .gitignore
  2. 14 0
      Dockerfile
  3. 23 2
      README.md
  4. 16 0
      docker-compose.yaml
  5. 76 0
      src/Queue.js
  6. 90 0
      src/Queue.js.bad
  7. 18 0
      src/package.json
  8. 508 0
      src/viki.js

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+src/config.json

+ 14 - 0
Dockerfile

@@ -0,0 +1,14 @@
+FROM node:15-alpine
+RUN apk add git make g++ gcc musl-dev python beets python3 ffmpeg shadow && \
+    groupmod --new-name tdedhar node && \
+    usermod -l tdedhar node && \
+    mv /home/node /home/tdedhar && \
+    chown -R tdedhar /home/tdedhar
+USER tdedhar
+ENV HOME /home/tdedhar
+RUN mkdir -p /home/tdedhar/src && \
+    mkdir -p /home/tdedhar/.config/beets
+COPY src/ /home/tdedhar/src/
+WORKDIR /home/tdedhar/src
+RUN yarn install
+ENTRYPOINT node viki.js

+ 23 - 2
README.md

@@ -1,2 +1,23 @@
-# viki
-nodejs discord bot for general purpose use
+# Viki
+Viki is a bot focused mainly on music streaming. Specifically, streaming from a `beets` music library on the docker host on which the `viki` container is run. Yes, this is a very specific, narrow use case.
+
+## Usage
+To get this running on your server, other than the normal creation of a bot user stuff on the discord, side, do the following:
+- create `src/config.json` and add:
+  - `"token"`, your bot's token
+  - `"prefix"`, the character that is the prefix for bot commands in your server
+  - `"whitelist"`, an array of users in the form `username#tag` who can run `addmusic` for your bot
+- in `docker-compose.yaml`:
+  - change the volumes to the locations of your `beets` config and media folder(s)
+  - optionally change the restart policy to your preference
+- in `Dockerfile`:
+  - change the added user to the user who owns the media library on the host
+  - change the `uid` and `gid` of this user to match the host, if necessary
+  - ensure the version of `beets` to be installed is equal or at least compatible with the host's
+- run `docker-compose up --build -d` to run the bot
+
+## Caveats
+- as mentioned above, yes, this is a very narrow use case
+- anyone you whitelist can technically execute arbitrary code in your container (and since there's bind mounts, place code on the host) through `addmusic`. so that's great
+- yes, the whitelist is an array in the config file, not role-based
+- yes, these are solvable problems

+ 16 - 0
docker-compose.yaml

@@ -0,0 +1,16 @@
+version: "3"
+
+services:
+  viki:
+    build: .
+    image: tarfeef101/viki
+    restart: on-failure
+    #restart: "no"
+    logging:
+      driver: "json-file"
+      options:
+        max-size: "200k"
+        max-file: "1"
+    volumes:
+      - "/home/tdedhar/Music:/home/tdedhar/Music"
+      - "/home/tdedhar/.config/beets:/home/tdedhar/.config/beets"

+ 76 - 0
src/Queue.js

@@ -0,0 +1,76 @@
+class Queue
+{
+  // initializes the queue
+  constructor()
+  {
+    this.arr = [];
+  }
+
+  // uses the array x to fill the queue
+  makeFilled(x)
+  {
+    this.arr = x;
+  }
+
+  // Returns true if the queue is empty, and false otherwise.
+  isEmpty()
+  {
+    return (this.arr.length === 0);
+  }
+
+  // Enqueues x in the queue (to the end)
+  enqueue(x)
+  {
+    this.arr.push(x);
+  }
+
+  // enqueues x to position i
+  insert(x, i)
+  {
+    this.arr.splice(i, 0, x);
+  }
+
+  // removes item at index i
+  remove(i)
+  {
+    this.arr.splice(i, 1);
+  }
+
+  // Dequeues an item and returns it. If the queue is empty, throws an error
+  dequeue()
+  {
+    // if the queue is empty, throw
+    if (this.arr.length === 0)
+    {
+      throw "Queue empty!";
+    }
+
+    // store the item at the front of the queue
+    const item = this.arr[0];
+    this.remove(0);
+
+    // return the dequeued item
+    return item;
+  }
+
+  // Returns the item at the front of the queue (without dequeuing it). If the
+  // queue is empty then undefined is returned.
+  peek()
+  {
+    return (this.arr.length > 0 ? this.arr[0] : undefined);
+  }
+
+  // returns an array of all items in the queue (again, without dequeuing) from the current pos.
+  read()
+  {
+    return this.arr;
+  }
+
+  // Deletes all the data, resets to as on construction
+  reset()
+  {
+    this.arr = [];
+  }
+}
+
+module.exports = Queue;

+ 90 - 0
src/Queue.js.bad

@@ -0,0 +1,90 @@
+class Queue
+{
+  // initializes the queue
+  constructor()
+  {
+    this.arr = [];
+    this.pos = 0;
+  }
+  
+  // uses the array x to fill the queue
+  makeFilled(x)
+  {
+    this.arr = x;
+    this.pos = 0;
+  }
+
+  // Returns true if the queue is empty, and false otherwise.
+  isEmpty()
+  {
+    return (this.arr.length == 0 || this.pos == (this.arr.length - 1));
+  }
+  
+  getPos()
+  {
+    return this.pos;
+  }
+
+  // Enqueues x in the queue (to the end)
+  enqueue(x)
+  {
+    this.arr.push(x);
+  }
+  
+  // enqueues x to position i
+  insert(x, i)
+  {
+    this.arr.splice(i, 0, x);
+  }
+  
+  // removes item at index i
+  remove(i)
+  {
+    this.arr.splice(i, 1);
+  }
+
+  // Dequeues an item and returns it. If the queue is empty, throws an error
+  dequeue()
+  {
+    // if the queue is empty, throw
+    if (this.arr.length == 0)
+    {
+      throw "Queue empty!";
+    }
+    
+    // if the queue is at the end already, return undefined
+    if (this.pos >= this.arr.length)
+    {
+      return undefined;
+    }
+
+    // store the item at the front of the queue
+    var item = this.arr[this.pos];
+    ++this.pos;
+
+    // return the dequeued item
+    return item;
+  }
+
+  // Returns the item at the front of the queue (without dequeuing it). If the
+  // queue is empty then undefined is returned.
+  peek()
+  {
+    return (this.arr.length > 0 ? this.arr[this.pos] : undefined);
+  }
+  
+  // returns an array of all items in the queue (again, without dequeuing) from the current pos.
+  read()
+  {
+    return this.arr.slice(this.pos);
+  }
+  
+  // Deletes all the data, resets to as on construction
+  reset()
+  {
+    this.arr = [];
+    this.pos = 0;
+  }
+}
+
+module.exports = QueueButBad;

+ 18 - 0
src/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "viki",
+  "version": "0.1.1",
+  "description": "A music bot for local file streaming using the beets music DB",
+  "main": "viki.js",
+  "license": "MIT",
+  "dependencies": {
+    "@discordjs/opus": "^0.3.3",
+    "bufferutil": "^4.0.2",
+    "discord.js": "^12.5.1",
+    "erlpack": "discord/erlpack",
+    "ffmpeg-static": "^4.2.7",
+    "libsodium-wrappers": "^0.7.8",
+    "request": "^2.88.2",
+    "utf-8-validate": "^5.0.3",
+    "zlib-sync": "^0.1.7"
+  }
+}

+ 508 - 0
src/viki.js

@@ -0,0 +1,508 @@
+// Load up the discord.js library
+const Discord = require("discord.js");
+// Load up the shell command library
+var exec = require('child_process').exec;
+// Load up the queue library
+const Queue = require('./Queue.js');
+// Initialize the bot.
+const client = new Discord.Client();
+// this allows us to define a voice connection with global scope
+var connection;
+var dispatcher;
+// playlist-related globals
+var playlist = new Queue();
+var played = []
+var cursong;
+var repeatall = false;
+var repeatone = false;
+// Array of music classes users can call (artist, track, etc)
+const musicTypes = ['track', 'title', 'song', 'artist', 'album'];
+const musicTypesString = "track, title, song, artist, album";
+// Here we load the config.json file that contains our token and our prefix values.
+const config = require("./config.json");
+
+
+// Define a function to execute a command
+function execute(command, callback)
+{
+  exec(command, function(error, stdout, stderr)
+  {
+    callback(stdout);
+  });
+};
+
+// Define a new function to search + replace a char in a string
+String.prototype.replaceAll = function(remove, replace)
+{
+    var target = this;
+    return target.split(remove).join(replace);
+};
+
+// This plays the next song in the queue, and logs that in the channel where it was requested.
+function play()
+{
+  var nextSong = cursong;
+  
+  // if we aren't repeating cursong, dequeue
+  if (!repeatone && cursong)
+  {
+    played.push(cursong);
+    nextSong = playlist.dequeue();
+  }
+  // if we set repeat, but had no songs played yet
+  // we should dequeue
+  else if (!nextSong)
+  {
+    nextSong = playlist.dequeue();
+  }
+
+  dispatcher = connection.play(nextSong[0]);
+  console.log(`Playing ${nextSong[2]}.`);
+  nextSong[1].channel.send(`Playing ${nextSong[2]}.`);
+  dispatcher.setVolume(0.2);
+  dispatcher.setBitrate(96);
+  cursong = nextSong;
+
+  var endHandler = function endHandler(reason)
+  {
+    if (!(playlist.isEmpty()))
+    {
+      play();
+    }
+    else
+    {
+      if (repeatall)
+      {
+        playlist.makeFilled(played);
+        played = [];
+        play();
+        console.log("Repeat all encountered.");
+      }
+      else
+      {
+        console.log("Playlist exhausted, music playback stopped.");
+      }
+    }
+  }
+
+  // what to do if it ends
+  dispatcher.on("finish", endHandler);
+  //dispatcher.on("close", endHandler);
+}
+
+// riven stuff
+// Load up the request library
+var Request = require("request");
+var unrolledStats = new Map();
+var rolledStats = new Map();
+var cacheTime = 0;
+
+function updateRivens()
+{
+  Request.get("http://n9e5v4d8.ssl.hwcdn.net/repos/weeklyRivensPC.json", (error, response, body) =>
+  {
+    if (error) return console.dir(error);
+
+    var rivenArr = JSON.parse(body);
+
+    for (var i = 0; i < rivenArr.length; i++)
+    {
+      var info = Object.assign({}, rivenArr[i]);
+      delete info.itemType;
+      delete info.compatibility;
+      delete info.rerolled;
+
+      // veiled rivens
+      if (!(rivenArr[i].compatibility))
+      {
+        // set value in map to info
+        unrolledStats.set(rivenArr[i].itemType.toUpperCase(), info);
+      }
+      else // weapon-specific, so check if rolled or unrolled
+      {
+        if (rivenArr[i].rerolled === true)
+        {
+          rolledStats.set(rivenArr[i].compatibility, info);
+        }
+        else
+        {
+          unrolledStats.set(rivenArr[i].compatibility, info);
+        }
+      }
+    }  
+  });
+}
+
+updateRivens();
+cacheTime = new Date().getTime() / 1000;
+
+
+client.on("ready", () =>
+{
+  // This event will run if the bot starts, and logs in, successfully.
+  console.log(`Bot has started, with ${client.users.cache.size} users, in ${client.channels.cache.size} channels of ${client.guilds.cache.size} guilds.`);
+
+  console.log(`users: ${Array.from(client.users.cache.values()).map(each => each.username)}`);
+
+  // Example of changing the bot's playing game to something useful. `client.user` is what the
+  // docs refer to as the "ClientUser".
+  client.user.setActivity("Taking Over Chicago");
+});
+
+client.on("guildCreate", guild =>
+{
+  // This event triggers when the bot joins a guild.
+  console.log(`New guild joined: ${guild.name} (id: ${guild.id}). This guild has ${guild.memberCount} members!`);
+  client.user.setActivity(`Serving ${client.guilds.cache.size} servers`);
+});
+
+client.on("guildDelete", guild => {
+  // this event triggers when the bot is removed from a guild.
+  console.log(`I have been removed from: ${guild.name} (id: ${guild.id})`);
+  client.user.setActivity("Taking Over Chicago");
+});
+
+
+client.on('message', async msg => {
+  // Ignores bot msgs
+  if (msg.author.bot) return;
+  
+  // ignores if message isn't prefixed
+  if (msg.content.indexOf(config.prefix) !== 0) return;
+  
+  // Here we separate our "command" name, and our "arguments" for the command. 
+  // e.g. if we have the message "+say Is this the real life?" , we'll get the following:
+  // command = say
+  // args = ["Is", "this", "the", "real", "life?"]
+  const args = msg.content.slice(config.prefix.length).trim().split(/ +/g);
+  const command = args.shift().toLowerCase();
+
+  if (command === 'ping')
+  {
+    const m = await msg.channel.send('pong');
+    m.edit(`Pong! Latency is ${m.createdTimestamp - msg.createdTimestamp}ms. API Latency is ${Math.round(client.ws.ping)}ms`);
+  }
+
+  if (command === 'join')
+  {
+    const channelName = args.join(' ');
+    const channel = msg.guild.channels.cache.find(each => each.name === channelName && each.type === "voice");
+
+    if (channel)
+    {
+      channel.join().then(conn =>
+      {
+        connection = conn;
+      }).catch(console.error);
+    }
+    else
+    {
+      msg.reply(`Sorry ${msg.author.username}, that channel doesn't appear to exist.`);
+    }
+  }
+
+  if (command === 'leave')
+  {
+    const channelName = args.join(' ');
+    const channel = msg.guild.channels.cache.find(each => each.name === channelName && each.type === "voice");
+
+    if (channel)
+    {
+      channel.leave();
+    }
+    else
+    {
+      msg.reply(`Sorry ${msg.author.username}, that channel doesn't appear to exist.`);
+    }
+  }
+
+  if (command === "prices")
+  {
+    // parse args 
+    var type = args[0];
+    args.splice(0, 1);
+    var query = args.join(' ');
+    query = query.toUpperCase();
+   
+    // check cache freshness
+    delta = (new Date().getTime() / 1000) - cacheTime;
+    if (delta > 3600) updateRivens();
+ 
+    if (type == "rolled")
+    {
+      var result = rolledStats.get(query);
+      if (!(result))
+      {
+        return msg.channel.send("Sorry, I couldn't find that weapon. Please check your message and try again.");
+      }
+      result = JSON.stringify(result, undefined, 2);
+      return msg.channel.send(result);
+    }
+    else if (type == "unrolled")
+    {
+      var result = unrolledStats.get(query);
+      if (!(result))
+      {
+        return msg.channel.send("Sorry, I couldn't find that weapon. Please check your message and try again.");
+      }
+      result = JSON.stringify(result, undefined, 2);
+      return msg.channel.send(result);
+    }
+    else
+    {
+      return msg.channel.send("Sorry, please enter a command in the form: prices unrolled/rolled [weapon_name]");
+    }
+  }
+
+  if (command === "addmusic") // adds songs to queue, starts playback if none already
+  {
+    if (!(config.whitelist.includes(msg.author.tag)))
+    {
+      return msg.channel.send("Sorry, you're not allowed to run this command. Please contact the server owner to acquire that permission.")
+    }
+
+    if (!connection)
+    {
+      return msg.channel.send("Please add me to a voice channel before adding music.")
+    }
+
+    var type = args[0];
+    if (!(musicTypes.includes(type)))
+    {
+      return msg.channel.send("Sorry, that is not a valid command. Please enter something from: " + musicTypesString);
+    }
+    if (type == 'song' || type == 'track') type = 'title'; // account for poor beets API
+    args.splice(0, 1);
+    const query = args.join(' '); // creates a single string of all args (the query)
+    var path; // this will hold the filepaths from our query
+    exec(`beet ls -p ${type}:${query} | wc -l`, function (error, stdout, stderr)
+    {
+      if (error)
+      {
+        return msg.channel.send(`Sorry, I encountered an issue looking for that: ${error}`);
+      }
+      else if (stdout === '\n' || stdout === '' || stdout === undefined)
+      {
+        return msg.channel.send(`There were no results that matched your search. Please give the type and name of your query (e.g. song songname, album albumname...)`);
+      }
+      else
+      {  
+        exec(`beet ls -p ${type}:${query}`, function (error, stdout, stderr)
+        {
+          if (error)
+          {
+            return msg.channel.send(`Sorry, I encountered an issue looking for that: ${error}`);
+          }
+          else
+          {
+            path = stdout.trim();
+            path = path.split("\n"); // now an array of paths (with spaces)
+            
+            // for each song, get the path and readable info, send to queue
+            for (var i = 0; i < path.length; i++)
+            {
+              let filepathRaw = path[i];
+              path[i] = path[i].replaceAll(" ", "\\ ");
+              path[i] = path[i].replaceAll("'", "\\'");
+              path[i] = path[i].replaceAll("&", "\\\46");
+              path[i] = path[i].replaceAll("(", "\\(");
+              path[i] = path[i].replaceAll(")", "\\)");
+              let filepath = path[i]; // path[i] descoped in callback
+
+              exec(`beet ls ${path[i]}`, function (error, stdouts, stderr)
+              {
+                if (error)
+                {
+                  return msg.channel.send(`Sorry, I encountered an issue looking for song ${i}: ${error}`);
+                }
+                else
+                {
+                  stdouts = stdouts.trim();
+                  playlist.enqueue([filepathRaw, msg, stdouts]);
+                  
+                  // check if music is playing, if not start it
+                  if ((!dispatcher || dispatcher.ended || dispatcher.destroyed || dispatcher.writableFinished || dispatcher.writableEnded) && !(playlist.isEmpty()))
+                  {
+                    play();
+                  }
+                }
+              });
+            }
+          }
+        });
+      }
+
+      let amt = stdout.trim();
+      msg.channel.send(`${amt} songs added!`);
+    });
+  }
+  
+  if (command === 'stop') // clears playlist, stops music
+  {
+    playlist.reset();
+    repeatone = false;
+    repeatall = false;
+    played = [];
+    dispatcher.end();
+    console.log("Playback stopped, playlist cleared.");
+    msg.channel.send("Playback stopped, playlist cleared.");
+  }
+  
+  if (command === 'next') // returns next song in playlist, or informs that there is none
+  {
+    if (playlist.isEmpty())
+    {
+      msg.channel.send("The playlist is empty.");
+    }
+    else
+    {
+      const next = playlist.peek();
+      msg.channel.send(`Next song is: ${next[2]}.`);
+    }
+  }
+
+  if (command === 'previous')
+  {
+    if (played.length <= 1)
+    {
+      msg.channel.send("No previous song.");
+    }
+    else
+    {
+      let temp = played.slice(-1).pop();
+      msg.channel.send(`Previous song was: ${temp[2]}`);
+    }
+  }
+
+  if (command === 'playlist')
+  {
+    if (playlist.isEmpty())
+    {
+      msg.channel.send("The playlist is empty.");
+    }
+    else
+    {
+      const list = playlist.read();
+      var retstr = ""
+      
+      for (var i = 0; i < list.length; i++)
+      {
+        retstr += `Song #${i + 1} is: ${list[i][2]}.\n`;
+      }
+      msg.channel.send(retstr);
+    }
+  }
+  
+  if (command === 'pause') // pauses the dispatcher if playing, or does nothing
+  {
+    dispatcher.pause(true);
+    msg.channel.send("Playback paused.");
+  }
+
+  if (command === 'resume') // resumes the dispatcher, or does nothing
+  {
+    dispatcher.resume();
+    msg.channel.send("Playback resumed.");
+  }
+
+  if (command === 'repeat')
+  {
+    var param = args[0];
+    
+    if (param === 'one' && cursong)
+    {
+      repeatone = true; // causes play function to repeat current cursong
+      repeatall = false;
+      msg.channel.send(`Repeating ${cursong[2]}.`);
+    }
+    else if (param === 'all') // track playlist, and repeat whole thing once empty
+    {
+      repeatone = false;
+      repeatall = true;
+      msg.channel.send("Repeating playlist.");
+    }
+    else if (param === 'off') // resets repeat variables
+    {
+      repeatone = false;
+      repeatall = false;
+      msg.channel.send("Repeat off.");
+    }
+    else
+    {
+      msg.channel.send("There was nothing to repeat, or an invalid option was given. Valid options are one, all, and off.");
+    }
+  }
+
+  if (command === 'skip') // starts playing the next song in the queue if it exists
+  {
+    if (playlist.isEmpty())
+    {
+      msg.channel.send("Sorry, the playlist is empty.");
+    }
+    else
+    {
+      function resolveEnd()
+      {
+        return new Promise((success, fail) =>
+        {
+          dispatcher.end();
+
+          dispatcher.on("finish", () =>
+          {
+            success('Track skipped!');
+          });
+
+          dispatcher.on("error", () =>
+          {
+            fail('Couldn\'t skip :(');
+          });
+        });
+      }
+
+      resolveEnd();
+    }
+  }
+
+  if (command === 'back') // if possible, adds cursong to queue at the front, starts playing last song
+  {
+    if (played.length == 0)
+    {
+      msg.channel.send("Sorry, there is no song to skip back to.");
+    }
+    else
+    {
+      function resolveEnd()
+      {
+        return new Promise((success, fail) =>
+        {
+          /*
+          playlist.reset();
+          repeatone = false;
+          repeatall = false;
+          played = [];
+          dispatcher.end();
+          */
+          playlist.cut(cursong); // put cursong back on
+          let tempsong = played[played.length - 1]; // captures the song to go back to
+          played = played.splice(played.length - 1, 1); // removes the last song from played
+          playlist.cut(tempsong); // put old song on the front
+          dispatcher.end(); // stop playing wrong song
+
+          dispatcher.on("finish", () =>
+          {
+            success('Track reversed!');
+          });
+
+          dispatcher.on("error", () =>
+          {
+            fail('Couldn\'t skip :(');
+          });
+        });
+      }
+
+      resolveEnd();
+    }
+  }
+});
+
+client.login(config.token);