/** * @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 '); 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 .') .checkPermission(client => getUserByUid(client.uid())) .exec((client, args, reply, ev) => { // print syntax if no value given if (!args.value) { reply(USAGE_PREFIX + 'password \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 '); 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 '); 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 ') .manual('starts playing back the playlist .') .checkPermission(requirePrivileges(PLAYBACK)) .exec((client, args, reply, ev) => { // print syntax if no playlistname given if (!args.playlistname) { reply(USAGE_PREFIX + 'playlist '); 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 '); 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 '); 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 ; 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 '); 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 '); 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 '); 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 '); 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 '); return; } audio.setTTSDefaultLocale(args.locale); successReaction(ev, reply); }); createCommand('yt') .addArgument(args => args.string.setName('url')) .help('Play via youtube-dl') .manual('Plays 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 '); 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 via youtube-dl') .manual('Streams 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 '); 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 via youtube-dl') .manual('Plays 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 '); 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 via youtube-dl') .manual('Enqueues 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 '); 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 via youtube-dl') .manual('Enqueues 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 '); 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 '); } }); createCommand('prefix') .addArgument(args => args.string.setName('prefix')) .help('Change command prefix') .manual('Changes the prefix for all core commands to , 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 '); 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} * @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} * @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} * @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} * @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} * @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} * @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} * @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(); }); }); } })