text: /**
   * @author Jonas Bögle
   * @license MIT
   * 
   * MIT License
   * 
   * Copyright (c) 2019-2020 Jonas Bögle, Michael Friese
   * 
   * Permission is hereby granted, free of charge, to any person obtaining a copy
   * of this software and associated documentation files (the "Software"), to deal
   * in the Software without restriction, including without limitation the rights
   * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   * copies of the Software, and to permit persons to whom the Software is
   * furnished to do so, subject to the following conditions:
   * 
   * The above copyright notice and this permission notice shall be included in all
   * copies or substantial portions of the Software.
   * 
   * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
   * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
   * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
   * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
   * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
   * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
   * SOFTWARE.
   * 
   * Thanks to the following GitHub sponsors for supporting my work:
   * - Michael Friese
   * - Jay Vasallo
   * 
   * https://github.com/sponsors/irgendwr
   * 
   */
  registerPlugin({
      name: 'SinusBot Commands',
      version: '1.1.2',
      description: 'Enables the default commands.',
      author: 'Jonas Bögle (@irgendwr)',
      engine: '>= 1.0.0',
      backends: ['ts3', 'discord'],
      // the next line is not required since beta.7
      //requiredModules: ['discord-dangerous'],
      autorun: true,
      vars: [
          {
              /*
               * Note: Normally you should **not** add something like this,
               * because you can already disable scripts by unchecking them.
               * However in this case it makes sense as `autorun: true`
               * no longer allows users to disable it otherwise.
               */
              name: 'disable',
              title: 'Disable the default SinusBot commands.',
              type: 'checkbox',
              default: false
          },
          {
              name: 'createSuccessReaction',
              title: 'Add a reaction to each command if it was successfull.',
              type: 'checkbox',
              default: false,
              /* conditions: [
                  { field: 'disable', value: false }
              ], */ // conditions checking for "false" are currently buggy. @flyth needs to fix this.
          },
          {
              name: 'discord',
              title: 'Show discord settings',
              type: 'checkbox',
              default: true,
              /* conditions: [
                  { field: 'disable', value: false }
              ], */ // conditions checking for "false" are currently buggy. @flyth needs to fix this.
          },
          {
              name: 'url',
              title: 'URL to Webinterface (optional, for album covers in discord)',
              type: 'string',
              placeholder: 'i.e. https://sinusbot.example.com',
              conditions: [
                  { field: 'discord', value: true },
                  /*{ field: 'disable', value: false }*/
              ], // conditions checking for "false" are currently buggy. @flyth needs to fix this.
          },
          {
              name: 'songInStatus',
              title: 'Show playing song in status.',
              type: 'checkbox',
              default: true,
              conditions: [
                  { field: 'discord', value: true }
              ],
          },
          {
              name: 'deleteOldMessages',
              title: 'Delete previous responses if !playing command is used again',
              type: 'checkbox',
              default: true,
              conditions: [
                  { field: 'discord', value: true },
                  /*{ field: 'disable', value: false }*/
              ], // conditions checking for "false" are currently buggy. @flyth needs to fix this.
          }
      ]
  }, (_, config, meta) => {
      const event = require('event')
      const engine = require('engine')
      const backend = require('backend')
      const format = require('format')
      const audio = require('audio')
      const media = require('media')
  
      engine.log(`Loaded ${meta.name} v${meta.version} by ${meta.author}.`)
      engine.log(`SinusBot v${engine.version()} on ${engine.os()}`)
  
      /********* privileges *********/
      const ENQUEUE           = 1 << 13;
      const SKIP_QUEUE        = 1 << 14;
      const ADMIN_QUEUE       = 1 << 15;
      const PLAYBACK          = 1 << 12;
      const START_STOP        = 1 <<  8;
      const EDIT_BOT_SETTINGS = 1 << 16;
      const LOGIN             = 1 <<  0;
      const UPLOAD_FILES      = 1 <<  2;
      const DELETE_FILES      = 1 <<  3;
      const EDIT_FILES        = 1 <<  4;
      const CREATE_AND_DELETE_PLAYLISTS = 1 << 5;
      const EDIT_PLAYLISTS    = 1 <<  7;
      const EDIT_INSTANCES    = 1 << 17;
      const EDIT_USERS        = 1 <<  9;
  
      const ERROR_PREFIX = '❌ ';
      const WARNING_PREFIX = '⚠ ';
      const SUCCESS_PREFIX = '✔ ';
      const USAGE_PREFIX = ERROR_PREFIX + 'Usage: ';
  
      const sinusbotURL = config.url;
      const REACTION_PREV = '⏮';
      const REACTION_PLAYPAUSE = '⏯';
      const REACTION_NEXT = '⏭';
      const REACTION_SUCCESS = '✅';
      const HIDE_REACTIONS_IF_PRIVATE = false;
  
      const PATTERN_URL = /^https?:\/\/\S+/i;
      const PATTERN_YT_DOMAIN = /^https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\//i;
  
      // for join/leave
      const ERROR_BOT_NULL = ERROR_PREFIX+'Unable to change channel.\nTry to set a *Default Channel* in the webinterface and click save.'
  
      /** @type {object[]} */
      let lastEmbeds = [];
  
      if (config.discord && engine.getBackend() != 'discord') {
          // hide discord-only settings if backend is not discord
          config.discord = false;
          engine.saveConfig(config);
      } else if (!config.discord && engine.getBackend() == 'discord') {
          // show discord-only settings if backend is discord
          config.discord = true;
          engine.saveConfig(config);
      }
  
      const ytCallbacks = {};
      event.on("ytdl.success", ev => {
          engine.log(`Downloaded YouTube Video: ${ev.url}`);
          const jobId = ev.jobId;
          if (typeof ytCallbacks[jobId] === 'function') {
              ytCallbacks[jobId](ev);
              delete ytCallbacks[jobId];
          }
      });
  
      event.on("ytdl.error", (ev, message) => {
          engine.log(`Error while downloading YouTube Video: ${ev.url}; ${message}`);
          if (message.startsWith("exit status")) {
              engine.log('Please see "Upload" page in web-interface for more details and read the documentation for troubleshooting advice: https://sinusbot.github.io/docs/youtube-dl/');
          }
  
          const jobId = ev.jobId;
          if (typeof ytCallbacks[jobId] === 'function') {
              ytCallbacks[jobId](ev, message);
              delete ytCallbacks[jobId];
          }
      });
  
      /**
       * Registers a callback for success/error events of a given jobId.
       * @param {string} jobId Job ID
       * @param {(ev: {url: string, jobId: string, trackId?: string}, message: string) => void} callback Callback
       */
      function ytCallback(jobId, callback) {
          ytCallbacks[jobId] = callback;
      }
  
      /**
       * 
       * @param {string} jobId Job ID
       * @param {MessageEvent} ev Event
       * @param {(msg: string) => void} reply
       */
      function handleYT(jobId, ev, reply) {
          ytCallback(jobId, (ytev, err) => {
              if (err) {
                  if (err.startsWith("exit status")) {
                      reply(ERROR_PREFIX + `Error: ${err}; Please see "Upload" page in web-interface for more details.`);
                  } else {
                      reply(ERROR_PREFIX + `Error: ${err}`);
                  }
                  return;
              }
              successReaction(ev, reply);
          });
      }
  
      if (config.disable) {
          engine.log('SinusBot commands are DISABLED.');
      } else { // BEGIN COMMANDS-ENABLED
          event.on('load', () => {
              const command = require('command');
              if (!command) {
                  engine.log('command.js library not found! Please download command.js to your scripts folder and restart the SinusBot, otherwise this script will not work.');
                  engine.log('command.js can be found here: https://github.com/Multivit4min/Sinusbot-Command/blob/master/command.js');
                  return;
              }
              const {createCommand} = command;
              
              createCommand('register')
              .addArgument(args => args.string.setName('username'))
              .help('Register a new user')
              .manual('Registers a new user bound to the Account you are using. This account has no privileges by default but can be edited by the bot administrators.')
              .exec((client, args, reply, ev) => {
                  if (!engine.registrationEnabled()) {
                      reply('Registration is disabled.');
                      return;
                  }
  
                  // print syntax if no username given
                  if (!args.username) {
                      reply(USAGE_PREFIX + 'register <username>');
                      return;
                  }
  
                  if (engine.getUserByName(args.username)) {
                      reply(ERROR_PREFIX + 'This username already exists.');
                      return;
                  }
  
                  // check if client already has a user
                  let user = getUserByUid(client.uid());
                  if (user) {
                      reply(ERROR_PREFIX + `You already have a user with the name "${user.name()}".`);
                      return;
                  }
  
                  // create user
                  let newUser = engine.addUser(args.username);
                  if (!newUser) {
                      reply(ERROR_PREFIX + 'Unable to create user, try another username.');
                      return;
                  }
  
                  if (!newUser.setUid(client.uid())) {
                      newUser.delete()
                      reply(ERROR_PREFIX + 'Unable to assign uid to user.');
                      return;
                  }
                  reply(SUCCESS_PREFIX + 'Registered a user with the given name.\nThis account has no privileges by default but can be edited by the bot administrators.');
                  successReaction(ev, reply);
              });
              
              createCommand('password')
              .alias('pass')
              .addArgument(args => args.rest.setName('value'))
              .help('Change your password')
              .manual('Changes your password to <value>.')
              .checkPermission(client => getUserByUid(client.uid()))
              .exec((client, args, reply, ev) => {
                  // print syntax if no value given
                  if (!args.value) {
                      reply(USAGE_PREFIX + 'password <value>\n'+ WARNING_PREFIX + 'Don\'t use this command in a public channel.');
                      return;
                  }
  
                  if (ev.mode !== 1) {
                      reply(WARNING_PREFIX + 'Don\'t use this command in a public channel.');
                      return;
                  }
  
                  let user = getUserByUid(client.uid());
                  if (!user) {
                      reply(ERROR_PREFIX + `You don't have a user-account. Use ${format.bold('!register')} to create one.`);
                      return;
                  }
  
                  // set password
                  if (!user.setPassword(args.value)) {
                      reply(ERROR_PREFIX + 'Unable to set password.');
                      return;
                  }
                  reply(SUCCESS_PREFIX + 'Changed your password.');
                  successReaction(ev, reply);
              });
  
              createCommand('whoami')
              .help('Show user identities')
              .manual('Shows user identities matching your ID/groups.')
              .exec((client, args, reply, ev) => {
                  let users = getUsersByClient(client);
                  if (users && users.length != 0) {
                      const usersStr = users.map(user => user.name()).join(", ");
                      if (users.length == 1) {
                          reply(`You match the following user: ${usersStr}.`);
                      } else {
                          reply(`You match the following ${users.length} users: ${usersStr}.`);
                      }
                  } else {
                      reply("You don't match any users.");
                  }
                  successReaction(ev, reply);
              });
  
              createCommand('privileges')
              .alias('priv', 'privs')
              .help('Show user privileges')
              .manual('Shows user privileges.')
              .exec((client, args, reply, ev) => {
                  let privs = [
                      { n: ENQUEUE, s: 'Enqueue' },
                      { n: SKIP_QUEUE, s: 'Skip Queue' },
                      { n: ADMIN_QUEUE, s: 'Admin Queue' },
                      { n: PLAYBACK, s: 'Playback' },
                      { n: START_STOP, s: 'Start/Stop' },
                      { n: EDIT_BOT_SETTINGS, s: 'Edit Bot Settings' },
                      { n: LOGIN, s: 'Login' },
                      { n: UPLOAD_FILES, s: 'Upload Files' },
                      { n: DELETE_FILES, s: 'Delete Files' },
                      { n: EDIT_FILES, s: 'Edit Files' },
                      { n: CREATE_AND_DELETE_PLAYLISTS, s: 'Create/Delete Playlists' },
                      { n: EDIT_PLAYLISTS, s: 'Edit Playlists' },
                      { n: EDIT_INSTANCES, s: 'Edit Instances' },
                      { n: EDIT_USERS, s: 'Edit Users' },
                  ];
                  let uprivs = [];
                  privs.forEach(p => {
                      if (requirePrivileges(p.n)(client)) {
                          uprivs.push(p.s);
                      }
                  })
  
                  if (uprivs.length >= 1) {
                      reply(`You have the following privileges: ${uprivs.join(", ")}.`);
                  } else {
                      reply(`You have no privileges.`);
                  }
                  //reply(getUsersByClient(client).map(u => u.privileges().toString(2)).join(", "));
  
                  successReaction(ev, reply);
              });
  
              createCommand('playing')
              .help('Show what\'s currently playing')
              .manual('Show what\'s currently playing')
              .exec((client, args, reply, ev) => {
                  if (engine.getBackend() !== 'discord' || !ev.message) {
                      if (!audio.isPlaying()) {
                          successReaction(ev, reply);
                          return reply('There is nothing playing at the moment.');
                      }
  
                      reply(formatTrack(media.getCurrentTrack()));
                      successReaction(ev, reply);
                      return;
                  }
  
                  let msg = getPlayingEmbed();
                  if (!audio.isPlaying()) {
                      msg.content = 'There is nothing playing at the moment.';
                  }
  
                  backend.extended().createMessage(ev.message.channelID(), msg, (err, res) => {
                      if (err) return engine.log(err);
                      if (!res) return engine.log('Error: empty response');
  
                      const {id, channel_id} = JSON.parse(res);
  
                      // messages that should be deleted
                      let deleteMsg = [];
                      const msgId = ev.message ? ev.message.ID() : null;
                      const index = lastEmbeds.findIndex(embed => embed.channelId == channel_id);
                      if (index !== -1) {
                          if (config.deleteOldMessages) {
                              // delete previous embed
                              deleteMsg.push(lastEmbeds[index].messageId);
                              // delete previous command from user
                              if (lastEmbeds[index].messageId) {
                                  deleteMsg.push(lastEmbeds[index].invokeMessageId);
                              }
                          }
                          // save new embed
                          lastEmbeds[index].messageId = id;
                          lastEmbeds[index].invokeMessageId = msgId;
                      } else {
                          // save new embed
                          lastEmbeds.push({
                              channelId: channel_id,
                              messageId: id,
                              invokeMessageId: msgId
                          });
                      }
  
                      deleteMessages(channel_id, deleteMsg);
  
                      if (HIDE_REACTIONS_IF_PRIVATE && ev.mode === 1) return;
                      
                      wait(1000)
                      // create reaction controls
                      .then(() => createReaction(channel_id, id, REACTION_PREV))
                      .then(() => wait(150))
                      .then(() => createReaction(channel_id, id, REACTION_PLAYPAUSE))
                      .then(() => wait(150))
                      .then(() => createReaction(channel_id, id, REACTION_NEXT));
                  });
                  successReaction(ev, reply);
              });
  
              createCommand('next')
              .help('Play the next track')
              .manual('Plays the next track (only when a playlist or queue is active).')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  media.playNext();
                  successReaction(ev, reply);
              });
  
              createCommand('prev')
              .alias('previous')
              .help('Play the previous track')
              .manual('Plays the previous track (only when a playlistis active).')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  media.playPrevious();
                  successReaction(ev, reply);
              });
  
              createCommand('search')
              .alias('s')
              .addArgument(args => args.rest.setName('searchstring'))
              .help('Search for tracks')
              .manual('Searches for tracks, returns 20 results at most.')
              .checkPermission(requirePrivileges(PLAYBACK, ENQUEUE))
              .exec((client, args, reply, ev) => {
                  // print syntax if no searchstring given
                  if (!args.searchstring) {
                      reply(USAGE_PREFIX + 'search <searchstring>');
                      return;
                  }
  
                  const tracks = media.search(args.searchstring);
                  if (tracks.length == 0) {
                      reply('Sorry, nothing found.');
                      successReaction(ev, reply);
                      return;
                  }
  
                  const response = tracks.map(formatTrack).join("\n")
                  reply(response);
                  successReaction(ev, reply);
              });
  
              createCommand('play')
              .alias('p')
              .addArgument(args => args.rest.setName('idORsearchstring', 'searchstring / uuid'))
              .help('Play a track by its id or name')
              .manual('Plays a track by its id or searches for a track and plays the first match.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  let query = args.idORsearchstring;
                  // print syntax if no idORsearchstring given
                  if (!query) {
                      reply(USAGE_PREFIX + 'play <searchstring / uuid>');
                      return;
                  }
  
                  let track = media.getTrackByID(query);
                  if (!track) {
                      let tracks = media.search(query);
                      if (tracks.length > 0) {
                          track = tracks[0];
                      } else {
                          query = stripURL(query);
                          if (query.match(PATTERN_URL)) {
                              if (!query.match(PATTERN_YT_DOMAIN)) {
                                  if (media.playURL(query)) {
                                      successReaction(ev, reply);
                                      return;
                                  }
                              }
                              if (media.ytStream(query)) {
                                  successReaction(ev, reply);
                                  return;
                              }
                              const jobId = media.yt(query);
                              handleYT(jobId, ev, reply);
                              return;
                          }
                          return reply('Sorry, nothing found.');
                      }
                  }
  
                  track.play();
                  reply(`Playing ${formatTrack(track)}`);
                  successReaction(ev, reply);
              });
  
              createCommand('playlist')
              .addArgument(args => args.rest.setName('playlistname'))
              .help('Start playing back the playlist <playlistname>')
              .manual('starts playing back the playlist <playlistname>.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  // print syntax if no playlistname given
                  if (!args.playlistname) {
                      reply(USAGE_PREFIX + 'playlist <playlistname>');
                      return;
                  }
  
                  const match = media.getPlaylists().find(playlist => {
                      // case insensitive equals
                      return playlist.name() == args.playlistname || playlist.name().localeCompare(args.playlistname, undefined, { sensitivity: 'accent' }) === 0
                  });
  
                  if (!match) {
                      reply('Sorry, no matching playlist found.');
                      successReaction(ev, reply);
                      return;
                  }
                  
                  media.playlistPlayByID(match, 0);
  
                  successReaction(ev, reply);
              });
  
              createCommand('queue')
              .alias('q')
              .addArgument(args => args.rest.setName('idORsearchstring', 'searchstring / uuid').optional(true))
              .help('Enqueue a track or resume queue')
              .manual('Enqueue a track by its id or search for a track and enqueue the first match. When no track is provided it wil resume the queue.')
              .checkPermission(requirePrivileges(PLAYBACK, ENQUEUE))
              .exec((client, args, reply, ev) => {
                  if (!args.idORsearchstring) {
                      if (!audio.isPlaying()) {
                          media.playQueueNext();
                      }
                      return;
                  }
  
                  let track = media.getTrackByID(args.idORsearchstring);
                  if (!track) {
                      const tracks = media.search(args.idORsearchstring);
                      if (tracks.length > 0) {
                          track = tracks[0];
                      } else {
                          reply('Sorry, nothing found.');
                          return;
                      }
                  }
  
                  track.enqueue();
                  reply(`Added ${formatTrack(track)} to the queue`);
                  successReaction(ev, reply);
              });
  
              createCommand('queuenext')
              .alias('qnext', 'qn')
              .addArgument(args => args.rest.setName('idORsearchstring', 'searchstring / uuid'))
              .help('Prepends a track to the queue')
              .manual('Prepends a track by its id or searches for a track and prepends the first match to the queue.')
              .checkPermission(requirePrivileges(SKIP_QUEUE))
              .exec((client, args, reply, ev) => {
                  // print syntax if no idORsearchstring given
                  if (!args.idORsearchstring) {
                      reply(USAGE_PREFIX + 'queuenext <searchstring / uuid>');
                      return;
                  }
  
                  let track = media.getTrackByID(args.idORsearchstring);
                  if (!track) {
                      const tracks = media.search(args.idORsearchstring);
                      if (tracks.length > 0) {
                          track = tracks[0];
                      } else {
                          reply('Sorry, nothing found.');
                          return;
                      }
                  }
  
                  track.enqueue();
                  reply(`Added ${formatTrack(track)} to the queue`);
                  successReaction(ev, reply);
              });
  
              createCommand('stop')
              .help('Stop playback')
              .manual('Stops playback.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  media.stop();
                  successReaction(ev, reply);
              });
  
              createCommand('!stop')
              .help('Stop playback and remove idle-track')
              .manual('Stops playback and removes idle-track.')
              .checkPermission(requirePrivileges(PLAYBACK|EDIT_BOT_SETTINGS))
              .exec((client, args, reply, ev) => {
                  media.stop();
                  media.clearIdleTrack();
                  successReaction(ev, reply);
              });
  
              createCommand('volume')
              .alias('vol')
              .addArgument(args => args.string.setName('value'))
              .help('Change the volume')
              .manual('Changes the volume.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  let value = args.value;
                  let volume = audio.getVolume();
  
                  switch (value) {
                  case 'up':
                      volume += 10;
                      break;
                  case 'dn':
                  case 'down':
                      volume -= 10;
                      break;
                  default:
                      value = parseInt(value, 10);
                      if (value >= 0 && value <= 100) {
                          volume = value;
                      } else {
                          reply(USAGE_PREFIX + 'volume <up|down|dn|0-100>');
                          return;
                      }
                  }
                  
                  if (volume < 0) {
                      volume = 0;
                  } else if (volume > 100) {
                      volume = 100;
                  }
  
                  audio.setVolume(volume);
                  successReaction(ev, reply);
              });
  
              createCommand('stream')
              .addArgument(args => args.string.setName('url'))
              .help('Stream a url')
              .manual('Streams from <url>; this may be http-streams like shoutcast / icecast or just remote soundfiles.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  // print syntax if no url given
                  if (!args.url) {
                      reply(USAGE_PREFIX + 'stream <url>');
                      return;
                  }
  
                  const url = stripURL(args.url);
  
                  if (url.match(PATTERN_YT_DOMAIN)) {
                      if (!media.ytStream(url)) {
                          reply(ERROR_PREFIX + 'Unable to stream this YouTube URL.');
                          return;
                      }
                      successReaction(ev, reply);
                      return;
                  }
  
                  if (!media.playURL(url)) {
                      reply(ERROR_PREFIX + 'Unable to stream this URL.');
                      return;
                  }
                  successReaction(ev, reply);
              });
  
              createCommand('say')
              .addArgument(args => args.rest.setName('text'))
              .help('Say a text via TTS')
              .manual('Uses text-to-speech (if configured) to say the given text.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  // print syntax if no text given
                  if (!args.text) {
                      reply(USAGE_PREFIX + 'say <text>');
                      return;
                  }
  
                  audio.say(args.text);
                  successReaction(ev, reply);
              });
  
              createCommand('sayex')
              .addArgument(args => args.string.setName('locale'))
              .addArgument(args => args.rest.setName('text'))
              .help('Say a text via TTS with given locale')
              .manual('Uses text-to-speech (if configured) to say the given text with a given locale.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  // print syntax if no locale/text given
                  if (!args.locale || !args.text) {
                      reply(USAGE_PREFIX + 'sayex <locale> <text>');
                      return;
                  }
  
                  audio.say(args.text, args.locale);
                  successReaction(ev, reply);
              });
  
              createCommand('ttsurl')
              .addArgument(args => args.string.setName('url'))
              .help('Set the TTS url.')
              .manual('Sets the TTS url.')
              .checkPermission(requirePrivileges(EDIT_BOT_SETTINGS))
              .exec((client, args, reply, ev) => {
                  // print syntax if no url given
                  if (!args.url) {
                      reply(USAGE_PREFIX + 'ttsurl <url>');
                      return;
                  }
  
                  audio.setTTSURL(stripURL(args.url));
                  successReaction(ev, reply);
              });
  
              createCommand('ttslocale')
              .addArgument(args => args.string.setName('locale'))
              .help('Set the TTS locale.')
              .manual('Sets the TTS locale.')
              .checkPermission(requirePrivileges(EDIT_BOT_SETTINGS))
              .exec((client, args, reply, ev) => {
                  // print syntax if no locale given
                  if (!args.locale) {
                      reply(USAGE_PREFIX + 'ttslocale <locale>');
                      return;
                  }
  
                  audio.setTTSDefaultLocale(args.locale);
                  successReaction(ev, reply);
              });
  
              createCommand('yt')
              .addArgument(args => args.string.setName('url'))
              .help('Play <url> via youtube-dl')
              .manual('Plays <url> via external youtube-dl (if enabled); beware: the file will be downloaded first and played back afterwards, so there might be a slight delay before playback starts.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  const url = stripURL(args.url);
                  if (!url) return reply(USAGE_PREFIX + 'yt <url>');
                  if (!url.match(PATTERN_URL)) return reply(ERROR_PREFIX + 'Invalid URL.');
  
                  const jobId = media.yt(url);
                  ytCallback(jobId, (ytev, err) => {
                      if (err) {
                          // try to stream
                          if (!media.ytStream(url)) {
                              if (err.startsWith("exit status")) {
                                  return reply(ERROR_PREFIX + `Error: ${err}; Please see "Upload" page in web-interface for more details.`);
                              }
                              return reply(ERROR_PREFIX + `Error: ${err}`);
                          }
                      }
                      successReaction(ev, reply);
                  });
              });
  
              createCommand('ytstream')
              .alias('streamyt')
              .addArgument(args => args.string.setName('url'))
              .help('Stream <url> via youtube-dl')
              .manual('Streams <url> via external youtube-dl (if enabled)')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  const url = stripURL(args.url);
                  if (!url) return reply(USAGE_PREFIX + 'ytstream <url>');
                  if (!url.match(PATTERN_URL)) return reply(ERROR_PREFIX + 'Invalid URL.');
  
                  if (!media.ytStream(url)) {
                      return reply(ERROR_PREFIX + 'Unable to stream this URL.');
                  }
                  successReaction(ev, reply);
              });
  
              createCommand('ytdl')
              .addArgument(args => args.string.setName('url'))
              .help('Download and play <url> via youtube-dl')
              .manual('Plays <url> via external youtube-dl (if enabled); beware: the file will be downloaded first and played back afterwards, so there might be a slight delay before playback starts; additionally, the file will be stored.')
              .checkPermission(requirePrivileges(PLAYBACK|UPLOAD_FILES))
              .exec((client, args, reply, ev) => {
                  const url = stripURL(args.url);
                  if (!url) return reply(USAGE_PREFIX + 'ytdl <url>');
                  if (!url.match(PATTERN_URL)) return reply(ERROR_PREFIX + 'Invalid URL.');
  
                  const jobId = media.ytdl(url, true);
                  handleYT(jobId, ev, reply);
              });
  
              createCommand('qyt')
              .addArgument(args => args.string.setName('url'))
              .help('Enqueue <url> via youtube-dl')
              .manual('Enqueues <url> via external youtube-dl (if enabled); beware: the file will be downloaded first and played back afterwards, so there might be a slight delay before playback starts.')
              .checkPermission(requirePrivileges(PLAYBACK, ENQUEUE))
              .exec((client, args, reply, ev) => {
                  const url = stripURL(args.url);
                  if (!url) return reply(USAGE_PREFIX + 'qyt <url>');
                  if (!url.match(PATTERN_URL)) return reply(ERROR_PREFIX + 'Invalid URL.');
  
                  const jobId = media.enqueueYt(url);
                  handleYT(jobId, ev, reply);
              });
  
              createCommand('qytdl')
              .addArgument(args => args.string.setName('url'))
              .help('Download and enqueue <url> via youtube-dl')
              .manual('Enqueues <url> via external youtube-dl (if enabled); beware: the file will be downloaded first and played back afterwards, so there might be a slight delay before playback starts; additionally, the file will be stored.')
              .checkPermission(requirePrivileges(PLAYBACK|UPLOAD_FILES, ENQUEUE|UPLOAD_FILES))
              .exec((client, args, reply, ev) => {
                  const url = stripURL(args.url);
                  if (!url) return reply(USAGE_PREFIX + 'qytdl <url>');
                  if (!url.match(PATTERN_URL)) return reply(ERROR_PREFIX + 'Invalid URL.');
  
                  const jobId = media.enqueueYtdl(url);
                  handleYT(jobId, ev, reply);
              });
  
              createCommand('shuffle')
              .help('Toggle shuffle')
              .manual('Toggles shuffle.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  audio.setShuffle(!audio.isShuffle());
                  reply(SUCCESS_PREFIX + `Shuffle is now ${audio.isShuffle() ? 'en' : 'dis'}abled.`);
                  successReaction(ev, reply);
              });
  
              createCommand('repeat')
              .help('Toggle repeat')
              .manual('Toggles repeat.')
              .checkPermission(requirePrivileges(PLAYBACK))
              .exec((client, args, reply, ev) => {
                  audio.setRepeat(!audio.isRepeat());
                  reply(SUCCESS_PREFIX + `Repeat is now ${audio.isRepeat() ? 'en' : 'dis'}abled.`);
                  successReaction(ev, reply);
              });
  
              if (engine.getBackend() == 'ts3') {
                  createCommand('sub')
                  .help('Subscribe to bot')
                  .manual('Subscribes to the bot. (subscription transfer-mode only)')
                  .checkPermission(() => engine.isSubscriptionMode())
                  .exec((client, args, reply, ev) => {
                      if (!engine.isSubscriptionMode()) {
                          reply(ERROR_PREFIX + 'This command only works if Transmit-Mode is set to Subscription.');
                          return;
                      }
                      client.subscribe(true);
                      successReaction(ev, reply);
                  });
  
                  createCommand('unsub')
                  .help('Unsubscribe from bot')
                  .manual('Unsubscribes from the bot. (subscription transfer-mode only)')
                  .checkPermission(() => engine.isSubscriptionMode())
                  .exec((client, args, reply, ev) => {
                      if (!engine.isSubscriptionMode()) {
                          reply(ERROR_PREFIX + 'This command only works if Transmit-Mode is set to Subscription.');
                          return;
                      }
                      client.subscribe(false);
                      successReaction(ev, reply);
                  });
  
                  createCommand('subchan')
                  .help('Add subscription for channel')
                  .manual('Adds subscription for the channel the user is currently in. (subscription transfer-mode only)')
                  .checkPermission(client => requirePrivileges(EDIT_BOT_SETTINGS)(client) && engine.isSubscriptionMode())
                  .exec((client, args, reply, ev) => {
                      if (!engine.isSubscriptionMode()) {
                          reply(ERROR_PREFIX + 'This command only works if Transmit-Mode is set to Subscription.');
                          return;
                      }
                      client.getChannels()[0].subscribe(true);
                      successReaction(ev, reply);
                  });
  
                  createCommand('unsubchan')
                  .help('Remove subscription for channel')
                  .manual('Removes subscription for the channel the user is currently in. (subscription transfer-mode only)')
                  .checkPermission(client => requirePrivileges(EDIT_BOT_SETTINGS)(client) && engine.isSubscriptionMode())
                  .exec((client, args, reply, ev) => {
                      if (!engine.isSubscriptionMode()) {
                          reply(ERROR_PREFIX + 'This command only works if Transmit-Mode is set to Subscription.');
                          return;
                      }
                      client.getChannels()[0].subscribe(false);
                      successReaction(ev, reply);
                  });
  
                  createCommand('mode')
                  .addArgument(args => args.string.setName('mode'))
                  .help('Change Transmit-Mode')
                  .manual('Changes Transmit-Mode; 0 = to channel, 1 = subscription mode')
                  .checkPermission(requirePrivileges(EDIT_BOT_SETTINGS))
                  .exec((client, args, reply, ev) => {
                      let mode = args.mode;
                      if (typeof mode === 'string') {
                          mode = mode.toLowerCase();
                      }
  
                      switch (mode) {
                      case "0":
                      case "chan":
                      case "channel":
                          engine.setSubscriptionMode(false);
                          reply(SUCCESS_PREFIX + 'Transmit-Mode is now set to Channel (default).');
                          successReaction(ev, reply);
                          break;
                      case "1":
                      case "sub":
                      case "subscription":
                          engine.setSubscriptionMode(true);
                          reply(SUCCESS_PREFIX + 'Transmit-Mode is now set to Subscription.');
                          successReaction(ev, reply);
                          break;
                      default:
                          reply(`Transmit-Mode is currently set to ${engine.isSubscriptionMode() ? 'Subscription' : 'Channel (default)'}.\n` + USAGE_PREFIX + 'mode <0|chan(nel)|1|sub(scription)>');
                      }
                  });
              }
  
              createCommand('registration')
              .addArgument(args => args.string.setName('value'))
              .help('Enable / disable user registration via chat')
              .manual('Enables / disables user registration via chat. Value should be either `enable` or `disable`.')
              .checkPermission(requirePrivileges(EDIT_BOT_SETTINGS))
              .exec((client, args, reply, ev) => {
                  switch (args.value) {
                  case "enable":
                      engine.enableRegistration();
                      reply(SUCCESS_PREFIX + 'Registration is now enabled.');
                      successReaction(ev, reply);
                      break;
                  case "disable":
                      engine.disableRegistration();
                      reply(SUCCESS_PREFIX + 'Registration is now disabled.');
                      successReaction(ev, reply);
                      break;
                  default:
                      reply(`Registration is currently ${engine.registrationEnabled() ? 'en' : 'dis'}abled.\n` + USAGE_PREFIX + 'registration <enable|disable>');
                  }
              });
  
              createCommand('prefix')
              .addArgument(args => args.string.setName('prefix'))
              .help('Change command prefix')
              .manual('Changes the prefix for all core commands to <new prefix>, default is "!".')
              .checkPermission(requirePrivileges(EDIT_BOT_SETTINGS))
              .exec((client, args, reply, ev) => {
                  // print syntax if no prefix given
                  if (!args.prefix) {
                      reply(USAGE_PREFIX + 'prefix <new prefix>');
                      return;
                  }
  
                  engine.setCommandPrefix(args.prefix);
                  reply(SUCCESS_PREFIX + 'New prefix: ' + args.prefix);
                  successReaction(ev, reply);
              });
  
              createCommand('ping')
              .help('responds with "PONG"')
              .exec((client, args, reply, ev) => {
                  reply(`PONG`);
                  successReaction(ev, reply);
              });
  
              createCommand('version')
              .help('Show version')
              .manual('Shows the SinusBot version.')
              .checkPermission(requirePrivileges(EDIT_BOT_SETTINGS))
              .exec((client, args, reply, ev) => {
                  reply(`SinusBot v${engine.version()} on ${engine.os()}\nsinusbot-commands.js v${meta.version}\ncommand.js v${command.getVersion()}`);
                  successReaction(ev, reply);
              });
  
              createCommand('reload')
              .help('Reload scripts')
              .manual('Reloads scripts.\nNote: Adding new scripts requires a complete sinusbot restart.')
              .checkPermission(requirePrivileges(EDIT_BOT_SETTINGS))
              .exec((client, args, reply, ev) => {
                  reply('reloading...');
                  let success = engine.reloadScripts();
                  if (success) {
                      reply(SUCCESS_PREFIX + `Scripts reloaded.\n*Please note: adding new scripts requires a complete sinusbot restart.*`);
                      successReaction(ev, reply);
                  } else {
                      reply('Unable to reload scripts. Did you allow it in your `config.ini`?');
                  }
              });
              
              createCommand('join')
              .help('Move the SinusBot to your channel')
              .manual('Moves the SinusBot into your channel.')
              .checkPermission(requirePrivileges(START_STOP))
              .exec((client, args, reply, ev) => {
                  var channel = client.getChannels()[0]
                  if (!channel) {
                      return reply(ERROR_PREFIX+'I\'m unable to join your channel :(');
                  }
  
                  if (!getBotClient()) {
                      return reply(ERROR_BOT_NULL);
                  }
                  bot.moveTo(channel);
                  engine.setDefaultChannelID(channel.id())
                  successReaction(ev, reply);
              });
              
              /* // currently not working due to a bug.
              createCommand('disconnect')
              .help('Disconnect the SinusBot')
              .manual('Disconnects the SinusBot.')
              .checkPermission(requirePrivileges(START_STOP))
              .exec((client, args, reply, ev) => {
                  backend.disconnect()
              });
              // */
  
              if (engine.getBackend() == 'discord') {
                  createCommand('leave')
                  .help('Disconnect the SinusBot')
                  .manual('Disconnects the SinusBot from the current voice channel.')
                  .checkPermission(requirePrivileges(START_STOP))
                  .exec((client, args, reply, ev) => {
                      if (!getBotClient()) {
                          return reply(ERROR_BOT_NULL);
                      }
                      
                      bot.moveTo('');
                      engine.setDefaultChannelID('');
                      successReaction(ev, reply);
                  });
              }
          });
      } // END COMMANDS-ENABLED
  
      // stores last bot client object for `getBotClient()`
      let bot = backend.getBotClient();
  
      /**
       * Wrapper for `backend.getBotClient()` because it sometimes returns `null` -_-
       * @returns {Client} Bot client
       */
      function getBotClient() {
          bot = backend.getBotClient() || bot;
          return bot;
      }
  
      /********** !playing stuff for discord **********/
      if (engine.getBackend() == 'discord') {
          event.on('discord:MESSAGE_REACTION_ADD', ev => {
              let ename = ev.emoji.name;
              // remove 0xefb88f aka. "VARIATION SELECTOR-16" (no idea why discord puts that at the end)
              if (ename.endsWith('\ufe0f')) {
                  ename = ename.slice(0, -1);
              }
              const emoji = ev.emoji.id ? `${ename}:${ev.emoji.id}` : ename;
  
              // ignore reactions that are not controls
              if (![REACTION_PREV, REACTION_PLAYPAUSE, REACTION_NEXT].includes(emoji)) return;
  
              // ignore reactions from the bot itself
              if (backend.getBotClientID().endsWith(ev.user_id)) return;
  
              // get user via id
              let client = backend.getClientByID(`${ev.guild_id || backend.getBotClientID().split('/')[0]}/${ev.user_id}`);
  
              if (!client) {
                  engine.log(`playing controls: using workaround for client '${ev.user_id}'`);
                  // @ts-ignore: workaround
                  client = fakeClient(ev.guild_id || backend.getBotClientID().split('/')[0], ev.user_id, ev.channel_id);
              }
  
              // ignore if no matching user found or reaction from the bot itself
              if (!client) return engine.log(`playing controls: could not find client '${ev.user_id}'`);
  
              // ignore if reaction is from the bot itself
              if (client.isSelf()) return;
  
              let callback = () => {
                  // delete the rection
                  deleteUserReaction(ev.channel_id, ev.message_id, ev.user_id, emoji);
  
                  // check if user has the 'playback' permission
                  if (!requirePrivileges(PLAYBACK)(client)) {
                      engine.log(`${client.nick()} is missing playback permissions for reaction controls`);
                      client.chat(ERROR_PREFIX + 'You need the playback permission to use reaction controls');
                      return;
                  }
  
                  const track = media.getCurrentTrack();
  
                  switch (emoji) {
                  case REACTION_PREV:
                      // ignore if nothing is playing
                      if (!audio.isPlaying()) return;
  
                      if (media.getQueue().length !== 0) {
                          // start from beginning if we're playing queue
                          audio.seek(0);
                      } else {
                          // try prev (doesn't work for queue or folder)
                          media.playPrevious();
  
                          // fallback: start from beginning if there is no previous track
                          if (!audio.isPlaying()) {
                              if (track) track.play();
                          }
                      }
                      break;
                  case REACTION_PLAYPAUSE:
                      if (audio.isPlaying()) {
                          media.stop();
                      } else {
                          if (!track) return;
                          const pos = audio.getTrackPosition();
  
                          if (pos && pos < (track.duration() - 1000 /* milliseconds */)) {
                              // continue playing at last pos
                              audio.setMute(true);
                              track.play();
                              audio.seek(pos);
                              audio.setMute(false);
                          } else {
                              // or start from beginning if it already ended
                              track.play();
                          }
                      }
                      break;
                  case REACTION_NEXT:
                      if (audio.isPlaying()) {
                          media.playNext();
                      } else {
                          // is something in queue?
                          if (media.getQueue().length !== 0) {
                              // resume queue
                              media.playQueueNext();
                          }
                      }
                  }
              };
  
              // was reaction added to previous response?
              if (lastEmbeds.some(embed => embed.messageId == ev.message_id)) {
                  callback();
                  return;
              }
  
              getMessage(ev.channel_id, ev.message_id).then(msg => {
                  // was reaction added to previous response of the bot?
                  if (backend.getBotClientID().endsWith(msg.author.id)) {
                      callback();
                  }
              })
          });
          
          /**
          * Called when track or it's info changes
          * @param {Track} track
          */
          const onChange = track => {
              if (config.songInStatus) {
                  const prefix = '🎵 ';
                  const suffix = ' 🎵';
      
                  // set track info as status
                  backend.extended().setStatus({
                      game: {
                          name: prefix + formatTrack(track) + suffix,
                          type: 2, // => 0 (game), 1 (streaming), 2 (listening)
                      },
                      status: "online",
                      afk: false
                  });
              }
      
              // update embeds
              lastEmbeds.forEach(async embed => {
                  await editMessage(embed.channelId, embed.messageId, getPlayingEmbed()).then(() => wait(100));
              });
          };
      
          event.on('track', onChange);
          event.on('trackInfo', onChange);
          event.on('trackEnd', () => {
              if (!config.songInStatus) {
                  return;
              }
              if (getBotClient()) {
                  bot.setDescription('');
              }
          });
      }
  
      /**
       * Returns embed for current track
       */
      function getPlayingEmbed() {
          let track = media.getCurrentTrack();
  
          if (!track) {
              return {
                  content: "",
                  embed: {},
              };
          }
  
          let album = track.album();
          let duration = track.duration();
  
          let fields = [];
          fields.push({
              name: "Duration",
              value: duration ? timestamp(duration) : 'stream',
              inline: true
          });
          if (album) {
              fields.push({
                  name: "Album",
                  value: album,
                  inline: true
              });
          }
  
          return {
              content: formatTrack(track),
              embed: {
                  title: formatTrack(track),
                  url: sinusbotURL || null,
                  color: 0xe13438,
                  thumbnail: {
                      url: sinusbotURL && track.thumbnail() ? `${sinusbotURL}/cache/${track.thumbnail()}` : null
                  },
                  fields: fields,
                  footer: {
                      icon_url: "https://sinusbot.github.io/logo.png",
                      text: "SinusBot"
                  }
              },
          };
      }
  
      /**
       * Returns a fake client object from IDs. (Discord only)
       * @param {string} guild Guild ID
       * @param {string} id User ID
       * @param {string} channelid Channel ID
       */
      function fakeClient(guild, id, channelid) {
          const clid = `${guild}/${id}`
          return {
              chat: (/** @type {string} */ str) => {
                  backend.extended().createMessage(channelid, {
                      content: str
                  });
              },
              isSelf: () => false,
              id: () => clid,
              uid: () => clid,
              uniqueId: () => clid,
              uniqueID: () => clid,
              DBID: () => clid,
              databaseID: () => clid,
              databaseId: () => clid,
              type: () => 1,
              getURL: () => `<@${id}>`,
              name: () => `unknown (ID: ${id})`,
              nick: () => `unknown (ID: ${id})`,
              phoneticName: () => '',
              description: () => '',
              getServerGroups: () => [],
              getChannelGroup: () => null,
              getChannels: () => [],
              getAudioChannel: () => null,
              equals: (/** @type {Client} */ client) => {
                const uid = client.uid().split("/")
                if (uid.length === 2) {
                  return uid[2] === id
                } else {
                  return client.uid() === clid
                }
              }
          }
      }
  
      /********** helper functions **********/
  
      /**
       * Returns the first user with a given UID.
       *
       * @param {string} uid UID of the client
       * @returns {User} first user with given uid
       */
      function getUserByUid(uid) {
          return engine.getUsers().find(user => user.tsUid() === uid)
      }
  
      /**
       * Returns alls users that match the clients UID and ServerGroups.
       *
       * @param {Client} client
       * @returns {User[]} Users that match the clients UID and ServerGroups.
       */
      function getUsersByClient(client) {
          return engine.getUsers().filter(user =>
              // does the UID match?
              client.uid() == user.uid() ||
              // does a group ID match?
              client.getServerGroups().map(group => group.id()).includes(user.groupId()) ||
              // group ID '-1' matches everyone
              user.groupId() == '-1'
          );
      }
  
      /**
       * Returns a function that checks if a given user has all of the required privileges.
       * @param {...number} privileges If at least one privilege matches the returned function will return true.
       */
      function requirePrivileges(...privileges) {
          return (/** @type {Client} */ client) => {
              // check if at least one user has the required privileges
              return getUsersByClient(client).some(user => {
                  // check if at least one privilege is found
                  return privileges.some(priv => {
                      return ((user.privileges()|user.instancePrivileges()) & priv) === priv;
                  });
              });
          };
      }
  
      // /**
      //  * Returns a formatted string from a track.
      //  *
      //  * @param {Track} track
      //  * @returns {string} formatted string
      //  */
      // function formatTrackWithID(track) {
      //     return `${format.code(track.id())} ${formatTrack(track)}`;
      // }
  
      /**
       * Returns a formatted string from a track.
       *
       * @param {Track} track
       * @returns {string} formatted string
       */
      function formatTrack(track) {
          let title = track.tempTitle() || track.title();
          let artist = track.tempArtist() || track.artist();
          return artist ? `${artist} - ${title}` : title;
      }
  
      /**
       * Removes TeamSpeaks URL bb-code or Discords < > from a given string.
       *
       * @param {string} str
       * @returns {string} str without [URL] [/URL] and < >
       */
      function stripURL(str) {
          // don't handle non-strings, return as provided
          if (typeof str !== 'string') return str;
  
          // remove surrounding [URL] [/URL] tags
          let match = str.match(/\[URL\](.+)\[\/URL\]/i);
          if (match && match.length >= 2) {
              return match[1];
          }
  
          // remove surrounding < >
          match = str.match(/<(.+)>/);
          if (match && match.length >= 2) {
              return match[1];
          }
  
          // if nothing matches just return str
          return str;
      }
  
      /**
       * Returns a more human readable timestamp (hours:minutes:secods)
       * @param {number} milliseconds
       */
      function timestamp(milliseconds) {
          const SECOND = 1000; //milliseconds
          const MINUTE = 60 * SECOND;
          const HOUR = 60 * MINUTE;
  
          let seconds = Math.floor(milliseconds / SECOND);
          let minutes = Math.floor(milliseconds / MINUTE);
          let hours = Math.floor(milliseconds / HOUR);
          
          minutes = minutes % (HOUR/MINUTE);
          seconds = seconds % (MINUTE/SECOND);
  
          let str = '';
  
          if (hours !== 0) {
              str += hours + ':';
              if (minutes <= 9) {
                  str += '0';
              }
          }
          str += minutes + ':';
          if (seconds <= 9) {
              str += '0';
          }
          str += seconds;
  
          return str;
      }
  
      /**
       * @ignore
       * @typedef MessageEvent
       * @type {object}
       * @property {Client} client
       * @property {Channel} channel
       * @property {string} text
       * @property {number} mode
       * @property {DiscordMessage} [message]
       */
  
      /**
       * Gives the user feedback if a command was successfull.
       *
       * @param {MessageEvent} ev
       * @param {(msg: string) => void} reply
       */
      function successReaction(ev, reply) {
          if (!config.createSuccessReaction) {
              return;
          }
          switch (engine.getBackend()) {
              case "discord":
                  if (ev.message) ev.message.createReaction(REACTION_SUCCESS)
                  return
              case "ts3":
                  return reply(`${REACTION_SUCCESS} done!`)
          }
      }
      
      /**
       * Waits for given milliseconds.
       * @param {number} ms Time to wait for in milliseconds.
       * @return {Promise}
       */
      function wait(ms) {
          return new Promise(resolve => setTimeout(resolve, ms));
      }
  
      /**
       * Adds a reaction to a message.
       * @param {string} channelID Channel ID
       * @param {string} messageID Message ID
       * @param {string} emoji Emoji
       * @return {Promise<object>}
       * @author Jonas Bögle
       * @license MIT
       */
      function createReaction(channelID, messageID, emoji) {
          return discord('PUT', `/channels/${channelID}/messages/${messageID}/reactions/${emoji}/@me`, null, false);
      }
  
      /**
       * Removes a reaction from a message.
       * @param {string} channelID Channel ID
       * @param {string} messageID Message ID
       * @param {string} userID User ID
       * @param {string} emoji Emoji
       * @return {Promise<object>}
       * @author Jonas Bögle
       * @license MIT
       */
      function deleteUserReaction(channelID, messageID, userID, emoji) {
          return discord('DELETE', `/channels/${channelID}/messages/${messageID}/reactions/${emoji}/${userID}`, null, false);
      }
  
      /**
       * Gets a message.
       * @param {string} channelID Channel ID
       * @param {string} messageID Message ID
       * @return {Promise<object>}
       * @author Jonas Bögle
       * @license MIT
       */
      function getMessage(channelID, messageID) {
          return discord('GET', `/channels/${channelID}/messages/${messageID}`, null, true);
      }
  
      /**
       * Edits a message.
       * @param {string} channelID Channel ID
       * @param {string} messageID Message ID
       * @param {object} message New message
       * @return {Promise<object>}
       * @author Jonas Bögle
       * @license MIT
       */
      function editMessage(channelID, messageID, message) {
          return discord('PATCH', `/channels/${channelID}/messages/${messageID}`, message, true);
      }
  
      /**
       * Deletes a message.
       * @param {string} channelID Channel ID
       * @param {string} messageID Message ID
       * @return {Promise<object>}
       * @author Jonas Bögle
       * @license MIT
       */
      function deleteMessage(channelID, messageID) {
          return discord('DELETE', `/channels/${channelID}/messages/${messageID}`, null, false);
      }
  
      /**
       * Deletes multiple messages.
       * @param {string} channelID Channel ID
       * @param {string[]} messageIDs Message IDs
       * @return {Promise<object>}
       * @author Jonas Bögle
       * @license MIT
       */
      function deleteMessages(channelID, messageIDs) {
          switch (messageIDs.length) {
              case 0: return Promise.resolve();
              case 1: return deleteMessage(channelID, messageIDs[0]);
              default: return discord('POST', `/channels/${channelID}/messages/bulk-delete`, {messages: messageIDs}, false);
          }
      }
  
      /**
       * Executes a discord API call
       * @param {string} method http method
       * @param {string} path path
       * @param {object} [data] json data
       * @param {boolean} [repsonse] `true` if you're expecting a json response, `false` otherwise
       * @return {Promise<object>}
       * @author Jonas Bögle
       * @license MIT
       */
      function discord(method, path, data=null, repsonse=true) {
          return new Promise((resolve, reject) => {
              backend.extended().rawCommand(method, path, data, (err, data) => {
                  if (err) return reject(err);
                  if (repsonse) {
                      let res;
                      try {
                          res = JSON.parse(data);
                      } catch (err) {
                          engine.log(`${method} ${path} failed`)
                          engine.log(`${data}`)
                          return reject(err);
                      }
                      
                      if (res === undefined) {
                          engine.log(`${method} ${path} failed`)
                          engine.log(`${data}`)
                          return reject('Invalid Response');
                      }
  
                      return resolve(res);
                  }
                  resolve();
              });
          });
      }
  })
  