diff --git a/.github/contributing.md b/.github/contributing.md new file mode 100644 index 000000000..064c70d55 --- /dev/null +++ b/.github/contributing.md @@ -0,0 +1,9 @@ +# Branches +The `master` branch is reserved for the current latest release. We will not accept Pull Requests made to it. + +For developement, work from the `v5` branch and then Pull Request against it. + +# Formatting +We use Unix-style end of line character: Line-Feed(`\n` aka `\x0A`) + +We use 4 spaces for indents diff --git a/.vscode/settings.json b/.vscode/settings.json index fd97dc62f..0750e5ed7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { "editor.defaultFormatter": "dbaeumer.vscode-eslint", "[javascript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "files.eol": "\n" }, "editor.formatOnSave": true } \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js index 5068f75c1..5f8439e86 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -13,6 +13,8 @@ Registered Tasks: scss - Deletes compiled CSS, then recompiles SCSS + inline-source - Generates a new index.html containing AngularJS modules + lint - Runs eslint secrets:encrypt - Encrypts an updated secrets.json to secrets.gpg @@ -24,7 +26,9 @@ Registered Tasks: compile - Creates an installer/tarball from the platform's pack - build - Runs cleanup, scss, pack, and compile + fullpack - Runs cleanup, scss, inline-source, pack + + build - Runs cleanup, scss, inline-source, pack, and compile */ module.exports = function(grunt) { @@ -58,5 +62,6 @@ module.exports = function(grunt) { require('./grunt/secrets.js')(grunt); require('./grunt/include-source')(grunt); + grunt.registerTask('fullpack', ['scss', 'include-source', 'pack']); grunt.registerTask('build', ['scss', 'include-source', 'pack', 'compile']); }; \ No newline at end of file diff --git a/backend/app-management/electron/events/second-instance.js b/backend/app-management/electron/events/second-instance.js index ad172c86d..f15f68636 100644 --- a/backend/app-management/electron/events/second-instance.js +++ b/backend/app-management/electron/events/second-instance.js @@ -11,6 +11,9 @@ exports.secondInstance = (_, argv) => { try { const { mainWindow } = require("../window-management"); if (mainWindow) { + if (!mainWindow.isVisible()) { + mainWindow.show(); + } if (mainWindow.isMinimized()) { mainWindow.restore(); } diff --git a/backend/app-management/electron/events/when-ready.js b/backend/app-management/electron/events/when-ready.js index 4d5aa7294..b05d3e9f8 100644 --- a/backend/app-management/electron/events/when-ready.js +++ b/backend/app-management/electron/events/when-ready.js @@ -125,6 +125,9 @@ exports.whenReady = async () => { const setupImporter = require("../../../import/setups/setup-importer"); setupImporter.setupListeners(); + const slcbImporter = require("../../../import/third-party/streamlabs-chatbot"); + slcbImporter.setupListeners(); + const { setupCommonListeners } = require("../../../common/common-listeners"); setupCommonListeners(); diff --git a/backend/app-management/electron/tray-creation.js b/backend/app-management/electron/tray-creation.js new file mode 100644 index 000000000..24b9674f6 --- /dev/null +++ b/backend/app-management/electron/tray-creation.js @@ -0,0 +1,102 @@ +'use strict'; + +const path = require("path"); + +const electron = require("electron"); +const { Menu, Tray, app } = electron; + +const frontendCommunicator = require('../../common/frontend-communicator.js'); +const { settings } = require("../../common/settings-access"); + +let mainTray; +let minimizedToTray = false; + +module.exports = function createTray(mainWindow) { + if (mainTray != null) { + return; + } + + const trayMenu = Menu.buildFromTemplate([ + { + label: 'Show', + type: 'normal', + click: () => { + if (minimizedToTray) { + mainWindow.show(); + minimizedToTray = false; + } else { + mainWindow.focus(); + } + } + }, + { + type: 'separator' + }, + { + label: 'Exit', + type: 'normal', + click: () => { + app.quit(); + } + } + ]); + mainTray = new Tray(path.join(__dirname, "../../../gui/images/logo_transparent_2.png")); + mainTray.setToolTip('Firebot'); + mainTray.setContextMenu(trayMenu); + + mainTray.on('double-click', () => { + if (minimizedToTray) { + mainWindow.show(); + minimizedToTray = false; + } else { + mainWindow.focus(); + } + }); + + mainWindow.on('minimize', () => { + if (settings.getMinimizeToTray() && minimizedToTray !== true) { + mainWindow.hide(); + minimizedToTray = true; + } + }); + + mainWindow.on('restore', () => { + if (minimizedToTray) { + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + minimizedToTray = false; + } + + }); + mainWindow.on('show', () => { + if (minimizedToTray) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + minimizedToTray = false; + } + }); + mainWindow.on('focus', () => { + if (minimizedToTray) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + minimizedToTray = false; + } + }); + + frontendCommunicator.on('settings-updated-renderer', (evt) => { + if ( + evt.path === '/settings/minimizeToTray' && + evt.data !== true && + minimizedToTray === true + ) { + mainWindow.show(); + minimizedToTray = false; + } + }); +}; \ No newline at end of file diff --git a/backend/app-management/electron/window-management.js b/backend/app-management/electron/window-management.js index 0054427ce..a36b42a55 100644 --- a/backend/app-management/electron/window-management.js +++ b/backend/app-management/electron/window-management.js @@ -6,6 +6,7 @@ const path = require("path"); const url = require("url"); const windowStateKeeper = require("electron-window-state"); const fileOpenHelpers = require("../file-open-helpers"); +const createTray = require('./tray-creation.js'); const logger = require("../../logwrapper"); /** @@ -208,6 +209,9 @@ function createMainWindow() { // wait for the main window's content to load, then show it mainWindow.webContents.on("did-finish-load", () => { + + createTray(mainWindow); + mainWindow.show(); if (splashscreenWindow) { splashscreenWindow.destroy(); diff --git a/backend/chat/chat-helpers.js b/backend/chat/chat-helpers.js index 4aec46635..68bb2b775 100644 --- a/backend/chat/chat-helpers.js +++ b/backend/chat/chat-helpers.js @@ -204,7 +204,7 @@ function parseMessageParts(firebotChatMessage, parts) { if (!firebotChatMessage.whisper && !firebotChatMessage.tagged && streamer.loggedIn && - p.text.includes(streamer.username)) { + (p.text.includes(streamer.username) || p.text.includes(streamer.displayName))) { firebotChatMessage.tagged = true; } } diff --git a/backend/common/settings-access.js b/backend/common/settings-access.js index a2d230bd3..b89e4a867 100644 --- a/backend/common/settings-access.js +++ b/backend/common/settings-access.js @@ -243,4 +243,12 @@ settings.getSidebarControlledServices = function() { : ["chat"]; }; +settings.getMinimizeToTray = function () { + let minimizeToTray = getDataFromFile('/settings/minimizeToTray'); + return minimizeToTray === true; +}; +settings.setMinimizeToTray = function (minimizeToTray) { + pushDataToFile('/settings/minimizeToTray', minimizeToTray === true); +}; + exports.settings = settings; diff --git a/backend/database/currencyDatabase.js b/backend/database/currencyDatabase.js index 45f1588ef..14b737381 100644 --- a/backend/database/currencyDatabase.js +++ b/backend/database/currencyDatabase.js @@ -493,7 +493,7 @@ frontendCommunicator.on("give-currency", async (/** @type {CurrencyInfo} */ { break; case "individual": await adjustCurrencyForUser(username, currencyId, amount); - messageTarget = `@${username}}`; + messageTarget = `@${username}`; break; } @@ -503,7 +503,7 @@ frontendCommunicator.on("give-currency", async (/** @type {CurrencyInfo} */ { return; } - twitchChat.sendChatMessage(`${amount < 0 ? "Removed" : "Gave"} ${util.commafy(amount)} ${currency.name} to ${messageTarget}!`); + twitchChat.sendChatMessage(`${amount < 0 ? "Removed" : "Gave"} ${util.commafy(amount)} ${currency.name} ${amount < 0 ? "from" : "to"} ${messageTarget}!`); } }); @@ -563,4 +563,4 @@ exports.addCurrencyToUserGroupOnlineUsers = addCurrencyToUserGroupOnlineUsers; exports.isViewerDBOn = isViewerDBOn; exports.getTopCurrencyHolders = getTopCurrencyHolders; exports.getTopCurrencyPosition = getTopCurrencyPosition; -exports.adjustCurrencyForAllUsers = adjustCurrencyForAllUsers; \ No newline at end of file +exports.adjustCurrencyForAllUsers = adjustCurrencyForAllUsers; diff --git a/backend/database/userDatabase.js b/backend/database/userDatabase.js index 007c44222..1befa7a1d 100644 --- a/backend/database/userDatabase.js +++ b/backend/database/userDatabase.js @@ -233,6 +233,26 @@ function getAllUsernames() { }); } +function getAllUsernamesWithIds() { + return new Promise(resolve => { + if (!isViewerDBOn()) { + return resolve([]); + } + + const projectionObj = { + displayName: 1 + }; + + db.find({ twitch: true }).projection(projectionObj).exec(function (err, docs) { + if (err) { + logger.error("Error getting all users: ", err); + return resolve([]); + } + return resolve(docs != null ? docs.map(u => ({ id: u._id, username: u.displayName })) : []); + }); + }); +} + function getTopViewTimeUsers(count) { return new Promise(resolve => { if (!isViewerDBOn()) { @@ -895,6 +915,7 @@ exports.getTwitchUserByUsername = getTwitchUserByUsername; exports.incrementDbField = incrementDbField; exports.getUserDb = getUserDb; exports.removeUser = removeUser; +exports.createNewUser = createNewUser; exports.updateUser = updateUser; exports.setChatUsersOnline = setChatUsersOnline; exports.getTopViewTimeUsers = getTopViewTimeUsers; @@ -903,5 +924,6 @@ exports.getOnlineUsers = getOnlineUsers; exports.updateUserMetadata = updateUserMetadata; exports.getUserMetadata = getUserMetadata; exports.getAllUsernames = getAllUsernames; +exports.getAllUsernamesWithIds = getAllUsernamesWithIds; exports.getTopMetadata = getTopMetadata; exports.getTopMetadataPosition = getTopMetadataPosition; \ No newline at end of file diff --git a/backend/effects/builtin-effect-loader.js b/backend/effects/builtin-effect-loader.js index 69fdd26c4..1d8a395bc 100644 --- a/backend/effects/builtin-effect-loader.js +++ b/backend/effects/builtin-effect-loader.js @@ -6,6 +6,7 @@ exports.loadEffects = () => { [ 'active-user-lists', 'ad-break', + 'add-quote', 'api', 'celebration', 'chat-feed-alert', diff --git a/backend/effects/builtin/add-quote.js b/backend/effects/builtin/add-quote.js new file mode 100644 index 000000000..3036fada4 --- /dev/null +++ b/backend/effects/builtin/add-quote.js @@ -0,0 +1,71 @@ +"use strict"; + +const twitchChannels = require("../../twitch-api/resource/channels"); +const quotesManager = require("../../quotes/quotes-manager"); +const { EffectCategory } = require('../../../shared/effect-constants'); +const moment = require("moment"); + +const addQuoteEffect = { + definition: { + id: "firebot:add-quote", + name: "Add Quote", + description: "Adds a quote to the quote database.", + icon: "fad fa-quote-right", + categories: [EffectCategory.FUN], + dependencies: [] + }, + globalSettings: {}, + optionsTemplate: ` + +

This is the name of the person who is creating the quote entry.

+ +
+ + +

This is the name of the person who actually said the quote.

+ +
+ + +

This is the actual quote text.

+ +
+ `, + optionsController: () => {}, + optionsValidator: effect => { + let errors = []; + if (effect.creator == null || effect.creator === "") { + errors.push("Please provide a quote creator."); + } + + if (effect.originator == null || effect.originator === "") { + errors.push("Please provide a quote originator."); + } + + if (effect.text == null || effect.text === "") { + errors.push("Please provide a value for quote text."); + } + return errors; + }, + onTriggerEvent: async event => { + const { effect } = event; + + const channelData = await twitchChannels.getChannelInformation(); + + const currentGameName = channelData && channelData.gameName ? channelData.gameName : "Unknown game"; + + const newQuote = { + text: effect.text, + originator: effect.originator.replace(/@/g, ""), + creator: effect.creator.replace(/@/g, ""), + game: currentGameName, + createdAt: moment().toISOString() + } + + quotesManager.addQuote(newQuote); + + return true; + } +} + +module.exports = addQuoteEffect; \ No newline at end of file diff --git a/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js b/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js index 7702dd412..c8eff83d8 100644 --- a/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js +++ b/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js @@ -1,10 +1,6 @@ "use strict"; -const firebotRolesManager = require("../../../../../roles/firebot-roles-manager"); -const customRolesManager = require("../../../../../roles/custom-roles-manager"); -const teamRolesManager = require("../../../../../roles/team-roles-manager"); -const twitchRolesManager = require("../../../../../../shared/twitch-roles"); -const chatRolesManager = require("../../../../../roles/chat-roles-manager"); +const { viewerHasRoles } = require("../../../../../roles/role-helpers"); module.exports = { id: "firebot:viewerroles", @@ -46,20 +42,7 @@ module.exports = { username = trigger.metadata.username; } - const userTwitchRoles = (await chatRolesManager.getUsersChatRoles(username)) - .map(twitchRolesManager.mapTwitchRole); - const userFirebotRoles = firebotRolesManager.getAllFirebotRolesForViewer(username); - const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(username); - const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(username); - - const allRoles = [ - ...userTwitchRoles, - ...userFirebotRoles, - ...userCustomRoles, - ...userTeamRoles - ]; - - const hasRole = allRoles.some(r => r.id === rightSideValue); + const hasRole = await viewerHasRoles(username, [rightSideValue]); switch (comparisonType) { case "include": diff --git a/backend/effects/builtin/cooldown-command.js b/backend/effects/builtin/cooldown-command.js index 6932da723..6c5e50ddb 100644 --- a/backend/effects/builtin/cooldown-command.js +++ b/backend/effects/builtin/cooldown-command.js @@ -13,15 +13,33 @@ const model = { }, globalSettings: {}, optionsTemplate: ` - - + +
+ + +
+ + {{$select.selected.trigger}}
-
+ + {{$select.selected.name}} + +
+
+
+ +
-
- + +
+ -
+
-
+
Secs
-
+
-
+
Username
-
Tip: Use $user to apply the cooldown to the associated user
-
+
Tip: Use $user to apply the cooldown to the associated user
+
Secs
@@ -84,18 +103,18 @@ const model = {
-
+
-
+
-
+
Username @@ -104,15 +123,21 @@ const model = {
`, - optionsController: ($scope, commandsService) => { + optionsController: ($scope, commandsService, sortTagsService) => { $scope.commands = commandsService.getCustomCommands(); + $scope.sortTags = sortTagsService.getSortTags('commands'); $scope.subcommands = []; - $scope.subcommandOptions = {}; + if ($scope.effect.selectionType == null) { + if ($scope.effect.commandId != null && $scope.effect.sortTagId == null) { + $scope.effect.selectionType = 'command'; + } + } + $scope.createSubcommandOptions = () => { - let options = {}; + const options = {}; if ($scope.subcommands) { $scope.subcommands.forEach(sc => { options[sc.id] = sc.regex || sc.fallback ? (sc.usage || "").split(" ")[0] : sc.arg; @@ -143,9 +168,9 @@ const model = { $scope.getSubcommands(); }, optionsValidator: effect => { - let errors = []; - if (effect.commandId == null) { - errors.push("Please select a command"); + const errors = []; + if (effect.commandId == null && effect.sortTagId == null) { + errors.push("Please select a command or tag"); } if (effect.userCooldownSecs != null && (effect.username == null || effect.username === '')) { errors.push("Please provide a username for the user cooldown"); @@ -155,15 +180,29 @@ const model = { } return errors; }, - onTriggerEvent: event => { - return new Promise(resolve => { - let { effect } = event; + onTriggerEvent: async event => { + const { effect } = event; + const commandIds = []; - const commandHandler = require("../../chat/commands/commandHandler"); + if (effect.commandId == null && effect.sortTagId == null) { + return false; + } + if (effect.commandId != null && (effect.selectionType == null || effect.selectionType === "command")) { + commandIds.push(effect.commandId); + } + + if (effect.sortTagId != null && effect.selectionType === "sortTag") { + const commandManager = require("../../chat/commands/CommandManager"); + const commands = commandManager.getAllCustomCommands().filter(c => c.sortTags.includes(effect.sortTagId)); + commands.forEach(c => commandIds.push(c.id)); + } + + const commandHandler = require("../../chat/commands/commandHandler"); + commandIds.forEach(id => { if (effect.action === "Add") { commandHandler.manuallyCooldownCommand({ - commandId: effect.commandId, + commandId: id, subcommandId: effect.subcommandId, username: effect.username, cooldowns: { @@ -173,7 +212,7 @@ const model = { }); } else if (effect.action === "Clear") { commandHandler.manuallyClearCooldownCommand({ - commandId: effect.commandId, + commandId: id, subcommandId: effect.subcommandId, username: effect.clearUsername, cooldowns: { @@ -182,9 +221,9 @@ const model = { } }); } - - resolve(true); }); + + return true; } }; diff --git a/backend/effects/builtin/custom-variable.js b/backend/effects/builtin/custom-variable.js index 2acd9d844..d38e38bde 100644 --- a/backend/effects/builtin/custom-variable.js +++ b/backend/effects/builtin/custom-variable.js @@ -15,7 +15,7 @@ const fileWriter = { globalSettings: {}, optionsTemplate: ` -

You'll use this name to reference this elsewhere via the $customVariable replace phrase.

+

You'll use this name to reference this elsewhere via $customVariable[name].

diff --git a/backend/effects/preset-lists/preset-effect-list-manager.js b/backend/effects/preset-lists/preset-effect-list-manager.js index 7494208e3..0b9db68dd 100644 --- a/backend/effects/preset-lists/preset-effect-list-manager.js +++ b/backend/effects/preset-lists/preset-effect-list-manager.js @@ -11,7 +11,7 @@ const JsonDbManager = require("../../database/json-db-manager"); * @prop {object} effects - the saved effects in the list * @prop {string} effects.id - the effect list root id * @prop {any[]} effects.list - the array of effects objects - * @prop {string[]} sortTags - the sort tags for the effect list + * @prop {string[]} sortTags - the tags for the effect list */ /** diff --git a/backend/effects/queues/effect-queue-manager.js b/backend/effects/queues/effect-queue-manager.js index e09d1de5e..34effee40 100644 --- a/backend/effects/queues/effect-queue-manager.js +++ b/backend/effects/queues/effect-queue-manager.js @@ -10,7 +10,7 @@ const effectQueueRunner = require("./effect-queue-runner"); * @prop {string} name - the name of the effect queue * @prop {string} mode - the mode of the effect queue * @prop {number} [interval] - the interval set for the interval mode - * @prop {string[]} sortTags - the sort tags for the effect queue + * @prop {string[]} sortTags - the tags for the effect queue */ /** diff --git a/backend/events/events-access.js b/backend/events/events-access.js index 725c934c5..14602371a 100644 --- a/backend/events/events-access.js +++ b/backend/events/events-access.js @@ -60,9 +60,9 @@ function saveSortTags() { let eventsDb = getEventsDb(); try { eventsDb.push("/sortTags", sortTags); - logger.debug(`Saved event sort tags.`); + logger.debug(`Saved event tags.`); } catch (err) { - logger.warn(`Unable to save event sort tags.`, err); + logger.warn(`Unable to save event tags.`, err); } } diff --git a/backend/events/filters/builtin/message.js b/backend/events/filters/builtin/message.js index 56cef5e39..9a51a3662 100644 --- a/backend/events/filters/builtin/message.js +++ b/backend/events/filters/builtin/message.js @@ -18,6 +18,8 @@ module.exports = { ComparisonType.DOESNT_STARTS_WITH, ComparisonType.ENDS_WITH, ComparisonType.DOESNT_END_WITH, + ComparisonType.MATCHES_REGEX_CS, + ComparisonType.DOESNT_MATCH_REGEX_CS, ComparisonType.MATCHES_REGEX, ComparisonType.DOESNT_MATCH_REGEX ], @@ -57,6 +59,14 @@ module.exports = { let regex = new RegExp(value, "gi"); return !regex.test(chatMessage); } + case ComparisonType.MATCHES_REGEX_CS: { + let regex = new RegExp(value, "g"); + return regex.test(chatMessage); + } + case ComparisonType.DOESNT_MATCH_REGEX_CS: { + let regex = new RegExp(value, "g"); + return !regex.test(chatMessage); + } default: return false; } diff --git a/backend/import/third-party/streamlabs-chatbot.js b/backend/import/third-party/streamlabs-chatbot.js new file mode 100644 index 000000000..21538c2d2 --- /dev/null +++ b/backend/import/third-party/streamlabs-chatbot.js @@ -0,0 +1,118 @@ +"use strict"; + +const twitchApi = require("../../twitch-api/api"); +const chatRolesManager = require("../../roles/chat-roles-manager"); +const frontendCommunicator = require("../../common/frontend-communicator"); +const logger = require("../../logwrapper"); +const userDb = require("../../database/userDatabase"); + +const addViewersFromTwitch = async (viewers) => { + let twitchViewers = []; + + const nameGroups = []; + while (viewers.length > 0) { + nameGroups.push(viewers.splice(0, 100)); + } + + for (const group of nameGroups) { + const client = twitchApi.getClient(); + + try { + const names = group.map(v => v.name); + const response = await client.users.getUsersByNames(names); + + if (response) { + twitchViewers = [ + ...twitchViewers, + ...response + ]; + } + } catch (err) { + logger.error("Failed to get users", { location: "/import/third-party/streamlabs.chatbot.js:35", err: err }); + + if (err._statusCode === 400) { + for (const viewer of group) { + try { + const response = await client.users.getUserByName(viewer.name); + + if (response) { + twitchViewers.push(response); + } + } catch (err) { + logger.error("Failed to get user", { location: "/import/third-party/streamlabs.chatbot.js:46", err: err }); + } + } + } + } + } + + const newViewers = []; + for (const viewer of twitchViewers) { + const roles = chatRolesManager.getUsersChatRoles(viewer.id); + + const newViewer = await userDb.createNewUser( + viewer.id, + viewer.name, + viewer.displayName, + viewer.profilePictureUrl, + roles + ); + + if (newViewer) { + newViewers.push(newViewer); + } else { + logger.error("Failed to create new user", { location: "/import/third-party/streamlabs.chatbot.js:68" }); + } + } + + return newViewers || []; +}; + +const importViewers = async (data) => { + logger.debug(`Attempting to import viewers...`); + + const { viewers, settings } = data; + + const newViewers = []; + let viewersToUpdate = []; + + for (const v of viewers) { + const viewer = await userDb.getUserByUsername(v.name); + + if (viewer == null) { + newViewers.push(v); + continue; + } + + viewersToUpdate.push(viewer); + } + + const createdViewers = await addViewersFromTwitch(newViewers); + + if (createdViewers.length) { + viewersToUpdate = [ + ...viewersToUpdate, + ...createdViewers + ]; + } + + for (const viewer of viewersToUpdate) { + const viewerToUpdate = viewer; + const importedViewer = viewers.find(v => v.name.toLowerCase() === viewer.username.toLowerCase()); + + if (settings.includeViewHours) { + viewerToUpdate.minutesInChannel += importedViewer.viewHours * 60; + } + + await userDb.updateUser(viewerToUpdate); + } + + logger.debug(`Finished importing viewers`); + return true; +}; + +const setupListeners = () => { + frontendCommunicator.onAsync("importSlcbViewers", async data => importViewers(data)); +}; + +exports.setupListeners = setupListeners; \ No newline at end of file diff --git a/backend/integrations/builtin/discord/discord-embed-builder.js b/backend/integrations/builtin/discord/discord-embed-builder.js index 66bae9264..78e370815 100644 --- a/backend/integrations/builtin/discord/discord-embed-builder.js +++ b/backend/integrations/builtin/discord/discord-embed-builder.js @@ -7,6 +7,7 @@ const accountAccess = require("../../../common/account-access"); function buildCustomEmbed(customEmbedData) { const customEmbed = { title: customEmbedData.title, + url: customEmbedData.url, description: customEmbedData.description }; @@ -124,10 +125,13 @@ async function buildEmbed(embedType, customEmbedData) { switch (embedType) { case "channel": { let channelEmbed = await buildChannelEmbed(); - channelEmbed.allowed_mentions = { //eslint-disable-line camelcase - parse: ["users", "roles", "everyone"] - }; - return channelEmbed; + if (channelEmbed) { + channelEmbed.allowed_mentions = { //eslint-disable-line camelcase + parse: ["users", "roles", "everyone"] + }; + return channelEmbed; + } + return null; } case "custom": { return buildCustomEmbed(customEmbedData); diff --git a/backend/integrations/builtin/discord/send-discord-message-effect.js b/backend/integrations/builtin/discord/send-discord-message-effect.js index fc8c7a0b4..8ce612c5e 100644 --- a/backend/integrations/builtin/discord/send-discord-message-effect.js +++ b/backend/integrations/builtin/discord/send-discord-message-effect.js @@ -55,6 +55,10 @@ module.exports = {
+
+ +
+
@@ -68,6 +72,10 @@ module.exports = {
+ +
+
* Must be live for this to post. +
diff --git a/backend/roles/role-helpers.js b/backend/roles/role-helpers.js new file mode 100644 index 000000000..88e202673 --- /dev/null +++ b/backend/roles/role-helpers.js @@ -0,0 +1,74 @@ +"use strict"; + +const firebotRolesManager = require("./firebot-roles-manager"); +const customRolesManager = require("./custom-roles-manager"); +const teamRolesManager = require("./team-roles-manager"); +const twitchRolesManager = require("../../shared/twitch-roles"); +const chatRolesManager = require("./chat-roles-manager"); + +/** + * + * @param {string} username + * @returns {Promise>} + */ +async function getAllRolesForViewer(username) { + const userTwitchRoles = (await chatRolesManager.getUsersChatRoles(username)) + .map(twitchRolesManager.mapTwitchRole); + const userFirebotRoles = firebotRolesManager.getAllFirebotRolesForViewer(username); + const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(username); + const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(username); + + return [ + ...userTwitchRoles, + ...userFirebotRoles, + ...userCustomRoles, + ...userTeamRoles + ]; +} + +/** + * Check if user has the given role its id + * @param {string} username + * @param {string} expectedRoleName + */ +async function viewerHasRole(username, expectedRoleId) { + const viewerRoles = await getAllRolesForViewer(username); + return viewerRoles.some(r => r.id === expectedRoleId); +} + +/** + * Check if user has the given role by name + * @param {string} username + * @param {string} roleName + */ +async function viewerHasRoleByName(username, expectedRoleName) { + const viewerRoles = await getAllRolesForViewer(username); + return viewerRoles.some(r => r.name === expectedRoleName); +} + + +/** + * Check if user has the given roles by their ids + * @param {string} username + * @param {string[]} expectedRoleIds + */ +async function viewerHasRoles(username, expectedRoleIds) { + const viewerRoles = await getAllRolesForViewer(username); + return expectedRoleIds.every(n => viewerRoles.some(r => r.id === n)); +} + +/** + * Check if user has the given roles by their names + * @param {string} username + * @param {string[]} expectedRoleNames + */ +async function viewerHasRolesByName(username, expectedRoleNames) { + const viewerRoles = await getAllRolesForViewer(username); + return expectedRoleNames.every(n => viewerRoles.some(r => r.name === n)); +} + +exports.getAllRolesForViewer = getAllRolesForViewer; +exports.viewerHasRoles = viewerHasRoles; +exports.viewerHasRolesByName = viewerHasRolesByName; +exports.viewerHasRole = viewerHasRole; +exports.viewerHasRoleByName = viewerHasRoleByName; \ No newline at end of file diff --git a/backend/timers/timer-manager.js b/backend/timers/timer-manager.js index fc02b20c9..fb7d4307d 100644 --- a/backend/timers/timer-manager.js +++ b/backend/timers/timer-manager.js @@ -20,7 +20,7 @@ const JsonDbManager = require("../database/json-db-manager"); * @prop {object} effects - the saved effects in the timer * @prop {string} effects.id - the effect list root id * @prop {any[]} effects.list - the array of effects objects - * @prop {string[]} sortTags - the sort tags for the timer + * @prop {string[]} sortTags - the tags for the timer */ /** diff --git a/backend/variables/builtin-variable-loader.js b/backend/variables/builtin-variable-loader.js index 5b9ead9f9..0522e9547 100644 --- a/backend/variables/builtin-variable-loader.js +++ b/backend/variables/builtin-variable-loader.js @@ -12,6 +12,7 @@ exports.loadReplaceVariables = () => { 'array-add', 'array-filter', 'array-find', + 'array-find-index', 'array-join', 'array-length', 'array-remove', @@ -38,6 +39,7 @@ exports.loadReplaceVariables = () => { 'current-viewer-count', 'custom-role-user-count', 'custom-variable', + 'custom-variable-keys', 'custom-variable-created-data', 'custom-variable-created-name', 'custom-variable-expired-data', @@ -50,6 +52,7 @@ exports.loadReplaceVariables = () => { 'ensure-number', 'eval-vars', 'file-line-count', + 'follow-age', 'follow-count', 'game', 'gift-count', @@ -59,9 +62,10 @@ exports.loadReplaceVariables = () => { 'gift-receivers', 'gift-sub-months', 'gift-sub-type', + 'has-role', + 'has-roles', 'host-type', 'host-viewer-count', - 'if', 'loop-count', 'loop-item', 'math', @@ -75,6 +79,7 @@ exports.loadReplaceVariables = () => { 'ordinal-indicator', 'preset-list-arg', 'profile-page-bytebin-token', + 'quick-store', 'quote', 'raid-viewer-count', 'random-active-viewer', @@ -95,6 +100,16 @@ exports.loadReplaceVariables = () => { 'reward-name', 'roll-dice', 'run-effect', + 'spoofed/all', + 'spoofed/and', + 'spoofed/any', + 'spoofed/if', + 'spoofed/nall', + 'spoofed/nand', + 'spoofed/nany', + 'spoofed/nor', + 'spoofed/not', + 'spoofed/or', 'stream-title', 'streamer', 'sub-count', diff --git a/backend/variables/builtin/arg.js b/backend/variables/builtin/arg.js index 42cd95d08..7db75960f 100644 --- a/backend/variables/builtin/arg.js +++ b/backend/variables/builtin/arg.js @@ -7,10 +7,6 @@ const { OutputDataType, VariableCategory } = require("../../../shared/variable-c const expressionish = require('expressionish'); -let triggers = {}; -triggers[EffectTrigger.COMMAND] = true; -triggers[EffectTrigger.MANUAL] = true; - const model = { definition: { handle: "arg", @@ -30,7 +26,10 @@ const model = { description: "Grab all args. This is a good way to grab all text after the !command trigger." } ], - triggers: triggers, + triggers: { + [EffectTrigger.COMMAND]: true, + [EffectTrigger.MANUAL]: true + }, categories: [VariableCategory.COMMON], possibleDataOutput: [OutputDataType.NUMBER, OutputDataType.TEXT] }, diff --git a/backend/variables/builtin/array-find-index.js b/backend/variables/builtin/array-find-index.js new file mode 100644 index 000000000..95c8f4221 --- /dev/null +++ b/backend/variables/builtin/array-find-index.js @@ -0,0 +1,84 @@ +// Migration: done + +'use strict'; + +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); + +function getPropertyAtPath(obj, propertyPath) { + let data = obj; + const pathNodes = propertyPath.split("."); + for (let i = 0; i < pathNodes.length; i++) { + if (data == null) { + break; + } + let node = pathNodes[i]; + // parse to int for array access + if (!isNaN(node)) { + node = parseInt(node); + } + data = data[node]; + } + return data; +} + +const model = { + definition: { + handle: "arrayFindIndex", + usage: "arrayFindIndex[jsonArray, matcher, propertyPath]", + description: "Finds a matching element in the array and returns it's index, or null if the element is absent", + examples: [ + { + usage: 'arrayFindIndex["[\\"a\\",\\"b\\",\\"c\\"]", b]', + description: 'Returns 1 , the index of "b"' + }, + { + usage: 'arrayFindIndex["[{\\"username\\": \\"alastor\\"},{\\"username\\": \\"ebiggz\\"}]", alastor, username]', + description: 'Returns 0, the index of the object where "username"="alastor"' + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.TEXT, OutputDataType.NUMBER] + }, + evaluator: (_, jsonArray, matcher, propertyPath = null) => { + if (jsonArray != null) { + if (matcher === undefined || matcher === "") { + return null; + } + + try { + matcher = JSON.parse(matcher); + } catch (err) { + return null; + } + + if (propertyPath === 'null' || propertyPath === "") { + propertyPath = null; + } + + try { + const array = JSON.parse(jsonArray); + if (Array.isArray(array)) { + let found; + + // propertyPath arg not specified + if (propertyPath == null || propertyPath === "") { + found = array.findIndex(v => v === matcher); + + // property path specified + } else { + found = array.findIndex(v => { + const property = getPropertyAtPath(v, propertyPath); + return property === matcher; + }); + } + return JSON.stringify(found != -1 ? found : null); + } + } catch (error) { + return null; + } + } + return null; + } +}; + +module.exports = model; \ No newline at end of file diff --git a/backend/variables/builtin/custom-variable-keys.js b/backend/variables/builtin/custom-variable-keys.js new file mode 100644 index 000000000..d6bfb8c03 --- /dev/null +++ b/backend/variables/builtin/custom-variable-keys.js @@ -0,0 +1,41 @@ +"use strict"; + +const customVariableManager = require("../../common/custom-variable-manager"); + +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); + +function isObject(data) { + return typeof data === 'object' && !(data instanceof String); +} + +const model = { + definition: { + handle: "customVariableKeys", + usage: "customVariableKeys[name]", + examples: [ + { + usage: "customVariableKeys[name, 1]", + description: "Get the array of keys for an object which is an array item by providing an array index as a second argument." + }, + { + usage: "customVariableKeys[name, property]", + description: "Get the array of keys for an object property by providing a property path (using dot notation) as a second argument." + } + ], + description: "Get the array of keys for an object saved in the custom variable.", + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.TEXT] + }, + evaluator: (_, name, propertyPath) => { + const data = customVariableManager.getCustomVariable(name, propertyPath); + if (data == null || !isObject(data)) { + return "[]"; // same as JSON.stringify([]); + } + + let keys = Object.keys(data); + return JSON.stringify(keys); + } +}; + + +module.exports = model; diff --git a/backend/variables/builtin/follow-age.js b/backend/variables/builtin/follow-age.js new file mode 100644 index 000000000..d51cd699a --- /dev/null +++ b/backend/variables/builtin/follow-age.js @@ -0,0 +1,51 @@ +"use strict"; + +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); + +const api = require("../../twitch-api/api"); +const moment = require("moment"); + +const model = { + definition: { + handle: "followAge", + usage: "followAge[username]", + description: "The time a given viewer has been following the channel, in days by default.", + examples: [ + { + usage: "followAge[$user]", + description: "Gets how long the associated user (i.e. who triggered command, pressed button, etc) has been following the channel (in days)." + }, + { + usage: "followAge[$target]", + description: "Gets how long the target user has been following the channel (in days)." + }, + { + usage: "followAge[username, unitOfTime]", + description: "Gets how long the specified username has been following the channel in a specific unit of time (in years, months, days, hours, or minutes)." + } + ], + categories: [VariableCategory.NUMBERS, VariableCategory.USER], + possibleDataOutput: [OutputDataType.NUMBER] + }, + evaluator: async (trigger, username, unitOfTime = "days") => { + username = username == null ? trigger.metadata.username : username; + if (username == null) { + return 0; + } + + try { + const followDate = await api.users.getFollowDateForUser(username); + if (followDate == null) { + return 0; + } + + const followDateMoment = moment(followDate); + return moment().diff(followDateMoment, unitOfTime); + + } catch { + return 0; + } + } +}; + +module.exports = model; \ No newline at end of file diff --git a/backend/variables/builtin/has-role.js b/backend/variables/builtin/has-role.js new file mode 100644 index 000000000..ef9643bde --- /dev/null +++ b/backend/variables/builtin/has-role.js @@ -0,0 +1,36 @@ +"use strict"; + +const { EffectTrigger } = require("../../../shared/effect-constants"); +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); + +const { viewerHasRoleByName } = require('../../roles/role-helpers'); + +let triggers = {}; +triggers[EffectTrigger.COMMAND] = true; +triggers[EffectTrigger.EVENT] = true; +triggers[EffectTrigger.MANUAL] = true; +triggers[EffectTrigger.CUSTOM_SCRIPT] = true; +triggers[EffectTrigger.PRESET_LIST] = true; +triggers[EffectTrigger.CHANNEL_REWARD] = true; + +module.exports = { + definition: { + handle: "hasRole", + usage: "hasRole[user, role]", + description: "Returns true if the user has the specified role. Only valid within $if", + triggers: triggers, + categories: [VariableCategory.COMMON, VariableCategory.USER], + possibleDataOutput: [OutputDataType.ALL] + }, + evaluator: async (trigger, username, role) => { + if (username == null || username === '') { + return false; + } + + if (role == null || role === '') { + return false; + } + + return viewerHasRoleByName(username, [role]); + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/has-roles.js b/backend/variables/builtin/has-roles.js new file mode 100644 index 000000000..b9e6371c8 --- /dev/null +++ b/backend/variables/builtin/has-roles.js @@ -0,0 +1,62 @@ +"use strict"; + +const { EffectTrigger } = require("../../../shared/effect-constants"); +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); +const { getAllRolesForViewer } = require('../../roles/role-helpers'); + +let triggers = {}; +triggers[EffectTrigger.COMMAND] = true; +triggers[EffectTrigger.EVENT] = true; +triggers[EffectTrigger.MANUAL] = true; +triggers[EffectTrigger.CUSTOM_SCRIPT] = true; +triggers[EffectTrigger.PRESET_LIST] = true; +triggers[EffectTrigger.CHANNEL_REWARD] = true; + +module.exports = { + definition: { + handle: "hasRoles", + usage: "hasRoles[user, any|all, role, rol2, ...]", + description: "Returns true if the user has the specified role. Only valid within $if", + examples: [ + { + usage: 'hasRole[$user, any, mod, vip]', + description: "returns true if $user is a mod OR VIP" + }, + { + usage: 'if[$user, all, mod, vip]', + description: "Returns true if $user is a mod AND a VIP" + } + ], + triggers: triggers, + categories: [VariableCategory.COMMON, VariableCategory.USER], + possibleDataOutput: [OutputDataType.ALL] + }, + evaluator: async (trigger, username, respective, ...roles) => { + if (username == null || username === '') { + return false; + } + + if (respective == null || respective === "") { + return false; + } + + if (roles == null || roles.length === 0) { + return false; + } + + respective = (respective + '').toLowerCase(); + if (respective !== 'any' && respective !== 'all') { + return false; + } + + const userRoles = await getAllRolesForViewer(); + + // any + if (respective === 'any') { + return userRoles.some(r => roles.includes(r.name)); + } + + // all + return roles.length === userRoles.filter(r => roles.includes(r.name)).length; + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/quick-store.js b/backend/variables/builtin/quick-store.js new file mode 100644 index 000000000..b47572e96 --- /dev/null +++ b/backend/variables/builtin/quick-store.js @@ -0,0 +1,58 @@ +// Migration: done + +"use strict"; + +const { OutputDataType } = require("../../../shared/variable-constants"); + +const model = { + definition: { + handle: "quickStore", + usage: "quickStore[key]", + description: "Retrieves or stores a value until the expression has finished evaluation", + examples: [ + { + usage: 'quickStore[name, value]', + description: 'Stores "value" under the key "name"' + }, { + usage: 'quickStore[name]', + description: 'Retrieves the value of what was stored under the key of "name"' + + } + ], + possibleDataOutput: [OutputDataType.ALL] + }, + evaluator: (meta, key, value) => { + if ( + arguments.length < 2 || + typeof key !== 'string' || + key === '' + ) { + return ''; + } + + if (meta.quickstore == null) { + meta.quickstore = Object.create(null); + } + const quickstore = meta.quickstore; + + // Retrieve value + if (arguments.length < 3) { + if (quickstore[key]) { + return quickstore[key]; + } + return ''; + } + + // unset value + if (value == null || value === '') { + delete quickstore[key]; + return ''; + } + + // set value + quickstore[key] = value; + return value; + } +}; + +module.exports = model; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/all.js b/backend/variables/builtin/spoofed/all.js new file mode 100644 index 000000000..8eca6d454 --- /dev/null +++ b/backend/variables/builtin/spoofed/all.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $ALL logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "ALL", + usage: "ALL[condition, condition, ...]", + description: 'Returns true if all of the conditions are true. Only works within $if[]', + examples: [ + { + usage: 'ALL[a === a, b === b]', + description: "Returns true as a equals a and b equals b" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/and.js b/backend/variables/builtin/spoofed/and.js new file mode 100644 index 000000000..c094df7cc --- /dev/null +++ b/backend/variables/builtin/spoofed/and.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $AND logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "AND", + usage: "AND[condition, condition, ...]", + description: 'Returns true if all of the conditions are true. Only works within $if[]', + examples: [ + { + usage: 'AND[a === a, b === b]', + description: "Returns true as a equals a and b equals b" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/any.js b/backend/variables/builtin/spoofed/any.js new file mode 100644 index 000000000..8edfea4ec --- /dev/null +++ b/backend/variables/builtin/spoofed/any.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $ANY logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "ANY", + usage: "ANY[condition, condition, ...]", + description: 'Returns true if any of the conditions are true. Only works within $if[]', + examples: [ + { + usage: 'ANY[a === b, c === c]', + description: "Returns true as c equals c" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/if.js b/backend/variables/builtin/spoofed/if.js similarity index 88% rename from backend/variables/builtin/if.js rename to backend/variables/builtin/spoofed/if.js index 642a67d85..9805eb736 100644 --- a/backend/variables/builtin/if.js +++ b/backend/variables/builtin/spoofed/if.js @@ -2,7 +2,7 @@ // Dummy variable - $if logic gets handled by the evaluator -const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); module.exports = { definition: { diff --git a/backend/variables/builtin/spoofed/nall.js b/backend/variables/builtin/spoofed/nall.js new file mode 100644 index 000000000..51e4359a1 --- /dev/null +++ b/backend/variables/builtin/spoofed/nall.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $NALL logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "NALL", + usage: "NALL[condition, condition, ...]", + description: 'Returns true if any of the conditions return false', + examples: [ + { + usage: 'NALL[a === a, b === c]', + description: "Returns true as b does not equals c" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/nand.js b/backend/variables/builtin/spoofed/nand.js new file mode 100644 index 000000000..c14438367 --- /dev/null +++ b/backend/variables/builtin/spoofed/nand.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $NAND logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "NAND", + usage: "NAND[condition, condition, ...]", + description: 'Returns true if any of the conditions return false', + examples: [ + { + usage: 'NAND[a === a, b === c]', + description: "Returns true as b does not equals c" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/nany.js b/backend/variables/builtin/spoofed/nany.js new file mode 100644 index 000000000..7557fee74 --- /dev/null +++ b/backend/variables/builtin/spoofed/nany.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $NANY logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "NANY", + usage: "NANY[condition, condition, ...]", + description: 'Returns true if all of the conditions return false', + examples: [ + { + usage: 'NANY[a === b, b === c]', + description: "Returns true as a does not equal be and b does not equals c" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/nor.js b/backend/variables/builtin/spoofed/nor.js new file mode 100644 index 000000000..e21108e13 --- /dev/null +++ b/backend/variables/builtin/spoofed/nor.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $NOR logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "NOR", + usage: "NOR[condition, condition, ...]", + description: 'Returns true if all of the conditions return false', + examples: [ + { + usage: 'NOR[a === b, b === c]', + description: "Returns true as a does not equal be and b does not equals c" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/not.js b/backend/variables/builtin/spoofed/not.js new file mode 100644 index 000000000..721649466 --- /dev/null +++ b/backend/variables/builtin/spoofed/not.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $NOT logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "NOT", + usage: "NOT[condition]", + description: 'Returns the opposite of the condition\'s result. Only works within $if[]', + examples: [ + { + usage: 'NOT[1 === 1]', + description: "Returns false as the condition is true" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/spoofed/or.js b/backend/variables/builtin/spoofed/or.js new file mode 100644 index 000000000..4bab58e5f --- /dev/null +++ b/backend/variables/builtin/spoofed/or.js @@ -0,0 +1,21 @@ +"use strict"; + +// Dummy variable - $OR logic gets handled by the evaluator + +const { OutputDataType, VariableCategory } = require("../../../../shared/variable-constants"); + +module.exports = { + definition: { + handle: "OR", + usage: "OR[condition, condition, ...]", + description: 'Returns true if any of the conditions are true. Only works within $if[]', + examples: [ + { + usage: 'OR[a === b, c === c]', + description: "Returns true as c equals c" + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + } +}; \ No newline at end of file diff --git a/backend/variables/builtin/top-metadata.js b/backend/variables/builtin/top-metadata.js index e7a2856cf..c09c7c190 100644 --- a/backend/variables/builtin/top-metadata.js +++ b/backend/variables/builtin/top-metadata.js @@ -26,6 +26,8 @@ const model = { } else if (count < 1) { // min of 1 count = 1; + } else if (typeof count !== 'number') { + count = parseInt(count, 10); } let topMetadataUsers = await userDatabase.getTopMetadata(metadataKey, count); diff --git a/gui/app/controllers/counters.controller.js b/gui/app/controllers/counters.controller.js index 86eeb0d31..82abf5d74 100644 --- a/gui/app/controllers/counters.controller.js +++ b/gui/app/controllers/counters.controller.js @@ -1,13 +1,75 @@ "use strict"; (function() { - const uuidv1 = require("uuid/v1"); - angular .module("firebotApp") .controller("countersController", function($scope, countersService, utilityService) { $scope.countersService = countersService; + $scope.onCountersUpdated = (items) => { + countersService.saveAllCounters(items); + }; + + $scope.headers = [ + { + name: "NAME", + icon: "fa-user", + cellTemplate: `{{data.name}}` + }, + { + name: "VALUE", + icon: "fa-tally", + cellTemplate: `{{data.value}}` + }, + { + name: "MIMINUM", + icon: "fa-arrow-to-bottom", + cellTemplate: `{{data.minimum ? data.minimum : 'n/a'}}` + }, + { + name: "MAXIMUM", + icon: "fa-arrow-to-top", + cellTemplate: `{{data.maximum ? data.maximum : 'n/a'}}` + } + ]; + + $scope.counterOptions = (item) => { + const options = [ + { + html: ` Edit`, + click: () => { + countersService.showAddEditCounterModal(item); + } + }, + { + html: ` Duplicate`, + click: () => { + countersService.duplicateCounter(item.id); + } + }, + { + html: ` Delete`, + click: () => { + utilityService + .showConfirmationModal({ + title: "Delete Counter", + question: `Are you sure you want to delete the Counter "${item.name}"?`, + confirmLabel: "Delete", + confirmBtnType: "btn-danger" + }) + .then(confirmed => { + if (confirmed) { + countersService.deleteCounter(item.id); + } + }); + + } + } + ]; + + return options; + }; + $scope.openRenameCounterModal = function(counter) { utilityService.openGetInputModal( { @@ -32,75 +94,5 @@ countersService.renameCounter(counter.id, newName); }); }; - - - $scope.openCreateCounterModal = function() { - utilityService.openGetInputModal( - { - model: "", - label: "Create Counter", - inputPlaceholder: "Enter counter name", - saveText: "Create", - validationFn: (value) => { - return new Promise(resolve => { - if (value == null || value.trim().length < 1) { - resolve(false); - } else if (countersService.counterNameExists(value)) { - resolve(false); - } else { - resolve(true); - } - }); - }, - validationText: "Counter name cannot be empty and must be unique." - - }, - (name) => { - const counter = { - id: uuidv1(), - name: name, - value: 0, - saveToTxtFile: false - }; - countersService.saveCounter(counter); - }); - }; - - $scope.openEditCounterModal = function(counter) { - utilityService.showModal({ - component: "editCounterModal", - windowClass: "no-padding-modal", - resolveObj: { - counter: () => counter - }, - closeCallback: resp => { - const { action, counter } = resp; - - switch (action) { - case "update": - countersService.saveCounter(counter); - break; - case "delete": - countersService.deleteCounter(counter.id); - break; - } - } - }); - }; - - $scope.openDeleteCounterModal = (counter) => { - utilityService - .showConfirmationModal({ - title: "Delete", - question: `Are you sure you want to delete the Counter "${counter.name}"?`, - confirmLabel: "Delete", - confirmBtnType: "btn-danger" - }) - .then(confirmed => { - if (confirmed) { - countersService.deleteCounter(counter.id); - } - }); - }; }); }()); diff --git a/gui/app/controllers/events.controller.js b/gui/app/controllers/events.controller.js index a81293eb9..7ad168de8 100644 --- a/gui/app/controllers/events.controller.js +++ b/gui/app/controllers/events.controller.js @@ -424,6 +424,50 @@ return options; }; + $scope.eventSetMenuOptions = function(group) { + + return [ + { + html: ` Rename`, + click: () => { + $scope.showRenameEventGroupModal(group); + } + }, + { + html: ` ${group.active ? 'Deactivate' : 'Activate'}`, + click: () => { + eventsService.toggleEventGroupActiveStatus(group.id); + } + }, + { + html: ` Duplicate set`, + click: () => { + eventsService.duplicateEventGroup(group); + } + }, + { + html: ` Delete set`, + click: () => { + $scope.showDeleteGroupModal(group); + } + }, + { + html: ` Copy events`, + click: () => { + $scope.copyEvents(group.id); + }, + hasTopDivider: true + }, + { + html: ` Paste event(s)`, + click: () => { + $scope.pasteEvents(group.id); + }, + enabled: $scope.hasCopiedEvents() + } + ]; + }; + $scope.simulateEventsByType = function() { utilityService.showModal({ diff --git a/gui/app/controllers/viewers.controller.js b/gui/app/controllers/viewers.controller.js index fe04df84e..859bfbd38 100644 --- a/gui/app/controllers/viewers.controller.js +++ b/gui/app/controllers/viewers.controller.js @@ -4,7 +4,7 @@ angular .module("firebotApp") - .controller("viewersController", function($scope, viewersService, currencyService, + .controller("viewersController", function($route, $scope, viewersService, currencyService, utilityService, settingsService) { $scope.viewerTablePageSize = settingsService.getViewerListPageSize(); @@ -24,6 +24,15 @@ }); }; + $scope.showImportViewersModal = () => { + utilityService.showModal({ + component: "importViewersModal", + closeCallback: () => { + $route.reload(); + } + }); + }; + $scope.viewerRowClicked = (data) => { $scope.showUserDetailsModal(data._id); }; diff --git a/gui/app/directives/chat/quick-actions/quick-actions.js b/gui/app/directives/chat/quick-actions/quick-actions.js index c26c5713a..77aae5f83 100644 --- a/gui/app/directives/chat/quick-actions/quick-actions.js +++ b/gui/app/directives/chat/quick-actions/quick-actions.js @@ -71,23 +71,23 @@ }; $ctrl.$onInit = async () => { - if ($ctrl.settings == null) { - $ctrl.settings = {}; - } - if (quickActionsService.quickActions == null || !quickActionsService.quickActions.length) { await quickActionsService.loadQuickActions(); } - let position = 0; - quickActionsService.quickActions.forEach(qa => { - $ctrl.settings[qa.id] = { - enabled: true, - position: position++ - }; - }); + if ($ctrl.settings == null) { + $ctrl.settings = {}; - settingsService.setQuickActionSettings($ctrl.settings); + let position = 0; + quickActionsService.quickActions.forEach(qa => { + $ctrl.settings[qa.id] = { + enabled: true, + position: position++ + }; + }); + + settingsService.setQuickActionSettings($ctrl.settings); + } }; $ctrl.customQuickActionsContextMenu = (customQuickAction) => { diff --git a/gui/app/directives/controls/effectList.js b/gui/app/directives/controls/effectList.js index 9b7893f29..f213a8555 100644 --- a/gui/app/directives/controls/effectList.js +++ b/gui/app/directives/controls/effectList.js @@ -20,28 +20,29 @@ template: `
-
-

EFFECTS

- +
+

EFFECTS

+
-
-
+
+
EFFECTS DURATION
- {{$ctrl.effectsData.queueDuration || 0}}s + {{$ctrl.effectsData.queueDuration || 0}}s
-
+
QUEUE PRIORITY @@ -52,16 +53,16 @@
- -
+
- + {{$index + 1}}. {{$ctrl.getEffectNameById(effect.type)}} ({{effect.effectLabel}}) - +
-
- + @@ -300,6 +210,143 @@ ctrl.effectsUpdate(); } + const createAllEffectsMenuOptions = () => { + ctrl.allEffectsMenuOptions = [ + { + html: ` Copy all effects`, + click: () => { + ctrl.copyEffects(); + }, + enabled: ctrl.effectsData.list.length > 0 + }, + { + html: ` Paste effects`, + click: function () { + ctrl.pasteEffects(true); + }, + enabled: ctrl.hasCopiedEffects() + }, + { + html: ` Delete all effects`, + click: function () { + ctrl.removeAllEffects(); + }, + enabled: ctrl.effectsData.list.length > 0 + }, + { + html: ` Share effects`, + click: function () { + ctrl.shareEffects(); + }, + enabled: ctrl.effectsData.list.length > 0, + hasTopDivider: true + }, + { + html: ` Import shared effect`, + click: function () { + ctrl.importSharedEffects(); + } + } + ]; + }; + + const createEffectMenuOptions = () => { + ctrl.effectMenuOptions = [ + { + html: ` Edit Label`, + click: function ($itemScope) { + const $index = $itemScope.$index; + ctrl.editLabelForEffectAtIndex($index); + } + }, + { + html: ` Edit Effect`, + click: function ($itemScope) { + const $index = $itemScope.$index; + const effect = $itemScope.effect; + ctrl.openEditEffectModal(effect, $index, ctrl.trigger, false); + } + }, + { + html: ` Toggle Enabled`, + click: function ($itemScope) { + const $index = $itemScope.$index; + ctrl.toggleEffectActiveState($index); + } + }, + { + html: ` Duplicate`, + click: function ($itemScope) { + const $index = $itemScope.$index; + ctrl.duplicateEffectAtIndex($index); + } + }, + { + html: ` Copy`, + click: function ($itemScope) { + const $index = $itemScope.$index; + ctrl.copyEffectAtIndex($index); + } + }, + { + html: ` Delete`, + click: function ($itemScope) { + const $index = $itemScope.$index; + ctrl.removeEffectAtIndex($index); + } + }, + { + text: "Paste...", + hasTopDivider: true, + enabled: ctrl.hasCopiedEffects(), + children: [ + { + html: ` Before`, + click: function ($itemScope) { + const $index = $itemScope.$index; + if (ctrl.hasCopiedEffects()) { + ctrl.pasteEffectsAtIndex($index, true); + } + } + }, + { + html: ` After`, + click: function ($itemScope) { + const $index = $itemScope.$index; + if (ctrl.hasCopiedEffects()) { + ctrl.pasteEffectsAtIndex($index, false); + } + } + } + ] + }, + { + text: "Add new...", + children: [ + { + html: ` Before`, + click: function ($itemScope) { + const $index = $itemScope.$index; + ctrl.openNewEffectModal($index - 1); + } + }, + { + html: ` After`, + click: function ($itemScope) { + const $index = $itemScope.$index; + ctrl.openNewEffectModal($index); + } + } + ] + } + ]; + }; + + const rebuildEffectMenus = () => { + createEffectMenuOptions(); + createAllEffectsMenuOptions(); + }; + ctrl.shareEffects = async () => { let shareCode = await backendCommunicator.fireEventAsync("getEffectsShareCode", ctrl.effectsData.list); if (shareCode == null) { @@ -378,10 +425,12 @@ ctrl.$onChanges = function() { createEffectsData(); + rebuildEffectMenus(); }; ctrl.effectsUpdate = function() { ctrl.update({ effects: ctrl.effectsData }); + rebuildEffectMenus(); }; ctrl.effectTypeChanged = function(effectType, index) { @@ -475,13 +524,16 @@ ctrl.copyEffectAtIndex = function(index) { objectCopyHelper.copyEffects([ctrl.effectsData.list[index]]); + createEffectMenuOptions(); + rebuildEffectMenus(); }; ctrl.copyEffects = function() { objectCopyHelper.copyEffects(ctrl.effectsData.list); + rebuildEffectMenus(); }; - ctrl.openNewEffectModal = function() { + ctrl.openNewEffectModal = index => { utilityService.showModal({ component: "addNewEffectModal", backdrop: true, @@ -503,88 +555,29 @@ active: true }; - ctrl.openEditEffectModal(newEffect, null, ctrl.trigger); + if (index == null) { + ctrl.openEditEffectModal(newEffect, null, ctrl.trigger, true); + return; + } + + ctrl.openEditEffectModal(newEffect, index, ctrl.trigger, true); } }); }; - ctrl.openEditEffectModal = function(effect, index, trigger) { + ctrl.openEditEffectModal = (effect, index, trigger, isNew) => { utilityService.showEditEffectModal(effect, index, trigger, response => { if (response.action === "add") { - ctrl.effectsData.list.push(response.effect); + ctrl.effectsData.list.splice(index + 1, 0, response.effect); } else if (response.action === "update") { ctrl.effectsData.list[response.index] = response.effect; } else if (response.action === "delete") { ctrl.removeEffectAtIndex(response.index); } ctrl.effectsUpdate(); - }, ctrl.triggerMeta); + }, ctrl.triggerMeta, isNew); }; - ctrl.effectMenuOptions = [ - { - html: ` Edit Label`, - click: function ($itemScope) { - const $index = $itemScope.$index; - ctrl.editLabelForEffectAtIndex($index); - } - }, - { - html: ` Edit`, - click: function ($itemScope) { - const $index = $itemScope.$index; - const effect = $itemScope.effect; - ctrl.openEditEffectModal(effect, $index, ctrl.trigger); - } - }, - { - html: ` Toggle Enabled`, - click: function ($itemScope) { - const $index = $itemScope.$index; - ctrl.toggleEffectActiveState($index); - } - }, - { - html: ` Duplicate`, - click: function ($itemScope) { - const $index = $itemScope.$index; - ctrl.duplicateEffectAtIndex($index); - } - }, - { - html: ` Copy`, - click: function ($itemScope) { - const $index = $itemScope.$index; - ctrl.copyEffectAtIndex($index); - } - }, - { - html: ` Paste Before`, - click: function ($itemScope) { - const $index = $itemScope.$index; - if (ctrl.hasCopiedEffects()) { - ctrl.pasteEffectsAtIndex($index, true); - } - } - }, - { - html: ` Paste After`, - click: function ($itemScope) { - const $index = $itemScope.$index; - if (ctrl.hasCopiedEffects()) { - ctrl.pasteEffectsAtIndex($index, false); - } - } - }, - { - html: ` Delete`, - click: function ($itemScope) { - const $index = $itemScope.$index; - ctrl.removeEffectAtIndex($index); - } - } - ]; - //effect queue ctrl.eqs = effectQueuesService; diff --git a/gui/app/directives/controls/sort-tag-dropdown.component.js b/gui/app/directives/controls/sort-tag-dropdown.component.js index e31fff8cb..b4bbe362f 100644 --- a/gui/app/directives/controls/sort-tag-dropdown.component.js +++ b/gui/app/directives/controls/sort-tag-dropdown.component.js @@ -46,7 +46,7 @@ class="dropdown-header" ng-show="sts.getSortTags($ctrl.context).length > 0" > - Sort Tags + Tags
  • - Edit sort tags + Edit tags
  • diff --git a/gui/app/directives/controls/sort-tag-list.js b/gui/app/directives/controls/sort-tag-list.js index f4fd83d21..3e47cb70e 100644 --- a/gui/app/directives/controls/sort-tag-list.js +++ b/gui/app/directives/controls/sort-tag-list.js @@ -11,11 +11,11 @@
    {{$ctrl.getTagName(tagId)}} - +
    -
    +
    @@ -44,7 +44,7 @@ utilityService.openSelectModal( { - label: "Add Sort Tag", + label: "Add Tag", options: remainingTags, saveText: "Add", validationText: "Please select a tag." diff --git a/gui/app/directives/controls/sort-tags-row.js b/gui/app/directives/controls/sort-tags-row.js index f325cbc1e..d930a7e0b 100644 --- a/gui/app/directives/controls/sort-tags-row.js +++ b/gui/app/directives/controls/sort-tags-row.js @@ -12,14 +12,14 @@
    {{tag.name}} - + +
    +
    + + +
    +

    Effects On Update

    +

    These effects are triggered every time the Counter value is updated by the Update Counter effect{{$ctrl.counter.minimum != null || $ctrl.counter.maximum != null ? ', except when the value hits the maximum or minimum' : ''}}.

    + +
    + +
    +

    Effects On Minimum

    +

    These effects are triggered when the minimum value is hit.

    + +
    + +
    +

    Effects On Maximum

    +

    These effects are triggered when the maximum value is hit.

    + +
    +
    + + + + `, + bindings: { + resolve: "<", + close: "&", + dismiss: "&" + }, + controller: function($rootScope, ngToast, countersService, utilityService) { + const $ctrl = this; + + $ctrl.txtFilePath = ""; + $ctrl.isNewCounter = true; + $ctrl.counter = { + name: "", + value: 0, + saveToTxtFile: false, + sortTags: [] + }; + + $ctrl.copyTxtFilePath = () => { + $rootScope.copyTextToClipboard($ctrl.txtFilePath); + + ngToast.create({ + className: 'success', + content: 'Counter txt file path copied!' + }); + }; + + $ctrl.save = () => { + if ($ctrl.counter.name == null || $ctrl.counter.name === "") { + ngToast.create("Please provide a name for this Counter"); + return; + } + + countersService.saveCounter($ctrl.counter).then(successful => { + if (successful) { + $ctrl.close({ + $value: { + counter: $ctrl.counter + } + }); + } else { + ngToast.create("Failed to save counter. Please try again or view logs for details."); + } + }); + }; + + $ctrl.editMinimum = () => { + utilityService.openGetInputModal( + { + model: $ctrl.counter.minimum, + inputType: "number", + label: "Set Minimum", + saveText: "Save", + descriptionText: "Set the minimum value this counter can be (optional).", + inputPlaceholder: "Enter number", + validationFn: async (value) => { + if (value != null) { + if ($ctrl.counter.maximum != null && value >= $ctrl.counter.maximum) { + return false; + } + } + + return true; + }, + validationText: `Minimum cannot be greater than or equal to the maximum (${$ctrl.counter.maximum}).` + }, + (editedValue) => { + $ctrl.counter.minimum = editedValue; + if (editedValue != null && $ctrl.counter.value < $ctrl.counter.minimum) { + $ctrl.counter.value = $ctrl.counter.minimum; + } + } + ); + }; + + $ctrl.editMaximum = () => { + utilityService.openGetInputModal( + { + model: $ctrl.counter.maximum, + inputType: "number", + label: "Set Maximum", + saveText: "Save", + descriptionText: "Set the maximum value this counter can be (optional).", + inputPlaceholder: "Enter number", + validationFn: async (value) => { + if (value != null) { + if ($ctrl.counter.minimum != null && value <= $ctrl.counter.minimum) { + return false; + } + } + + return true; + }, + validationText: `Maximum cannot be less than or equal to the minimum (${$ctrl.counter.minimum})` + }, + (editedValue) => { + $ctrl.counter.maximum = editedValue; + if (editedValue != null && $ctrl.counter.value > $ctrl.counter.maximum) { + $ctrl.counter.value = $ctrl.counter.maximum; + } + } + ); + }; + + $ctrl.editCurrentValue = () => { + utilityService.openGetInputModal( + { + model: $ctrl.counter.value, + inputType: "number", + label: "Set Current Value", + saveText: "Save", + descriptionText: "Update the current value for this counter.", + inputPlaceholder: "Enter number", + validationFn: async (value) => { + if (value == null) { + return { + success: false, + reason: `Counter value cannot be empty.` + }; + } + if ($ctrl.counter.minimum != null && value < $ctrl.counter.minimum) { + return { + success: false, + reason: `Counter value cannot be less than the minimum (${$ctrl.counter.minimum}).` + }; + } else if ($ctrl.counter.maximum != null && value > $ctrl.counter.maximum) { + return { + success: false, + reason: `Counter value cannot be greater than the maximum (${$ctrl.counter.maximum}).` + }; + } + return true; + } + }, + (editedValue) => { + $ctrl.counter.value = editedValue; + } + ); + }; + + $ctrl.updateEffectsListUpdated = (effects) => { + $ctrl.counter.updateEffects = effects; + }; + $ctrl.maximumEffectsListUpdated = (effects) => { + $ctrl.counter.maximumEffects = effects; + }; + $ctrl.minimumEffectsListUpdated = (effects) => { + $ctrl.counter.minimumEffects = effects; + }; + + $ctrl.$onInit = () => { + if ($ctrl.resolve.counter) { + $ctrl.counter = JSON.parse( + angular.toJson($ctrl.resolve.counter) + ); + + if ($ctrl.counter.sortTags == null) { + $ctrl.counter.sortTags = []; + } + + $ctrl.isNewCounter = false; + } + + if ($ctrl.isNewCounter && $ctrl.counter.id == null) { + $ctrl.counter.id = uuidv1(); + } + + $ctrl.txtFilePath = countersService.getTxtFilePath($ctrl.counter.name); + }; + } + }); +}()); diff --git a/gui/app/directives/modals/counters/editCounter/editCounterModal.html b/gui/app/directives/modals/counters/editCounter/editCounterModal.html deleted file mode 100644 index c1c6891d0..000000000 --- a/gui/app/directives/modals/counters/editCounter/editCounterModal.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - \ No newline at end of file diff --git a/gui/app/directives/modals/counters/editCounter/editCounterModal.js b/gui/app/directives/modals/counters/editCounter/editCounterModal.js deleted file mode 100644 index ad29802a3..000000000 --- a/gui/app/directives/modals/counters/editCounter/editCounterModal.js +++ /dev/null @@ -1,196 +0,0 @@ -"use strict"; - -(function() { - angular.module("firebotApp").component("editCounterModal", { - templateUrl: "./directives/modals/counters/editCounter/editCounterModal.html", - bindings: { - resolve: "<", - close: "&", - dismiss: "&", - modalInstance: "<" - }, - controller: function($rootScope, $scope, ngToast, countersService, utilityService) { - let $ctrl = this; - - $ctrl.txtFilePath = ""; - - $ctrl.counter = null; - - $ctrl.copyTxtFilePath = function() { - $rootScope.copyTextToClipboard($ctrl.txtFilePath); - - ngToast.create({ - className: 'success', - content: 'Counter txt file path copied!' - }); - }; - - $ctrl.delete = function() { - $ctrl.close({ - $value: { - action: "delete", - counter: $ctrl.counter - } - }); - }; - - $ctrl.save = function() { - $ctrl.close({ - $value: { - action: "update", - counter: $ctrl.counter - } - }); - }; - - $ctrl.valueIsNull = (value) => value === undefined || value === null; - - $ctrl.editMinimum = () => { - utilityService.openGetInputModal( - { - model: $ctrl.counter.minimum, - inputType: "number", - label: "Set Minimum", - saveText: "Save", - descriptionText: "Set the minimum value this counter can be (optional).", - inputPlaceholder: "Enter number", - validationFn: (value) => { - return new Promise(resolve => { - if (!$ctrl.valueIsNull(value)) { - if (!$ctrl.valueIsNull($ctrl.counter.maximum) && value >= $ctrl.counter.maximum) { - return resolve({ - success: false, - reason: `Minimum cannot be greater than or equal to the maximum (${$ctrl.counter.maximum}).` - }); - } - } - resolve(true); - }); - } - }, - (editedValue) => { - $ctrl.counter.minimum = editedValue; - if (!$ctrl.valueIsNull(editedValue) && $ctrl.counter.value < $ctrl.counter.minimum) { - $ctrl.counter.value = $ctrl.counter.minimum; - } - } - ); - }; - - $ctrl.editMaximum = () => { - utilityService.openGetInputModal( - { - model: $ctrl.counter.maximum, - inputType: "number", - label: "Set Maximum", - saveText: "Save", - descriptionText: "Set the maximum value this counter can be (optional).", - inputPlaceholder: "Enter number", - validationFn: (value) => { - return new Promise(resolve => { - if (!$ctrl.valueIsNull(value)) { - if (!$ctrl.valueIsNull($ctrl.counter.minimum) && value <= $ctrl.counter.minimum) { - return resolve({ - success: false, - reason: `Maximum cannot be less than or equal to the minimum (${$ctrl.counter.minimum}).` - }); - } - } - - resolve(true); - }); - } - }, - (editedValue) => { - $ctrl.counter.maximum = editedValue; - if (!$ctrl.valueIsNull(editedValue) && $ctrl.counter.value > $ctrl.counter.maximum) { - $ctrl.counter.value = $ctrl.counter.maximum; - } - } - ); - }; - - $ctrl.editCurrentValue = () => { - utilityService.openGetInputModal( - { - model: $ctrl.counter.value, - inputType: "number", - label: "Set Current Value", - saveText: "Save", - descriptionText: "Update the current value for this counter.", - inputPlaceholder: "Enter number", - validationFn: (value) => { - return new Promise(resolve => { - if (value == null) { - return resolve({ - success: false, - reason: `Counter value cannot be empty.` - }); - } - if (!$ctrl.valueIsNull($ctrl.counter.minimum) && value < $ctrl.counter.minimum) { - return resolve({ - success: false, - reason: `Counter value cannot be less than the minimum (${$ctrl.counter.minimum}).` - }); - } else if (!$ctrl.valueIsNull($ctrl.counter.maximum) && value > $ctrl.counter.maximum) { - return resolve({ - success: false, - reason: `Counter value cannot be greater than the maximum (${$ctrl.counter.maximum}).` - }); - } - resolve(true); - }); - } - }, - (editedValue) => { - $ctrl.counter.value = editedValue; - } - ); - }; - - $ctrl.updateEffectsListUpdated = function(effects) { - $ctrl.counter.updateEffects = effects; - }; - $ctrl.maximumEffectsListUpdated = function(effects) { - $ctrl.counter.maximumEffects = effects; - }; - $ctrl.minimumEffectsListUpdated = function(effects) { - $ctrl.counter.minimumEffects = effects; - }; - - $ctrl.$onInit = function() { - if ($ctrl.resolve.counter == null) { - ngToast.create({ - className: 'danger', - content: 'Unable to edit counter!' - }); - $ctrl.dismiss(); - return; - } - - // doing the json stuff is a realatively simple way to deep copy a command object. - $ctrl.counter = JSON.parse(JSON.stringify($ctrl.resolve.counter)); - - $ctrl.txtFilePath = countersService.getTxtFilePath($ctrl.counter.name); - - let modalId = $ctrl.resolve.modalId; - $ctrl.modalId = modalId; - utilityService.addSlidingModal( - $ctrl.modalInstance.rendered.then(() => { - let modalElement = $("." + modalId).children(); - return { - element: modalElement, - name: "Edit Counter", - id: modalId, - instance: $ctrl.modalInstance - }; - }) - ); - - $scope.$on("modal.closing", function() { - utilityService.removeSlidingModal(); - }); - }; - } - }); -}()); diff --git a/gui/app/directives/modals/misc/aboutModal.js b/gui/app/directives/modals/misc/aboutModal.js index 5c0575b32..e7f680fbe 100644 --- a/gui/app/directives/modals/misc/aboutModal.js +++ b/gui/app/directives/modals/misc/aboutModal.js @@ -12,7 +12,7 @@

    {{$ctrl.version}}

    Source
    -

    GitHub

    +

    GitHub

    Support
    Experiencing a problem or have a suggestion?
    diff --git a/gui/app/directives/modals/misc/manage-sort-tags.js b/gui/app/directives/modals/misc/manage-sort-tags.js index dc9c367a9..3e96d7e42 100644 --- a/gui/app/directives/modals/misc/manage-sort-tags.js +++ b/gui/app/directives/modals/misc/manage-sort-tags.js @@ -8,7 +8,7 @@ template: `
    -
    @@ -256,7 +199,7 @@ TYPE - SORT TAGS + TAGS diff --git a/gui/app/templates/viewers/_viewers.html b/gui/app/templates/viewers/_viewers.html index 1f5e7da60..d0a949ece 100644 --- a/gui/app/templates/viewers/_viewers.html +++ b/gui/app/templates/viewers/_viewers.html @@ -1,8 +1,11 @@ -
    +
    -
    + +
    diff --git a/gui/scss/core/_helpers.scss b/gui/scss/core/_helpers.scss index 516d44971..555a3e1a0 100644 --- a/gui/scss/core/_helpers.scss +++ b/gui/scss/core/_helpers.scss @@ -162,13 +162,25 @@ font-weight: 900; } .justify-between { - justify-content: space-between; + justify-content: space-between; +} + +.justify-center { + justify-content: center; +} + +.justify-end { + justify-content: flex-end; } .items-start { align-items: flex-start; - } +} .items-center { align-items: center; -} \ No newline at end of file +} + +.items-end { + align-items: flex-end; + } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 82d157fd3..0631f78e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "firebotv5", - "version": "5.50.2", + "version": "5.51.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1842,7 +1842,7 @@ "integrity": "sha512-luI3Jemd1AbOQW0krdzfEG3fM0IFtLY0bSSqIDEx3POE0XjKIC1MkrO8Csyq9PPgueLphyAPofzUwZ8YeZ88SA==" }, "angular-bootstrap-contextmenu": { - "version": "git+https://github.com/cavemobster/ui.bootstrap.contextMenu.git#47dd417f75e5042accc9d4f09e486bcc7e294012", + "version": "git+https://github.com/cavemobster/ui.bootstrap.contextMenu.git#b178d834933645102e7d95b5f63abe7ffe042ae8", "from": "git+https://github.com/cavemobster/ui.bootstrap.contextMenu.git" }, "angular-pageslide-directive": { @@ -4362,8 +4362,8 @@ } }, "expressionish": { - "version": "github:SReject/expressionish#cfa0bebbf43356cd431188ab5024424bd2378a59", - "from": "github:SReject/expressionish#cfa0bebbf43356cd431188ab5024424bd2378a59" + "version": "github:SReject/expressionish#0e91954d1e7c1bb9c6ddd026f6eaada739f87a48", + "from": "github:SReject/expressionish#0e91954d1e7c1bb9c6ddd026f6eaada739f87a48" }, "extend": { "version": "3.0.2", @@ -7128,9 +7128,9 @@ } }, "node-hue-api": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/node-hue-api/-/node-hue-api-4.0.10.tgz", - "integrity": "sha512-s+UvFttQfNXFadk8p6N9q9A5hteY2Q48W/mVze9nFPR5gwPH374cdA61ezKOx1WgBrN4btHj1z81veznhEZZAA==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/node-hue-api/-/node-hue-api-4.0.11.tgz", + "integrity": "sha512-lpnDdMjLTmm00JRsU70Mtm0Ix03cf7PRjKQAJbSg/Y0ChiIKQs+oDbSUpW2aDhEbor+wKpyfLYLGLTrjlG24pQ==", "requires": { "axios": "^0.21.1", "bottleneck": "^2.19.5", diff --git a/package.json b/package.json index 97b696e67..a18d55e2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebotv5", - "version": "5.50.4", + "version": "5.51.0", "description": "Powerful all-in-one bot for Twitch streamers.", "main": "main.js", "scripts": { @@ -85,7 +85,7 @@ "empty-folder": "^2.0.2", "eventsource": "^1.0.7", "express": "^4.17.1", - "expressionish": "github:SReject/expressionish#cfa0bebbf43356cd431188ab5024424bd2378a59", + "expressionish": "github:SReject/expressionish#0e91954d1e7c1bb9c6ddd026f6eaada739f87a48", "form-data": "^4.0.0", "fs-extra": "^7.0.1", "fuse.js": "^3.4.6", @@ -103,7 +103,7 @@ "ng-toast": "^2.0.0", "ng-youtube-embed": "^1.7.16", "node-cache": "^4.2.1", - "node-hue-api": "^4.0.10", + "node-hue-api": "^4.0.11", "node-json-db": "^1.4.1", "node-xlsx": "^0.20.0", "parse-link-header": "^1.0.1", diff --git a/server/api/v1/controllers/effectsApiController.js b/server/api/v1/controllers/effectsApiController.js index ddf1f6edc..f8b46621a 100644 --- a/server/api/v1/controllers/effectsApiController.js +++ b/server/api/v1/controllers/effectsApiController.js @@ -74,7 +74,24 @@ exports.runPresetList = async function(req, res) { } const body = req.body || {}; - const { args, username } = body; + const query = req.query || {}; + let args, username; + + // GET + if (req.method === "GET") + { + username = query.username; + args = query; + + // POST + } else if (req.method === "POST") { + username = body.username; + args = body.args; + + // Not GET or POST + } else { + return res.status(404).send({ status: "error", message: "Invalid request method" }); + } const processEffectsRequest = { trigger: { diff --git a/server/api/v1/controllers/viewersApiController.js b/server/api/v1/controllers/viewersApiController.js index a3fe9b98f..d4892fd24 100644 --- a/server/api/v1/controllers/viewersApiController.js +++ b/server/api/v1/controllers/viewersApiController.js @@ -1,7 +1,44 @@ "use strict"; +const userDb = require("../../../../backend/database/userDatabase"); +const customRolesManager = require("../../../../backend/roles/custom-roles-manager") const currencyDb = require("../../../../backend/database/currencyDatabase"); +exports.getAllUsers = async function(req, res) { + return res.json(await userDb.getAllUsernamesWithIds()); +} + +exports.getUserMetadata = async function(req, res) { + const { userId } = req.params; + const { username } = req.query; + + if (userId == null) { + return res.status(400).send({ + status: "error", + message: `No viewerIdOrName provided` + }); + } + + let metadata; + if (username === "true") { + metadata = await userDb.getUserByUsername(userId); + } else { + metadata = await userDb.getUserById(userId); + } + + if (metadata === null) { + return res.status(404).send({ + status: "error", + message: `Specified viewer does not exist` + }); + } + + const customRoles = customRolesManager.getAllCustomRolesForViewer(metadata.username) ?? []; + metadata.customRoles = customRoles; + + return res.json(metadata); +} + exports.getUserCurrency = async function(req, res) { const { userId, currencyId } = req.params; diff --git a/server/api/v1/v1Router.js b/server/api/v1/v1Router.js index b31a219bd..467032f67 100644 --- a/server/api/v1/v1Router.js +++ b/server/api/v1/v1Router.js @@ -66,6 +66,14 @@ router.route("/custom-variables/:variableName") // viewers const viewers = require("./controllers/viewersApiController"); +router + .route("/viewers") + .get(viewers.getAllUsers); + +router + .route("/viewers/:userId") + .get(viewers.getUserMetadata); + router .route("/viewers/:userId/currency") .get(viewers.getUserCurrency); diff --git a/shared/filter-constants.js b/shared/filter-constants.js index 8cf2f282e..330cdeb4c 100644 --- a/shared/filter-constants.js +++ b/shared/filter-constants.js @@ -18,8 +18,10 @@ const ComparisonType = Object.freeze({ STARTS_WITH: "starts with", DOESNT_END_WITH: "doesn't end with", ENDS_WITH: "ends with", - MATCHES_REGEX: "matches regex", - DOESNT_MATCH_REGEX: "doesn't match regex" + MATCHES_REGEX_CS: "matches regex", + DOESNT_MATCH_REGEX_CS: "doesn't matches regex", + MATCHES_REGEX: "matches regex (case insensitive)", + DOESNT_MATCH_REGEX: "doesn't match regex (case insensitive)" }); exports.ComparisonType = ComparisonType; \ No newline at end of file