From 042cc56780d43045cbe7fd84bce93305fe86368b Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 19 Feb 2024 22:29:43 -0500 Subject: [PATCH 001/113] chore: refactor viewer roles --- .../electron/events/when-ready.js | 2 +- .../chat-listeners/active-user-handler.js | 4 +- .../chat-listeners/twitch-chat-listeners.js | 16 +- .../builtin/custom-role-management.ts | 40 ++- .../moderation/chat-moderation-manager.js | 8 +- src/backend/chat/twitch-chat.ts | 8 +- .../twitch-commands/moderation-handlers.ts | 14 +- src/backend/common/profile-manager.js | 20 +- src/backend/currency/currency-manager.ts | 10 +- .../conditions/builtin/viewer-roles.js | 12 +- src/backend/effects/builtin/update-role.js | 25 +- .../effects/builtin/update-vip-role.js | 12 +- .../events/filters/builtin/viewer-roles.js | 14 +- .../games/builtin/heist/heist-command.js | 12 +- .../games/builtin/slots/spin-command.js | 14 +- .../games/builtin/trivia/trivia-command.js | 12 +- .../restrictions/builtin/permissions.js | 9 +- src/backend/roles/chat-roles-manager.js | 157 ----------- src/backend/roles/chat-roles-manager.ts | 172 ++++++++++++ src/backend/roles/custom-roles-manager.js | 204 -------------- src/backend/roles/custom-roles-manager.ts | 261 ++++++++++++++++++ src/backend/roles/firebot-roles-manager.js | 28 -- src/backend/roles/firebot-roles-manager.ts | 30 ++ src/backend/roles/role-helpers.js | 88 ------ src/backend/roles/role-helpers.ts | 65 +++++ src/backend/roles/team-roles-manager.js | 67 ----- src/backend/roles/team-roles-manager.ts | 88 ++++++ src/backend/twitch-api/api.ts | 4 +- .../twitch-api/frontend-twitch-listeners.js | 3 +- .../twitch-api/pubsub/pubsub-client.js | 7 +- src/backend/twitch-api/resource/channels.ts | 14 +- src/backend/twitch-api/resource/users.ts | 16 +- src/backend/utility.js | 11 + .../variables/builtin/user/roles/has-role.ts | 18 +- .../variables/builtin/user/roles/has-roles.ts | 24 +- .../builtin/user/roles/user-roles.ts | 42 +-- .../viewers/viewer-online-status-manager.ts | 2 +- .../modals/misc/viewer-seach-modal.js | 4 +- .../modals/roles/addOrEditCustomRoleModal.js | 27 +- .../modals/viewers/viewerDetailsModal.js | 28 +- src/gui/app/services/viewer-roles.service.js | 26 +- .../controllers/customRolesApiController.ts | 8 +- .../v1/controllers/viewersApiController.js | 4 +- src/types/roles.d.ts | 4 + src/types/viewers.d.ts | 2 +- 45 files changed, 928 insertions(+), 708 deletions(-) delete mode 100644 src/backend/roles/chat-roles-manager.js create mode 100644 src/backend/roles/chat-roles-manager.ts delete mode 100644 src/backend/roles/custom-roles-manager.js create mode 100644 src/backend/roles/custom-roles-manager.ts delete mode 100644 src/backend/roles/firebot-roles-manager.js create mode 100644 src/backend/roles/firebot-roles-manager.ts delete mode 100644 src/backend/roles/role-helpers.js create mode 100644 src/backend/roles/role-helpers.ts delete mode 100644 src/backend/roles/team-roles-manager.js create mode 100644 src/backend/roles/team-roles-manager.ts create mode 100644 src/types/roles.d.ts diff --git a/src/backend/app-management/electron/events/when-ready.js b/src/backend/app-management/electron/events/when-ready.js index 8fd2d8c7b..f846e467b 100644 --- a/src/backend/app-management/electron/events/when-ready.js +++ b/src/backend/app-management/electron/events/when-ready.js @@ -123,7 +123,7 @@ exports.whenReady = async () => { windowManagement.updateSplashScreenStatus("Loading custom roles..."); const customRolesManager = require("../../../roles/custom-roles-manager"); - customRolesManager.loadCustomRoles(); + await customRolesManager.loadCustomRoles(); windowManagement.updateSplashScreenStatus("Loading known bot list..."); const chatRolesManager = require("../../../roles/chat-roles-manager"); diff --git a/src/backend/chat/chat-listeners/active-user-handler.js b/src/backend/chat/chat-listeners/active-user-handler.js index 464e73a72..196cb3bbc 100644 --- a/src/backend/chat/chat-listeners/active-user-handler.js +++ b/src/backend/chat/chat-listeners/active-user-handler.js @@ -13,13 +13,13 @@ const ONLINE_TIMEOUT = 450; // 7.50 mins /** * Simple User * @typedef {Object} User - * @property {id} id + * @property {string} id * @property {string} username */ /** * @typedef {Object} UserDetails - * @property {number} id + * @property {string} id * @property {string} username * @property {string} displayName * @property {string} profilePicUrl diff --git a/src/backend/chat/chat-listeners/twitch-chat-listeners.js b/src/backend/chat/chat-listeners/twitch-chat-listeners.js index b06e5d9e2..8643fc09a 100644 --- a/src/backend/chat/chat-listeners/twitch-chat-listeners.js +++ b/src/backend/chat/chat-listeners/twitch-chat-listeners.js @@ -54,9 +54,13 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { await chatModerationManager.moderateMessage(firebotChatMessage); if (firebotChatMessage.isVip === true) { - chatRolesManager.addVipToVipList(firebotChatMessage.username); + chatRolesManager.addVipToVipList({ + id: msg.userInfo.userId, + username: msg.userInfo.userName, + displayName: msg.userInfo.displayName + }); } else { - chatRolesManager.removeVipFromVipList(firebotChatMessage.username); + chatRolesManager.removeVipFromVipList(msg.userInfo.userId); } // send to the frontend @@ -126,9 +130,13 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { const firebotChatMessage = await chatHelpers.buildFirebotChatMessage(msg, messageText, false, true); if (firebotChatMessage.isVip === true) { - chatRolesManager.addVipToVipList(firebotChatMessage.username); + chatRolesManager.addVipToVipList({ + id: msg.userInfo.userId, + username: msg.userInfo.userName, + displayName: msg.userInfo.displayName + }); } else { - chatRolesManager.removeVipFromVipList(firebotChatMessage.username); + chatRolesManager.removeVipFromVipList(msg.userInfo.userId); } frontendCommunicator.send("twitch:chat:message", firebotChatMessage); diff --git a/src/backend/chat/commands/builtin/custom-role-management.ts b/src/backend/chat/commands/builtin/custom-role-management.ts index 4ce85dc45..490db4aed 100644 --- a/src/backend/chat/commands/builtin/custom-role-management.ts +++ b/src/backend/chat/commands/builtin/custom-role-management.ts @@ -1,6 +1,7 @@ import { SystemCommand } from "../../../../types/commands"; import customRoleManager from "../../../roles/custom-roles-manager"; import chat from "../../twitch-chat"; +import twitchApi from "../../../twitch-api/api"; /** * The `!role` command @@ -61,37 +62,56 @@ export const CustomRoleManagementSystemCommand: SystemCommand = { switch (triggeredArg) { case "add": { - const roleName = args.slice(2); + const roleName = args.slice(2)[0]; const role = customRoleManager.getRoleByName(roleName); if (role == null) { await chat.sendChatMessage("Can't find a role by that name."); } else { const username = args[1].replace("@", ""); - customRoleManager.addViewerToRole(role.id, username); - await chat.sendChatMessage(`Added role ${role.name} to ${username}`); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + await chat.sendChatMessage(`Could not add role ${role.name} to ${username}. User does not exist.`); + } else { + customRoleManager.addViewerToRole(role.id, { + id: user.id, + username: user.name, + displayName: user.displayName + }); + await chat.sendChatMessage(`Added role ${role.name} to ${username}`); + } } break; } case "remove": { - const roleName = args.slice(2); + const roleName = args.slice(2)[0]; const role = customRoleManager.getRoleByName(roleName); if (role == null) { await chat.sendChatMessage("Can't find a role by that name."); } else { const username = args[1].replace("@", ""); - customRoleManager.removeViewerFromRole(role.id, username); - await chat.sendChatMessage(`Removed role ${role.name} from ${username}`); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + await chat.sendChatMessage(`Could not remove role ${role.name} from ${username}. User does not exist.`); + } else { + customRoleManager.removeViewerFromRole(role.id, user.id); + await chat.sendChatMessage(`Removed role ${role.name} from ${username}`); + } } break; } case "list": { if (args.length > 1) { const username = args[1].replace("@", ""); - const roleNames = customRoleManager.getAllCustomRolesForViewer(username).map((r) => r.name); - if (roleNames.length < 1) { - await chat.sendChatMessage(`${username} has no custom roles assigned.`); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + await chat.sendChatMessage(`Could not get roles for ${username}. User does not exist.`); } else { - await chat.sendChatMessage(`${username}'s custom roles: ${roleNames.join(", ")}`); + const roleNames = customRoleManager.getAllCustomRolesForViewer(user.id).map((r) => r.name); + if (roleNames.length < 1) { + await chat.sendChatMessage(`${username} has no custom roles assigned.`); + } else { + await chat.sendChatMessage(`${username}'s custom roles: ${roleNames.join(", ")}`); + } } } else { diff --git a/src/backend/chat/moderation/chat-moderation-manager.js b/src/backend/chat/moderation/chat-moderation-manager.js index 476dedd2d..be2bb9715 100644 --- a/src/backend/chat/moderation/chat-moderation-manager.js +++ b/src/backend/chat/moderation/chat-moderation-manager.js @@ -174,7 +174,7 @@ async function moderateMessage(chatMessage) { return; } - const userExemptGlobally = rolesManager.userIsInRole(chatMessage.username, chatMessage.roles, + const userExemptGlobally = rolesManager.userIsInRole(chatMessage.userId, chatMessage.roles, chatModerationSettings.exemptRoles); if (userExemptGlobally) { @@ -184,7 +184,7 @@ async function moderateMessage(chatMessage) { const twitchApi = require("../../twitch-api/api"); const chat = require("../twitch-chat"); - const userExemptForEmoteLimit = rolesManager.userIsInRole(chatMessage.username, chatMessage.roles, chatModerationSettings.emoteLimit.exemptRoles); + const userExemptForEmoteLimit = rolesManager.userIsInRole(chatMessage.userId, chatMessage.roles, chatModerationSettings.emoteLimit.exemptRoles); if (chatModerationSettings.emoteLimit.enabled && !!chatModerationSettings.emoteLimit.max && !userExemptForEmoteLimit) { const emoteCount = chatMessage.parts.filter(p => p.type === "emote").length; const emojiCount = chatMessage.parts @@ -203,7 +203,7 @@ async function moderateMessage(chatMessage) { } } - const userExemptForUrlModeration = rolesManager.userIsInRole(chatMessage.username, chatMessage.roles, chatModerationSettings.urlModeration.exemptRoles); + const userExemptForUrlModeration = rolesManager.userIsInRole(chatMessage.userId, chatMessage.roles, chatModerationSettings.urlModeration.exemptRoles); if (chatModerationSettings.urlModeration.enabled && !userExemptForUrlModeration && !permitCommand.hasTemporaryPermission(chatMessage.username)) { let shouldDeleteMessage = false; const message = chatMessage.rawText; @@ -280,7 +280,7 @@ async function moderateMessage(chatMessage) { messageId: messageId, username: username, scanForBannedWords: chatModerationSettings.bannedWordList.enabled, - isExempt: rolesManager.userIsInRole(chatMessage.username, chatMessage.roles, chatModerationSettings.bannedWordList.exemptRoles), + isExempt: rolesManager.userIsInRole(chatMessage.userId, chatMessage.roles, chatModerationSettings.bannedWordList.exemptRoles), maxEmotes: null } ); diff --git a/src/backend/chat/twitch-chat.ts b/src/backend/chat/twitch-chat.ts index f605240e1..cf2a8ebad 100644 --- a/src/backend/chat/twitch-chat.ts +++ b/src/backend/chat/twitch-chat.ts @@ -370,10 +370,14 @@ frontendCommunicator.onAsync("update-user-vip-status", async (data: UserVipReque if (shouldBeVip) { await twitchApi.moderation.addChannelVip(user.id); - chatRolesManager.addVipToVipList(username); + chatRolesManager.addVipToVipList({ + id: user.id, + username: user.name, + displayName: user.displayName + }); } else { await twitchApi.moderation.removeChannelVip(user.id); - chatRolesManager.removeVipFromVipList(username); + chatRolesManager.removeVipFromVipList(user.id); } }); diff --git a/src/backend/chat/twitch-commands/moderation-handlers.ts b/src/backend/chat/twitch-commands/moderation-handlers.ts index 5a7bd8763..a011f1a8b 100644 --- a/src/backend/chat/twitch-commands/moderation-handlers.ts +++ b/src/backend/chat/twitch-commands/moderation-handlers.ts @@ -115,15 +115,19 @@ export const vipHandler: TwitchSlashCommandHandler<[string]> = { }; }, handle: async ([targetUsername]) => { - const targetUserId = (await twitchApi.users.getUserByName(targetUsername))?.id; + const targetUser = await twitchApi.users.getUserByName(targetUsername); - if (targetUserId == null) { + if (targetUser == null) { return false; } - const result = await twitchApi.moderation.addChannelVip(targetUserId); + const result = await twitchApi.moderation.addChannelVip(targetUser.id); if (result === true) { - chatRolesManager.addVipToVipList(targetUsername); + chatRolesManager.addVipToVipList({ + id: targetUser.id, + username: targetUser.name, + displayName: targetUser.displayName + }); } return result; } @@ -155,7 +159,7 @@ export const unvipHandler: TwitchSlashCommandHandler<[string]> = { const result = await twitchApi.moderation.removeChannelVip(targetUserId); if (result === true) { - chatRolesManager.removeVipFromVipList(targetUsername); + chatRolesManager.removeVipFromVipList(targetUserId); } return result; } diff --git a/src/backend/common/profile-manager.js b/src/backend/common/profile-manager.js index 4943007e6..e64cf59bd 100644 --- a/src/backend/common/profile-manager.js +++ b/src/backend/common/profile-manager.js @@ -149,9 +149,14 @@ function deleteProfile() { } const getPathInProfile = function(filepath) { - const profilePath = - `${dataAccess.getUserDataPath()}/profiles/${getLoggedInProfile()}`; - return path.join(profilePath, filepath); + return path.join(dataAccess.getUserDataPath(), + "profiles", + getLoggedInProfile(), + filepath); +}; + +const getPathInProfileRelativeToUserData = function(filepath) { + return path.join("profiles", getLoggedInProfile(), filepath); }; /** @@ -178,11 +183,15 @@ const getJsonDbInProfile = function(filepath, humanReadable = true) { }; const profileDataPathExistsSync = function(filePath) { - const profilePath = `/profiles/${getLoggedInProfile()}`, - joinedPath = path.join(profilePath, filePath); + const joinedPath = getPathInProfileRelativeToUserData(filePath); return dataAccess.userDataPathExistsSync(joinedPath); }; +const deletePathInProfile = function(filePath) { + const joinedPath = getPathInProfileRelativeToUserData(filePath); + return dataAccess.deletePathInUserData(joinedPath); +}; + exports.getLoggedInProfile = getLoggedInProfile; exports.createNewProfile = createNewProfile; exports.getPathInProfile = getPathInProfile; @@ -193,3 +202,4 @@ exports.logInProfile = logInProfile; exports.renameProfile = renameProfile; exports.getNewProfileName = () => profileToRename; exports.hasProfileRename = () => profileToRename != null; +exports.deletePathInProfile = deletePathInProfile; diff --git a/src/backend/currency/currency-manager.ts b/src/backend/currency/currency-manager.ts index d82216ffd..b2519e3d3 100644 --- a/src/backend/currency/currency-manager.ts +++ b/src/backend/currency/currency-manager.ts @@ -380,8 +380,8 @@ class CurrencyManager { const teamRoles: Record> = {}; for (const viewer of onlineViewers) { - teamRoles[viewer.username] = await teamRolesManager - .getAllTeamRolesForViewer(viewer.username); + teamRoles[viewer._id] = await teamRolesManager + .getAllTeamRolesForViewer(viewer._id); } const userIdsInRoles = onlineViewers @@ -390,9 +390,9 @@ class CurrencyManager { const allRoles = [ ...twitchRoles.map(tr => twitchRolesManager.mapTwitchRole(tr)), - ...customRolesManager.getAllCustomRolesForViewer(u.username), - ...teamRoles[u.username], - ...firebotRolesManager.getAllFirebotRolesForViewer(u.username) + ...customRolesManager.getAllCustomRolesForViewer(u._id), + ...teamRoles[u._id], + ...firebotRolesManager.getAllFirebotRolesForViewer(u._id) ]; return { diff --git a/src/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js b/src/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js index dcac0fbc7..ddfe3a42e 100644 --- a/src/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js +++ b/src/backend/effects/builtin/conditional-effects/conditions/builtin/viewer-roles.js @@ -1,6 +1,7 @@ "use strict"; -const { viewerHasRoles } = require("../../../../../roles/role-helpers"); +const twitchApi = require("../../../../../twitch-api/api"); +const roleHelpers = require("../../../../../roles/role-helpers").default; module.exports = { id: "firebot:viewerroles", @@ -10,7 +11,7 @@ module.exports = { leftSideValueType: "text", leftSideTextPlaceholder: "Enter username", rightSideValueType: "preset", - getRightSidePresetValues: viewerRolesService => { + getRightSidePresetValues: (viewerRolesService) => { return viewerRolesService.getAllRoles() .map(r => ({ value: r.id, @@ -42,7 +43,12 @@ module.exports = { username = trigger.metadata.username; } - const hasRole = await viewerHasRoles(username, [rightSideValue]); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return false; + } + + const hasRole = await roleHelpers.viewerHasRoles(user.id, [rightSideValue]); switch (comparisonType) { case "include": diff --git a/src/backend/effects/builtin/update-role.js b/src/backend/effects/builtin/update-role.js index acbb39ef9..1cb38fb39 100644 --- a/src/backend/effects/builtin/update-role.js +++ b/src/backend/effects/builtin/update-role.js @@ -1,7 +1,9 @@ "use strict"; const { EffectCategory } = require('../../../shared/effect-constants'); +const twitchApi = require("../../twitch-api/api"); const customRolesManager = require("../../roles/custom-roles-manager"); +const logger = require('../../logwrapper'); /** * The Delay effect @@ -111,6 +113,11 @@ const delay = { */ onTriggerEvent: async event => { const effect = event.effect; + + if (effect.removeAllRoleId) { + customRolesManager.removeAllViewersFromRole(effect.removeAllRoleId); + return; + } let username = ""; if (effect.viewerType === "current") { @@ -119,16 +126,22 @@ const delay = { username = effect.customViewer ? effect.customViewer.trim() : ""; } - if (effect.addRoleId) { - customRolesManager.addViewerToRole(effect.addRoleId, username); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + logger.warn(`Unable to ${effect.addRoleId ? "add" : "remove"} custom role for ${username}. User does not exist.`); + return; } - if (effect.removeRoleId) { - customRolesManager.removeViewerFromRole(effect.removeRoleId, username); + if (effect.addRoleId) { + customRolesManager.addViewerToRole(effect.addRoleId, { + id: user.id, + username: user.name, + displayName: user.displayName + }); } - if (effect.removeAllRoleId) { - customRolesManager.removeAllViewersFromRole(effect.removeAllRoleId); + if (effect.removeRoleId) { + customRolesManager.removeViewerFromRole(effect.removeRoleId, user.id); } return true; diff --git a/src/backend/effects/builtin/update-vip-role.js b/src/backend/effects/builtin/update-vip-role.js index f88742bdf..b0f51a301 100644 --- a/src/backend/effects/builtin/update-vip-role.js +++ b/src/backend/effects/builtin/update-vip-role.js @@ -38,7 +38,7 @@ const model = { `, optionsController: () => {}, - optionsValidator: effect => { + optionsValidator: (effect) => { const errors = []; if (effect.action == null) { errors.push("Please choose an action."); @@ -48,7 +48,7 @@ const model = { } return errors; }, - onTriggerEvent: async event => { + onTriggerEvent: async (event) => { if (event.effect.action === "Add VIP") { const user = await twitchApi.users.getUserByName(event.effect.username); @@ -56,7 +56,11 @@ const model = { const result = await twitchApi.moderation.addChannelVip(user.id); if (result === true) { - chatRolesManager.addVipToVipList(user.displayName); + chatRolesManager.addVipToVipList({ + id: user.id, + username: user.name, + displayName: user.displayName + }); logger.debug(`${event.effect.username} was assigned VIP via the VIP effect.`); } else { logger.error(`${event.effect.username} was unable to be assigned VIP via the VIP effect.`); @@ -71,7 +75,7 @@ const model = { const result = await twitchApi.moderation.removeChannelVip(user.id); if (result === true) { - chatRolesManager.removeVipFromVipList(user.displayName); + chatRolesManager.removeVipFromVipList(user.id); logger.debug(`${event.effect.username} was unassigned VIP via the VIP effect.`); } else { logger.error(`${event.effect.username} was unable to be unassigned VIP via the VIP effect.`); diff --git a/src/backend/events/filters/builtin/viewer-roles.js b/src/backend/events/filters/builtin/viewer-roles.js index 53921c8bd..90880b51e 100644 --- a/src/backend/events/filters/builtin/viewer-roles.js +++ b/src/backend/events/filters/builtin/viewer-roles.js @@ -4,6 +4,7 @@ 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 twitchApi = require("../../../twitch-api/api"); module.exports = { id: "firebot:viewerroles", @@ -68,14 +69,19 @@ module.exports = { return false; } - /** @type{string[]} */ + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return false; + } + + /** @type {string[]} */ let twitchUserRoles = eventMeta.twitchUserRoles; if (twitchUserRoles == null) { - twitchUserRoles = await chatRolesManager.getUsersChatRoles(username); + twitchUserRoles = await chatRolesManager.getUsersChatRoles(user.id); } - const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(username) || []; - const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(username) || []; + const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(user.id) || []; + const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(user.id) || []; const userTwitchRoles = (twitchUserRoles || []) .map(twitchRolesManager.mapTwitchRole); diff --git a/src/backend/games/builtin/heist/heist-command.js b/src/backend/games/builtin/heist/heist-command.js index 1b412a594..05520343f 100644 --- a/src/backend/games/builtin/heist/heist-command.js +++ b/src/backend/games/builtin/heist/heist-command.js @@ -14,6 +14,7 @@ const twitchRolesManager = require("../../../../shared/twitch-roles"); const moment = require("moment"); const heistRunner = require("./heist-runner"); +const logger = require("../../../logwrapper"); const HEIST_COMMAND_ID = "firebot:heist"; @@ -44,6 +45,11 @@ const heistCommand = { const { chatEvent, userCommand } = event; const username = userCommand.commandSender; + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + logger.warn(`Could not process heist command for ${username}. User does not exist.`); + return; + } const heistSettings = gameManager.getGameSettings("firebot-heist"); const chatter = heistSettings.settings.chatSettings.chatter; @@ -157,11 +163,11 @@ const heistCommand = { } // deduct wager from user balance - await currencyManager.adjustCurrencyForViewer(username, currencyId, -Math.abs(wagerAmount)); + await currencyManager.adjustCurrencyForViewerById(user.id, currencyId, 0 - Math.abs(wagerAmount)); // get all user roles - const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(username) || []; - const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(username) || []; + const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(user.id) || []; + const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(user.id) || []; const userTwitchRoles = (userCommand.senderRoles || []) .map(r => twitchRolesManager.mapTwitchRole(r)) .filter(r => !!r); diff --git a/src/backend/games/builtin/slots/spin-command.js b/src/backend/games/builtin/slots/spin-command.js index bfa7e7497..085cc179b 100644 --- a/src/backend/games/builtin/slots/spin-command.js +++ b/src/backend/games/builtin/slots/spin-command.js @@ -13,6 +13,7 @@ const slotMachine = require("./slot-machine"); const logger = require("../../../logwrapper"); const moment = require("moment"); const NodeCache = require("node-cache"); +const twitchApi = require("../../../twitch-api/api"); const activeSpinners = new NodeCache({checkperiod: 2}); const cooldownCache = new NodeCache({checkperiod: 5}); @@ -47,6 +48,11 @@ const spinCommand = { const slotsSettings = gameManager.getGameSettings("firebot-slots"); const chatter = slotsSettings.settings.chatSettings.chatter; const username = userCommand.commandSender; + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + logger.warn(`Could not process spin command for ${username}. User does not exist.`); + return; + } // parse the wager amount let wagerAmount; @@ -168,7 +174,7 @@ const spinCommand = { } try { - await currencyManager.adjustCurrencyForViewer(username, currencyId, -Math.abs(wagerAmount)); + await currencyManager.adjustCurrencyForViewerById(user.id, currencyId, 0 - Math.abs(wagerAmount)); } catch (error) { logger.error(error); await twitchChat.sendChatMessage(`Sorry ${username}, there was an error deducting currency from your balance so the spin has been canceled.`, null, chatter); @@ -183,8 +189,8 @@ const spinCommand = { try { successChance = successChancesSettings.basePercent; - const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(username) || []; - const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(username) || []; + const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(user.id) || []; + const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(user.id) || []; const userTwitchRoles = (userCommand.senderRoles || []) .map(r => twitchRolesManager.mapTwitchRole(r)) .filter(r => !!r); @@ -215,7 +221,7 @@ const spinCommand = { const winnings = Math.floor(wagerAmount * (successfulRolls * winMultiplier)); - await currencyManager.adjustCurrencyForViewer(username, currencyId, winnings); + await currencyManager.adjustCurrencyForViewerById(user.id, currencyId, winnings); if (slotsSettings.settings.generalMessages.spinSuccessful) { const currency = currencyAccess.getCurrencyById(currencyId); diff --git a/src/backend/games/builtin/trivia/trivia-command.js b/src/backend/games/builtin/trivia/trivia-command.js index 037137d66..df8ec0052 100644 --- a/src/backend/games/builtin/trivia/trivia-command.js +++ b/src/backend/games/builtin/trivia/trivia-command.js @@ -14,6 +14,7 @@ const logger = require("../../../logwrapper"); const moment = require("moment"); const triviaHelper = require("./trivia-helper"); const NodeCache = require("node-cache"); +const twitchApi = require("../../../twitch-api/api"); let fiveSecTimeoutId; let answerTimeoutId; @@ -111,6 +112,11 @@ const triviaCommand = { const wagerAmount = parseInt(triggeredArg); const username = userCommand.commandSender; + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + logger.warn(`Could not process trivia command for ${username}. User does not exist.`); + return; + } if (currentQuestion) { if (currentQuestion.username === username) { @@ -180,15 +186,15 @@ const triviaCommand = { } try { - await currencyManager.adjustCurrencyForViewer(username, currencyId, -Math.abs(wagerAmount)); + await currencyManager.adjustCurrencyForViewerById(user.id, currencyId, 0 - Math.abs(wagerAmount)); } catch (error) { logger.error(error.message); await twitchChat.sendChatMessage(`Sorry ${username}, there was an error deducting currency from your balance so trivia has been canceled.`, null, chatter); return; } - const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(username) || []; - const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(username) || []; + const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(user.id) || []; + const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(user.id) || []; const userTwitchRoles = (userCommand.senderRoles || []) .map(r => twitchRolesManager.mapTwitchRole(r)) .filter(r => !!r); diff --git a/src/backend/restrictions/builtin/permissions.js b/src/backend/restrictions/builtin/permissions.js index 4ccb8b6e7..039c59edb 100644 --- a/src/backend/restrictions/builtin/permissions.js +++ b/src/backend/restrictions/builtin/permissions.js @@ -3,6 +3,7 @@ const customRolesManager = require("../../roles/custom-roles-manager"); const teamRolesManager = require("../../roles/team-roles-manager"); const twitchRolesManager = require("../../../shared/twitch-roles"); +const twitchApi = require("../../twitch-api/api"); const model = { definition: { @@ -108,9 +109,13 @@ const model = { return new Promise(async (resolve, reject) => { if (restrictionData.mode === "roles") { const username = triggerData.metadata.username; + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + reject("User does not exist"); + } - const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(username) || []; - const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(username) || []; + const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(user.id) || []; + const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(user.id) || []; const userTwitchRoles = (triggerData.metadata.userTwitchRoles || []) .map(mr => twitchRolesManager.mapTwitchRole(mr)); diff --git a/src/backend/roles/chat-roles-manager.js b/src/backend/roles/chat-roles-manager.js deleted file mode 100644 index 03ddccd9f..000000000 --- a/src/backend/roles/chat-roles-manager.js +++ /dev/null @@ -1,157 +0,0 @@ -"use strict"; -const axios = require("axios").default; -const twitchApi = require("../twitch-api/api"); -const accountAccess = require("../common/account-access"); -const logger = require("../logwrapper"); - -const VIEWLIST_BOTS_URL = "https://api.twitchinsights.net/v1/bots/all"; - -let viewerlistBotMap = {}; - -const cacheViewerListBots = async () => { - if (viewerlistBotMap != null && Object.keys(viewerlistBotMap).length > 0) { - return; - } - - try { - const responseData = (await axios.get(VIEWLIST_BOTS_URL)).data; - if (responseData.bots) { - viewerlistBotMap = responseData?.bots?.reduce((acc, [username, _channels, id]) => { - acc[username.toLowerCase()] = id; - return acc; - }, {}) ?? {}; - } - } catch { - // silently fail - } -}; - -/** @type {string[]} */ -let vips = []; - -/** - * @param {string[]} usersInVipRole - * @return {void} - */ -const loadUsersInVipRole = (usersInVipRole) => { - vips = usersInVipRole; -}; - -/** - * @param {string} username - * @return {void} - */ -const addVipToVipList = (username) => { - if (!vips.includes(username)) { - vips.push(username); - } -}; - -/** - * @param {string} username - * @return {void} - */ -const removeVipFromVipList = (username) => { - vips = vips.filter(vip => vip !== username); -}; - -/** - * @param {string} userIdOrName - * @returns {Promise} - */ -const getUserSubscriberRole = async (userIdOrName) => { - if (userIdOrName == null || userIdOrName === "") { - return ""; - } - - const isName = isNaN(userIdOrName); - - const client = twitchApi.streamerClient; - const userId = isName ? (await twitchApi.users.getUserByName(userIdOrName)).id : userIdOrName; - - const streamer = accountAccess.getAccounts().streamer; - const subInfo = await client.subscriptions.getSubscriptionForUser(streamer.userId, userId); - - if (subInfo == null || subInfo.tier == null) { - return null; - } - - let role = ''; - switch (subInfo.tier) { - case "1000": - role = "tier1"; - break; - case "2000": - role = "tier2"; - break; - case "3000": - role = "tier3"; - break; - } - - return role; -}; - -/** - * @param {string} [userIdOrName] - * @returns {Promise} - */ -const getUsersChatRoles = async (userIdOrName = "") => { - if (userIdOrName == null || userIdOrName === "") { - return []; - } - userIdOrName = userIdOrName.toLowerCase(); - const isName = isNaN(userIdOrName); - - const roles = []; - - try { - const client = twitchApi.streamerClient; - const username = isName ? userIdOrName : (await twitchApi.users.getUserById(userIdOrName)).name; - - if (viewerlistBotMap[username?.toLowerCase() ?? ""] != null) { - roles.push("viewerlistbot"); - } - - const streamer = accountAccess.getAccounts().streamer; - if (!userIdOrName || userIdOrName === streamer.userId || userIdOrName === streamer.username) { - roles.push("broadcaster"); - } - - if (streamer.broadcasterType !== "") { - const subscriberRole = await getUserSubscriberRole(userIdOrName); - if (subscriberRole != null) { - roles.push("sub"); - roles.push(subscriberRole); - } - } - - if (vips.some(v => v.toLowerCase() === username.toLowerCase())) { - roles.push("vip"); - } - - const moderators = (await client.moderation.getModerators(streamer.userId)).data; - if (moderators.some(m => m.userName === username)) { - roles.push("mod"); - } - - return roles; - } catch (err) { - logger.error("Failed to get user chat roles", err); - return []; - } -}; - -function userIsKnownBot(username) { - if (viewerlistBotMap[username?.toLowerCase() ?? ""] != null) { - return true; - } - return false; -} - -exports.loadUsersInVipRole = loadUsersInVipRole; -exports.addVipToVipList = addVipToVipList; -exports.removeVipFromVipList = removeVipFromVipList; -exports.getUsersChatRoles = getUsersChatRoles; -exports.cacheViewerListBots = cacheViewerListBots; -exports.userIsKnownBot = userIsKnownBot; \ No newline at end of file diff --git a/src/backend/roles/chat-roles-manager.ts b/src/backend/roles/chat-roles-manager.ts new file mode 100644 index 000000000..d5444b1db --- /dev/null +++ b/src/backend/roles/chat-roles-manager.ts @@ -0,0 +1,172 @@ +import axios from "axios"; +import { HelixUserRelation } from "@twurple/api"; + +import { BasicViewer } from "../../types/viewers"; +import logger from "../logwrapper"; +import accountAccess from "../common/account-access"; +import twitchApi from "../twitch-api/api"; + +const VIEWLIST_BOTS_URL = "https://api.twitchinsights.net/v1/bots/all"; + +interface KnownBot { + id?: string; + username: string; + channels: number; +} + +interface KnownBotServiceResponse { + bots: Array<[string, number, number]> +} + +class ChatRolesManager { + private _knownBots: KnownBot[] = []; + private _vips: BasicViewer[] = []; + + async cacheViewerListBots(): Promise { + if (this._knownBots?.length) { + return; + } + + try { + const responseData = (await axios.get(VIEWLIST_BOTS_URL)).data; + if (responseData?.bots != null) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + this._knownBots = responseData.bots.map(([username, channels, _lastSeen]) => { + return { + username: username.toLowerCase(), + channels: channels + }; + }) ?? []; + } + } catch { + // silently fail + } + } + + async userIsKnownBot(userId: string): Promise { + if (!userId?.length) { + return false; + } + + if (this._knownBots?.length) { + if (this._knownBots.some(b => b.id === userId) === true) { + return true; + } + + const user = await twitchApi.users.getUserById(userId); + if (user != null) { + const username = user.name.toLowerCase(); + const bot = this._knownBots.find(b => b.username === username); + + if (bot != null) { + bot.id = user.id; + return true; + } + } + } + + return false; + } + + loadUsersInVipRole(usersInVipRole: HelixUserRelation[]): void { + this._vips = usersInVipRole.map((u) => { + return { + id: u.id, + username: u.name, + displayName: u.displayName + }; + }); + } + + addVipToVipList(viewer: BasicViewer): void { + if (!this._vips.some(v => v.id === viewer.id)) { + this._vips.push(viewer); + } + } + + removeVipFromVipList(userId: string): void { + this._vips = this._vips.filter(v => v.id !== userId); + } + + private async getUserSubscriberRole(userIdOrName: string): Promise { + if (userIdOrName == null || userIdOrName === "") { + return ""; + } + + const isName = !new RegExp(/^\d+$/).test(userIdOrName); + + const client = twitchApi.streamerClient; + const userId = isName + ? (await twitchApi.users.getUserByName(userIdOrName)).id + : userIdOrName; + + const streamer = accountAccess.getAccounts().streamer; + const subInfo = await client.subscriptions.getSubscriptionForUser(streamer.userId, userId); + + if (subInfo == null || subInfo.tier == null) { + return null; + } + + let role = ""; + switch (subInfo.tier) { + case "1000": + role = "tier1"; + break; + case "2000": + role = "tier2"; + break; + case "3000": + role = "tier3"; + break; + } + + return role; + } + + async getUsersChatRoles(userId: string): Promise { + if (!userId?.length) { + return []; + } + + const roles: string[] = []; + + try { + const client = twitchApi.streamerClient; + + if (await this.userIsKnownBot(userId) === true) { + roles.push("viewerlistbot"); + } + + const streamer = accountAccess.getAccounts().streamer; + if (userId === streamer.userId) { + roles.push("broadcaster"); + } + + if (streamer.broadcasterType !== "") { + const subscriberRole = await this.getUserSubscriberRole(userId); + if (subscriberRole != null) { + roles.push("sub"); + roles.push(subscriberRole); + } + } + + if (this._vips.some(v => v.id === userId)) { + roles.push("vip"); + } + + const moderators = (await client.moderation.getModerators(streamer.userId)).data; + if (moderators.some(m => m.userId === userId)) { + roles.push("mod"); + } + + return roles; + } catch (err) { + logger.error("Failed to get user chat roles", err); + return []; + } + } +} + +const chatRolesManager = new ChatRolesManager(); + +export = chatRolesManager; \ No newline at end of file diff --git a/src/backend/roles/custom-roles-manager.js b/src/backend/roles/custom-roles-manager.js deleted file mode 100644 index 37a85f000..000000000 --- a/src/backend/roles/custom-roles-manager.js +++ /dev/null @@ -1,204 +0,0 @@ -"use strict"; - -const logger = require("../logwrapper"); -const profileManager = require("../common/profile-manager"); -const frontendCommunicator = require("../common/frontend-communicator"); -const twitchRoleManager = require("../../shared/twitch-roles"); - -/** - * @typedef CustomRole - * @property {string} id - * @property {string} name - * @property {string[]} viewers - */ - -/** @type {Object.} */ -let customRoles = {}; - -const ROLES_FOLDER = "/roles/"; -function getCustomRolesDb() { - return profileManager.getJsonDbInProfile(`${ROLES_FOLDER}customroles`); -} - -function loadCustomRoles() { - logger.debug(`Attempting to load roles data...`); - - const rolesDb = getCustomRolesDb(); - - try { - const customRolesData = rolesDb.getData("/"); - - if (customRolesData) { - customRoles = customRolesData; - } - - logger.debug(`Loaded roles data.`); - } catch (err) { - logger.warn(`There was an error reading roles data file.`, err); - } -} - -function saveCustomRole(role) { - if (role == null) { - return; - } - - customRoles[role.id] = role; - - try { - const rolesDb = getCustomRolesDb(); - - rolesDb.push(`/${role.id}`, role); - - logger.debug(`Saved role ${role.id} to file.`); - - } catch (err) { - logger.warn(`There was an error saving a role.`, err); - } -} - -function deleteCustomRole(roleId) { - if (roleId == null) { - return; - } - - delete customRoles[roleId]; - - try { - const rolesDb = getCustomRolesDb(); - - rolesDb.delete(`/${roleId}`); - - logger.debug(`Deleted role: ${roleId}`); - - } catch (err) { - logger.warn(`There was an error deleting a role.`, err); - } -} - -const findIndexIgnoreCase = (array, element) => { - if (Array.isArray(array)) { - const search = array.findIndex(e => e.toString().toLowerCase() === - element.toString().toLowerCase()); - return search; - } - return -1; -}; - -function addViewerToRole(roleId, username) { - if (username == null || username.length < 1) { - return; - } - const role = customRoles[roleId]; - if (role) { - if (findIndexIgnoreCase(role.viewers, username) !== -1) { - return; - } - - role.viewers.push(username); - - saveCustomRole(role); - - exports.triggerUiRefresh(); - } -} - -function removeAllViewersFromRole(roleId) { - const role = customRoles[roleId]; - if (role) { - role.viewers = []; - - saveCustomRole(role); - - exports.triggerUiRefresh(); - } -} - -function removeViewerFromRole(roleId, username) { - if (username == null || username.length < 1) { - return; - } - const role = customRoles[roleId]; - if (role) { - const index = findIndexIgnoreCase(role.viewers, username); - - if (index === -1) { - return; - } - - role.viewers.splice(index, 1); - - saveCustomRole(role); - - exports.triggerUiRefresh(); - } -} - -function getAllCustomRolesForViewer(username) { - const roles = Object.values(customRoles); - return roles - .filter(r => findIndexIgnoreCase(r.viewers, username) !== -1) - .map(r => { - return { - id: r.id, - name: r.name - }; - }); -} - -function userIsInRole(username, userTwitchRoles, roleIdsToCheck) { - const roles = [ - ...(userTwitchRoles || []).map(twitchRoleManager.mapTwitchRole), - ...getAllCustomRolesForViewer(username) - ]; - return roles.some(r => r != null && roleIdsToCheck.includes(r.id)); -} - -frontendCommunicator.onAsync("getCustomRoles", async () => customRoles); - -frontendCommunicator.on("saveCustomRole", (role) => { - saveCustomRole(role); -}); - -frontendCommunicator.on("deleteCustomRole", (roleId) => { - deleteCustomRole(roleId); -}); - -exports.triggerUiRefresh = () => { - frontendCommunicator.send("custom-role-update"); -}; - -exports.getRoleByName = name => { - const roles = Object.values(customRoles); - const roleIndex = findIndexIgnoreCase(roles.map(r => r.name), name); - if (roleIndex < 0) { - return null; - } - return roles[roleIndex]; -}; -exports.getCustomRoles = () => Object.values(customRoles); - -exports.getAllCustomRolesForViewer = getAllCustomRolesForViewer; - -exports.loadCustomRoles = loadCustomRoles; - -exports.userIsInRole = userIsInRole; - -exports.saveCustomRole = saveCustomRole; - -exports.deleteCustomRole = deleteCustomRole; - -exports.addViewerToRole = addViewerToRole; - -exports.removeViewerFromRole = removeViewerFromRole; - -exports.removeAllViewersFromRole = removeAllViewersFromRole; - - - - - - - - - diff --git a/src/backend/roles/custom-roles-manager.ts b/src/backend/roles/custom-roles-manager.ts new file mode 100644 index 000000000..daf8429dd --- /dev/null +++ b/src/backend/roles/custom-roles-manager.ts @@ -0,0 +1,261 @@ +import { JsonDB } from "node-json-db"; +import path from "path"; + +import logger from "../logwrapper"; +import util from "../utility"; +import profileManager from "../common/profile-manager"; +import frontendCommunicator from "../common/frontend-communicator"; +import twitchApi from "../twitch-api/api"; +import twitchRoleManager from "../../shared/twitch-roles"; +import { BasicViewer } from "../../types/viewers"; + +interface LegacyCustomRole { + id: string; + name: string; + viewers: string[]; +} + +interface CustomRole { + id: string; + name: string; + viewers: Array<{ + id: string; + username: string; + displayName: string; + }>; +} + +const ROLES_FOLDER = "roles"; + +class CustomRolesManager { + private _customRoles: Record = {}; + + constructor() { + frontendCommunicator.onAsync("get-custom-roles", async () => this._customRoles); + + frontendCommunicator.on("save-custom-role", (role: CustomRole) => { + this.saveCustomRole(role); + }); + + frontendCommunicator.on("delete-custom-role", (roleId: string) => { + this.deleteCustomRole(roleId); + }); + } + + async migrateLegacyCustomRoles(): Promise { + // Check for legacy custom roles file + if (profileManager.profileDataPathExistsSync(path.join(ROLES_FOLDER, "customroles.json"))) { + logger.info("Legacy custom roles file detected. Starting migration."); + + try { + const legacyCustomRolesDb = profileManager.getJsonDbInProfile(path.join(ROLES_FOLDER, "customroles")); + const legacyCustomRoles: Record = legacyCustomRolesDb.getData("/"); + + for (const legacyRole of Object.values(legacyCustomRoles)) { + logger.info(`Migrating custom role ${legacyRole.name}`); + + const newCustomRole: CustomRole = { + id: legacyRole.id, + name: legacyRole.name, + viewers: [] + }; + + const users = await twitchApi.users.getUsersByNames(legacyRole.viewers); + for (const user of users) { + newCustomRole.viewers.push({ + id: user.id, + username: user.name, + displayName: user.displayName + }); + } + + this.saveCustomRole(newCustomRole); + logger.info(`Finished migrating custom role ${newCustomRole.name}`); + } + + logger.info("Deleting legacy custom roles database"); + profileManager.deletePathInProfile(path.join(ROLES_FOLDER, "customroles.json")); + + logger.info("Legacy custom role migration complete"); + } catch (error) { + logger.error("Unexpected error during custom role migration", error); + } + } + } + + private getCustomRolesDb(): JsonDB { + return profileManager.getJsonDbInProfile(path.join(ROLES_FOLDER, "custom-roles")); + } + + async loadCustomRoles(): Promise { + await this.migrateLegacyCustomRoles(); + + logger.debug("Attempting to load custom roles"); + + const rolesDb = this.getCustomRolesDb(); + + try { + const customRolesData = rolesDb.getData("/"); + + if (customRolesData != null) { + this._customRoles = customRolesData; + } + + logger.debug("Loaded custom roles"); + + await this.refreshCustomRolesUserData(); + } catch (error) { + logger.warn("There was an error reading custom roles data file", error); + } + } + + async refreshCustomRolesUserData(): Promise { + logger.debug("Refreshing custom role user data"); + + for (const customRole of Object.values(this._customRoles ?? {})) { + logger.debug(`Updating custom role ${customRole.name}`); + + const userIds = customRole.viewers.map(v => v.id); + const users = await twitchApi.users.getUsersByIds(userIds); + + for (const user of users) { + const viewerIndex = customRole.viewers.findIndex(v => v.id === user.id); + customRole.viewers[viewerIndex] = { + id: user.id, + username: user.name, + displayName: user.displayName + } + } + + this.saveCustomRole(customRole); + logger.debug(`Custom role ${customRole.name} updated`); + } + } + + saveCustomRole(role: CustomRole) { + if (role == null) { + return; + } + + this._customRoles[role.id] = role; + + try { + const rolesDb = this.getCustomRolesDb(); + + rolesDb.push(`/${role.id}`, role); + + logger.debug(`Saved role ${role.id} to file.`); + } catch (error) { + logger.warn("There was an error saving a role.", error); + } + } + + addViewerToRole(roleId: string, viewer: BasicViewer) { + if (!viewer?.id?.length) { + return; + } + const role = this._customRoles[roleId]; + if (role != null) { + if (role.viewers.map(v => v.id).includes(viewer.id)) { + return; + } + + role.viewers.push({ + id: viewer.id, + username: viewer.username, + displayName: viewer.displayName + }); + + this.saveCustomRole(role); + + this.triggerUiRefresh(); + } + } + + getCustomRoles(): CustomRole[] { + return Object.values(this._customRoles); + } + + getRoleByName(name: string): CustomRole { + const roles = this.getCustomRoles(); + const roleIndex = util.findIndexIgnoreCase(roles.map(r => r.name), name); + return roleIndex < 0 ? null : roles[roleIndex]; + } + + getAllCustomRolesForViewer(userId: string) { + const roles = this.getCustomRoles(); + return roles + .filter(r => r.viewers.map(v => v.id).includes(userId)) + .map((r) => { + return { + id: r.id, + name: r.name + }; + }); + } + + userIsInRole(userId: string, userTwitchRoles: string[], roleIdsToCheck: string[]): boolean { + const roles = [ + ...(userTwitchRoles || []).map(twitchRoleManager.mapTwitchRole), + ...this.getAllCustomRolesForViewer(userId) + ]; + return roles.some(r => r != null && roleIdsToCheck.includes(r.id)); + } + + removeViewerFromRole(roleId: string, userId: string) { + if (!userId?.length) { + return; + } + const role = this._customRoles[roleId]; + if (role != null) { + const index = role.viewers.map(v => v.id).indexOf(userId); + + if (index === -1) { + return; + } + + role.viewers.splice(index, 1); + + this.saveCustomRole(role); + + exports.triggerUiRefresh(); + } + } + + removeAllViewersFromRole(roleId: string): void { + const role = this._customRoles[roleId]; + if (role != null) { + role.viewers = []; + + this.saveCustomRole(role); + + exports.triggerUiRefresh(); + } + } + + deleteCustomRole(roleId: string) { + if (!roleId?.length) { + return; + } + + delete this._customRoles[roleId]; + + try { + const rolesDb = this.getCustomRolesDb(); + + rolesDb.delete(`/${roleId}`); + + logger.debug(`Deleted role: ${roleId}`); + } catch (error) { + logger.warn("There was an error deleting a role.", error); + } + } + + triggerUiRefresh(): void { + frontendCommunicator.send("custom-roles-updated"); + } +} + +const customRolesManager = new CustomRolesManager(); + +export = customRolesManager; \ No newline at end of file diff --git a/src/backend/roles/firebot-roles-manager.js b/src/backend/roles/firebot-roles-manager.js deleted file mode 100644 index 3f278d71f..000000000 --- a/src/backend/roles/firebot-roles-manager.js +++ /dev/null @@ -1,28 +0,0 @@ -"use strict"; - -const firebotRoles = require("../../shared/firebot-roles"); - -const activeChatUsers = require("../chat/chat-listeners/active-user-handler"); - -function userIsInFirebotRole(role, username) { - switch (role.id) { - case "ActiveChatters": - return activeChatUsers.userIsActive(username); - default: - return false; - } -} - -function getAllFirebotRolesForViewer(username) { - const roles = firebotRoles.getFirebotRoles(); - return roles - .filter(r => userIsInFirebotRole(r, username) !== false) - .map(r => { - return { - id: r.id, - name: r.name - }; - }); -} - -exports.getAllFirebotRolesForViewer = getAllFirebotRolesForViewer; diff --git a/src/backend/roles/firebot-roles-manager.ts b/src/backend/roles/firebot-roles-manager.ts new file mode 100644 index 000000000..32ea603e2 --- /dev/null +++ b/src/backend/roles/firebot-roles-manager.ts @@ -0,0 +1,30 @@ +import { FirebotRole } from "../../types/roles"; +import firebotRoles from "../../shared/firebot-roles"; +import activeChatUsers from "../chat/chat-listeners/active-user-handler"; + +class FirebotRolesManager { + private userIsInFirebotRole(role: FirebotRole, userIdOrName: string): boolean { + switch (role.id) { + case "ActiveChatters": + return activeChatUsers.userIsActive(userIdOrName); + default: + return false; + } + } + + getAllFirebotRolesForViewer(userIdOrName: string): FirebotRole[] { + const roles = firebotRoles.getFirebotRoles(); + return roles + .filter(r => this.userIsInFirebotRole(r, userIdOrName) !== false) + .map((r) => { + return { + id: r.id, + name: r.name + }; + }); + } +} + +const firebotRolesManager = new FirebotRolesManager(); + +export = firebotRolesManager; \ No newline at end of file diff --git a/src/backend/roles/role-helpers.js b/src/backend/roles/role-helpers.js deleted file mode 100644 index 4af74e0fe..000000000 --- a/src/backend/roles/role-helpers.js +++ /dev/null @@ -1,88 +0,0 @@ -"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 - ]; -} - -/** - * - * @param {string} username - */ -async function getAllRolesForViewerNameSpaced(username) { - return { - twitchRoles: (await chatRolesManager.getUsersChatRoles(username)).map(twitchRolesManager.mapTwitchRole), - firebotRoles: firebotRolesManager.getAllFirebotRolesForViewer(username), - customRoles: customRolesManager.getAllCustomRolesForViewer(username), - teamRoles: await teamRolesManager.getAllTeamRolesForViewer(username) - }; -} - -/** - * 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.getAllRolesForViewerNameSpaced = getAllRolesForViewerNameSpaced; -exports.viewerHasRoles = viewerHasRoles; -exports.viewerHasRolesByName = viewerHasRolesByName; -exports.viewerHasRole = viewerHasRole; -exports.viewerHasRoleByName = viewerHasRoleByName; \ No newline at end of file diff --git a/src/backend/roles/role-helpers.ts b/src/backend/roles/role-helpers.ts new file mode 100644 index 000000000..3165237f8 --- /dev/null +++ b/src/backend/roles/role-helpers.ts @@ -0,0 +1,65 @@ +import { FirebotRole } from "../../types/roles"; +import firebotRolesManager from "./firebot-roles-manager"; +import chatRolesManager from "./chat-roles-manager"; +import teamRolesManager from "./team-roles-manager"; +import customRolesManager from "./custom-roles-manager"; +import twitchRolesManager from "../../shared/twitch-roles"; + +export interface FirebotViewerRoles { + twitchRoles: FirebotRole[]; + firebotRoles: FirebotRole[]; + customRoles: FirebotRole[]; + teamRoles: FirebotRole[]; +} + +class RoleHelpers { + async getAllRolesForViewer(userId: string): Promise { + const roles = await this.getAllRolesForViewerNameSpaced(userId); + + return [ + ...roles.twitchRoles, + ...roles.firebotRoles, + ...roles.customRoles, + ...roles.teamRoles + ]; + } + + async getAllRolesForViewerNameSpaced(userId: string): Promise { + return { + twitchRoles: (await chatRolesManager.getUsersChatRoles(userId)).map(twitchRolesManager.mapTwitchRole), + firebotRoles: firebotRolesManager.getAllFirebotRolesForViewer(userId), + customRoles: customRolesManager.getAllCustomRolesForViewer(userId), + teamRoles: await teamRolesManager.getAllTeamRolesForViewer(userId) + }; + } + + async viewerHasRole(userId: string, expectedRoleId: string): Promise { + const viewerRoles = await this.getAllRolesForViewer(userId); + return viewerRoles.some(r => r.id === expectedRoleId); + } + + async viewerHasRoleByName(userId: string, expectedRoleName: string): Promise { + const viewerRoles = await this.getAllRolesForViewer(userId); + return viewerRoles.some(r => r.name === expectedRoleName); + } + + /** + * Check if user has the given roles by their ids + */ + async viewerHasRoles(userId: string, expectedRoleIds: string[]): Promise { + const viewerRoles = await this.getAllRolesForViewer(userId); + return expectedRoleIds.every(n => viewerRoles.some(r => r.id === n)); + } + + /** + * Check if user has the given roles by their names + */ + async viewerHasRolesByName(userId: string, expectedRoleNames: string[]): Promise { + const viewerRoles = await this.getAllRolesForViewer(userId); + return expectedRoleNames.every(n => viewerRoles.some(r => r.name === n)); + } +} + +const roleHelpers = new RoleHelpers(); + +export default roleHelpers; \ No newline at end of file diff --git a/src/backend/roles/team-roles-manager.js b/src/backend/roles/team-roles-manager.js deleted file mode 100644 index b62d7030b..000000000 --- a/src/backend/roles/team-roles-manager.js +++ /dev/null @@ -1,67 +0,0 @@ -"use strict"; - -const twitchApi = require("../twitch-api/api"); -const frontendCommunicator = require("../common/frontend-communicator"); - -let streamerTeams = []; - -const loadTeamRoles = async () => { - const roles = await twitchApi.teams.getStreamerTeams(); - - if (!roles.length) { - streamerTeams = null; - return; - } - - roles.forEach(async team => { - const members = await team.getUserRelations(); - streamerTeams.push({ - mappedRole: { - id: parseInt(team.id), - name: team.displayName - }, - members: members.map(m => m.displayName) - }); - }); -}; - -const getTeamRoles = async () => { - if (streamerTeams == null) { - return []; - } - - if (!streamerTeams.length) { - await loadTeamRoles(); - } - - return streamerTeams.map(team => team.mappedRole); -}; - -const getAllTeamRolesForViewer = async (username) => { - if (streamerTeams == null) { - return []; - } - - const teams = []; - streamerTeams.forEach(team => { - if (team.members.some(m => m.toLowerCase() === username.toLowerCase())) { - teams.push(team.mappedRole); - } - }); - - return teams; -}; - -frontendCommunicator.onAsync("getTeamRoles", async () => { - if (streamerTeams == null) { - return []; - } - - const roles = await getTeamRoles(); - return roles; - -}); - -exports.loadTeamRoles = loadTeamRoles; -exports.getTeamRoles = getTeamRoles; -exports.getAllTeamRolesForViewer = getAllTeamRolesForViewer; \ No newline at end of file diff --git a/src/backend/roles/team-roles-manager.ts b/src/backend/roles/team-roles-manager.ts new file mode 100644 index 000000000..9e6a43ccd --- /dev/null +++ b/src/backend/roles/team-roles-manager.ts @@ -0,0 +1,88 @@ +import { FirebotRole } from "../../types/roles"; +import twitchApi from "../twitch-api/api"; +import frontendCommunicator from "../common/frontend-communicator"; + +interface TwitchTeam { + mappedRole: { + id: string; + name: string; + }, + members: Array<{ + id: string; + username: string; + displayName: string; + }> +} + +class TeamRolesManager { + private _streamerTeams: TwitchTeam[] = []; + + constructor() { + frontendCommunicator.onAsync("get-team-roles", async () => { + if (this._streamerTeams == null) { + return []; + } + + const roles = await this.getTeamRoles(); + return roles; + }); + } + + async loadTeamRoles(): Promise { + const roles = await twitchApi.teams.getStreamerTeams(); + + if (!roles?.length) { + this._streamerTeams = null; + return; + } + + roles.forEach(async (team) => { + const members = await team.getUserRelations(); + this._streamerTeams.push({ + mappedRole: { + id: team.id, + name: team.displayName + }, + members: members.map((m) => { + return { + id: m.id, + username: m.name, + displayName: m.displayName + }; + }) + }); + }); + } + + async getTeamRoles(): Promise { + if (this._streamerTeams == null) { + return []; + } + + if (!this._streamerTeams.length) { + await this.loadTeamRoles(); + } + + return this._streamerTeams.map(team => team.mappedRole); + } + + async getAllTeamRolesForViewer(userIdOrName: string): Promise { + if (this._streamerTeams == null) { + return []; + } + + const teams: FirebotRole[] = []; + this._streamerTeams.forEach((team) => { + if (team.members.some(m => m.id.toLowerCase() === userIdOrName.toLowerCase() + || m.username.toLowerCase() === userIdOrName.toLowerCase())) { + teams.push(team.mappedRole); + } + }); + + return teams; + } +} + +const teamRolesManager = new TeamRolesManager(); + +export = teamRolesManager; \ No newline at end of file diff --git a/src/backend/twitch-api/api.ts b/src/backend/twitch-api/api.ts index 3289b7fe4..5e713c33f 100644 --- a/src/backend/twitch-api/api.ts +++ b/src/backend/twitch-api/api.ts @@ -131,4 +131,6 @@ class TwitchApi { } } -export = new TwitchApi(); \ No newline at end of file +const twitchApi = new TwitchApi(); + +export = twitchApi; \ No newline at end of file diff --git a/src/backend/twitch-api/frontend-twitch-listeners.js b/src/backend/twitch-api/frontend-twitch-listeners.js index b2080cad7..7b7eb501f 100644 --- a/src/backend/twitch-api/frontend-twitch-listeners.js +++ b/src/backend/twitch-api/frontend-twitch-listeners.js @@ -14,7 +14,8 @@ exports.setupListeners = () => { const response = await twitchApi.streamerClient.search.searchChannels(query, { limit: 10 }); return (response?.data ?? []).map(c => ({ id: c.id, - username: c.displayName, + username: c.name, + displayName: c.displayName, avatarUrl: c.thumbnailUrl })); }); diff --git a/src/backend/twitch-api/pubsub/pubsub-client.js b/src/backend/twitch-api/pubsub/pubsub-client.js index 209c113b4..38cb1808f 100644 --- a/src/backend/twitch-api/pubsub/pubsub-client.js +++ b/src/backend/twitch-api/pubsub/pubsub-client.js @@ -118,10 +118,13 @@ async function createClient() { switch (message.type) { case "vip_added": - chatRolesManager.addVipToVipList(message.targetUserName); + chatRolesManager.addVipToVipList({ + id: message.targetUserId, + username: message.targetUserName + }); break; case "vip_removed": - chatRolesManager.removeVipFromVipList(message.targetUserName); + chatRolesManager.removeVipFromVipList(message.targetUserId); break; default: switch (message.action) { diff --git a/src/backend/twitch-api/resource/channels.ts b/src/backend/twitch-api/resource/channels.ts index 2b098e832..84d690d15 100644 --- a/src/backend/twitch-api/resource/channels.ts +++ b/src/backend/twitch-api/resource/channels.ts @@ -1,6 +1,6 @@ import logger from '../../logwrapper'; import accountAccess from "../../common/account-access"; -import { ApiClient, CommercialLength, HelixChannel, HelixChannelUpdate, HelixUser } from "@twurple/api"; +import { ApiClient, CommercialLength, HelixChannel, HelixChannelUpdate, HelixUser, HelixUserRelation } from "@twurple/api"; export class TwitchChannelsApi { private _streamerClient: ApiClient; @@ -148,18 +148,12 @@ export class TwitchChannelsApi { /** * Gets all the VIPs in the streamer's channel */ - async getVips(): Promise { - const vips: string[] = []; + async getVips(): Promise { + const vips: HelixUserRelation[] = []; const streamerId = accountAccess.getAccounts().streamer.userId; try { - let result = await this._streamerClient.channels.getVips(streamerId); - vips.push(...result.data.map(c => c.displayName)); - - while (result.cursor) { - result = await this._streamerClient.channels.getVips(streamerId, { after: result.cursor }); - vips.push(...result.data.map(c => c.displayName)); - } + vips.push(...await this._streamerClient.channels.getVipsPaginated(streamerId).getAll()); } catch (error) { logger.error("Error getting VIPs", error.message); } diff --git a/src/backend/twitch-api/resource/users.ts b/src/backend/twitch-api/resource/users.ts index 0b218e293..028763b9f 100644 --- a/src/backend/twitch-api/resource/users.ts +++ b/src/backend/twitch-api/resource/users.ts @@ -15,12 +15,26 @@ export class TwitchUsersApi { return await this._streamerClient.users.getUserById(userId); } + async getUsersByIds(userIds: string[]): Promise { + const users: HelixUser[] = []; + for (let x = 0; x < userIds.length; x += 100) { + const userBatch = userIds.slice(x, x + 100); + users.push(...await this._streamerClient.users.getUsersByIds(userBatch)); + } + return users; + } + async getUserByName(username: string): Promise { return await this._streamerClient.users.getUserByName(username); } async getUsersByNames(usernames: string[]): Promise { - return await this._streamerClient.users.getUsersByNames(usernames); + const users: HelixUser[] = []; + for (let x = 0; x < usernames.length; x += 100) { + const userBatch = usernames.slice(x, x + 100); + users.push(...await this._streamerClient.users.getUsersByNames(userBatch)); + } + return users; } async getFollowDateForUser(username: string): Promise { diff --git a/src/backend/utility.js b/src/backend/utility.js index 9214cb25b..fb4879286 100644 --- a/src/backend/utility.js +++ b/src/backend/utility.js @@ -285,6 +285,16 @@ const emptyFolder = async (folderPath) => { } }; +const findIndexIgnoreCase = (array, element) => { + if (Array.isArray(array)) { + element = element.toString().toLowerCase(); + const search = array.findIndex(e => e.toString().toLowerCase() === element); + return search; + } + + return -1; +}; + exports.getRandomInt = getRandomInt; exports.escapeRegExp = escapeRegExp; exports.getUrlRegex = getUrlRegex; @@ -305,3 +315,4 @@ exports.convertToString = convertToString; exports.deepClone = deepClone; exports.deepFreeze = deepFreeze; exports.emptyFolder = emptyFolder; +exports.findIndexIgnoreCase = findIndexIgnoreCase; diff --git a/src/backend/variables/builtin/user/roles/has-role.ts b/src/backend/variables/builtin/user/roles/has-role.ts index e7233615b..7bbe249b4 100644 --- a/src/backend/variables/builtin/user/roles/has-role.ts +++ b/src/backend/variables/builtin/user/roles/has-role.ts @@ -1,8 +1,9 @@ import { ReplaceVariable } from "../../../../../types/variables"; import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; -import { EffectTrigger } from '../../../../../shared/effect-constants'; +import { EffectTrigger } from "../../../../../shared/effect-constants"; -const { viewerHasRoleByName } = require('../../../../roles/role-helpers'); +import twitchApi from "../../../../twitch-api/api"; +import roleHelpers from "../../../../roles/role-helpers"; const triggers = {}; triggers[EffectTrigger.COMMAND] = true; @@ -21,16 +22,21 @@ const model : ReplaceVariable = { categories: [VariableCategory.COMMON, VariableCategory.USER], possibleDataOutput: [OutputDataType.ALL] }, - evaluator: async (trigger, username, role) => { - if (username == null || username === '') { + evaluator: async (trigger, username: string, role: string) => { + if (username == null || username === "") { return false; } - if (role == null || role === '') { + if (role == null || role === "") { return false; } - return viewerHasRoleByName(username, role); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return false; + } + + return await roleHelpers.viewerHasRoleByName(user.id, role); } }; export default model; \ No newline at end of file diff --git a/src/backend/variables/builtin/user/roles/has-roles.ts b/src/backend/variables/builtin/user/roles/has-roles.ts index 6838a23dc..69e9d63a0 100644 --- a/src/backend/variables/builtin/user/roles/has-roles.ts +++ b/src/backend/variables/builtin/user/roles/has-roles.ts @@ -1,8 +1,9 @@ import { ReplaceVariable } from "../../../../../types/variables"; import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; -import { EffectTrigger } from '../../../../../shared/effect-constants'; +import { EffectTrigger } from "../../../../../shared/effect-constants"; -const { getAllRolesForViewer } = require('../../../../roles/role-helpers'); +import twitchApi from "../../../../twitch-api/api"; +import roleHelpers from "../../../../roles/role-helpers"; const triggers = {}; triggers[EffectTrigger.COMMAND] = true; @@ -19,11 +20,11 @@ const model : ReplaceVariable = { description: "Returns true if the user has the specified roles. Only valid within $if", examples: [ { - usage: 'hasRoles[$user, any, mod, vip]', + usage: "hasRoles[$user, any, mod, vip]", description: "returns true if $user is a mod OR VIP" }, { - usage: 'hasRoles[$user, all, mod, vip]', + usage: "hasRoles[$user, all, mod, vip]", description: "Returns true if $user is a mod AND a VIP" } ], @@ -31,8 +32,8 @@ const model : ReplaceVariable = { categories: [VariableCategory.COMMON, VariableCategory.USER], possibleDataOutput: [OutputDataType.ALL] }, - evaluator: async (trigger, username, respective, ...roles) => { - if (username == null || username === '') { + evaluator: async (trigger, username: string, respective, ...roles) => { + if (username == null || username === "") { return false; } @@ -45,14 +46,19 @@ const model : ReplaceVariable = { } respective = (`${respective}`).toLowerCase(); - if (respective !== 'any' && respective !== 'all') { + if (respective !== "any" && respective !== "all") { return false; } - const userRoles = await getAllRolesForViewer(username); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return false; + } + + const userRoles = await roleHelpers.getAllRolesForViewer(user.id); // any - if (respective === 'any') { + if (respective === "any") { return userRoles.some(r => roles.includes(r.name)); } diff --git a/src/backend/variables/builtin/user/roles/user-roles.ts b/src/backend/variables/builtin/user/roles/user-roles.ts index 1f8a4403d..0936ead5d 100644 --- a/src/backend/variables/builtin/user/roles/user-roles.ts +++ b/src/backend/variables/builtin/user/roles/user-roles.ts @@ -1,8 +1,9 @@ import { ReplaceVariable } from "../../../../../types/variables"; import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; -import { EffectTrigger } from '../../../../../shared/effect-constants'; +import { EffectTrigger } from "../../../../../shared/effect-constants"; -import { getAllRolesForViewerNameSpaced } from '../../../../roles/role-helpers'; +import twitchApi from "../../../../twitch-api/api"; +import roleHelpers from "../../../../roles/role-helpers"; const triggers = {}; triggers[EffectTrigger.COMMAND] = true; @@ -19,31 +20,31 @@ const model : ReplaceVariable = { description: "Returns an array containing all roles of the user", examples: [ { - usage: 'userRoles', + usage: "userRoles", description: "Returns all roles for the user" }, { - usage: 'userRoles[$user]', + usage: "userRoles[$user]", description: "Returns all roles of the specified user" }, { - usage: 'userRoles[$user, all]', + usage: "userRoles[$user, all]", description: "Returns all roles of the specified user as nested arrays in the order of: twitch, team, firebot and custom" }, { - usage: 'userRoles[$user, firebot]', + usage: "userRoles[$user, firebot]", description: "Returns all firebot roles of the specified user" }, { - usage: 'userRoles[$user, custom]', + usage: "userRoles[$user, custom]", description: "Returns all custom roles of the specified user" }, { - usage: 'userRoles[$user, twitch]', + usage: "userRoles[$user, twitch]", description: "Returns all Twitch roles of the specified user" }, { - usage: 'userRoles[$user, team]', + usage: "userRoles[$user, team]", description: "Returns all Twitch team roles of the specified user" } ], @@ -54,20 +55,25 @@ const model : ReplaceVariable = { evaluator: async (trigger, username: null | string, roleType) : Promise => { if (username == null && roleType == null) { username = trigger.metadata.username; - roleType = 'all'; + roleType = "all"; } - if (username == null || username === '') { + if (username == null || username === "") { return []; } if (roleType == null || roleType === "") { - roleType = 'all'; + roleType = "all"; } else { roleType = (`${roleType}`).toLowerCase(); } - const userRoles = await getAllRolesForViewerNameSpaced(username); + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return []; + } + + const userRoles = await roleHelpers.getAllRolesForViewerNameSpaced(user.id); Object .keys(userRoles) @@ -75,7 +81,7 @@ const model : ReplaceVariable = { userRoles[key] = userRoles[key].map(r => r.name); }); - if (roleType === 'all') { + if (roleType === "all") { return [ userRoles.twitchRoles || [], userRoles.teamRoles || [], @@ -83,16 +89,16 @@ const model : ReplaceVariable = { userRoles.customRoles || [] ]; } - if (roleType === 'twitch') { + if (roleType === "twitch") { return userRoles.twitchRoles; } - if (roleType === 'team') { + if (roleType === "team") { return userRoles.teamRoles; } - if (roleType === 'firebot') { + if (roleType === "firebot") { return userRoles.firebotRoles; } - if (roleType === 'custom') { + if (roleType === "custom") { return userRoles.customRoles; } return []; diff --git a/src/backend/viewers/viewer-online-status-manager.ts b/src/backend/viewers/viewer-online-status-manager.ts index 763cdf766..9248b3b99 100644 --- a/src/backend/viewers/viewer-online-status-manager.ts +++ b/src/backend/viewers/viewer-online-status-manager.ts @@ -100,7 +100,7 @@ class ViewerOnlineStatusManager { lastSeen: now }; - if (chatRolesManager.userIsKnownBot(viewer.username) && settings.getAutoFlagBots()) { + if (await chatRolesManager.userIsKnownBot(viewer.id) === true && settings.getAutoFlagBots()) { dbData.disableAutoStatAccrual = true; dbData.disableActiveUserList = true; } diff --git a/src/gui/app/directives/modals/misc/viewer-seach-modal.js b/src/gui/app/directives/modals/misc/viewer-seach-modal.js index cd96d6bab..8ce900590 100644 --- a/src/gui/app/directives/modals/misc/viewer-seach-modal.js +++ b/src/gui/app/directives/modals/misc/viewer-seach-modal.js @@ -18,12 +18,12 @@ - {{$select.selected.username}} + {{$select.selected.displayName}}
-
{{channel.username}}
+
{{channel.displayName}}
diff --git a/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js b/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js index 29f4f62e4..3ef457e7c 100644 --- a/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js +++ b/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js @@ -38,8 +38,8 @@
-
{{viewer}}
- +
{{viewer.displayName}}
+
@@ -78,15 +78,6 @@ pageSize: 5 }; - const findIndexIgnoreCase = (array, element) => { - if (Array.isArray(array)) { - const search = array.findIndex(e => e.toString().toLowerCase() === - element.toString().toLowerCase()); - return search; - } - return -1; - }; - $ctrl.addViewer = function() { utilityService.openViewerSearchModal( { @@ -97,7 +88,7 @@ if (user == null) { return resolve(false); } - if (findIndexIgnoreCase($ctrl.role.viewers, user.username) !== -1) { + if ($ctrl.role.viewers.map(v => v.id).includes(user.id)) { return resolve(false); } resolve(true); @@ -106,19 +97,23 @@ validationText: "Viewer already has this role." }, (user) => { - $ctrl.role.viewers.push(user.username); + $ctrl.role.viewers.push({ + id: user.id, + username: user.username, + displayName: user.displayName + }); }); }; - $ctrl.deleteViewer = function(viewer) { + $ctrl.deleteViewer = function(userId, displayName) { utilityService.showConfirmationModal({ title: "Remove Viewer", - question: `Are you sure you want to remove ${viewer} from this role?`, + question: `Are you sure you want to remove ${displayName} from this role?`, confirmLabel: "Remove", confirmBtnType: "btn-danger" }).then(confirmed => { if (confirmed) { - $ctrl.role.viewers = $ctrl.role.viewers.filter(v => v !== viewer); + $ctrl.role.viewers = $ctrl.role.viewers.filter(v => v.id !== userId); } }); }; diff --git a/src/gui/app/directives/modals/viewers/viewerDetailsModal.js b/src/gui/app/directives/modals/viewers/viewerDetailsModal.js index 418b8c73d..564830ff0 100644 --- a/src/gui/app/directives/modals/viewers/viewerDetailsModal.js +++ b/src/gui/app/directives/modals/viewers/viewerDetailsModal.js @@ -138,7 +138,7 @@
CUSTOM ROLES
{{customRole.name}} - +
@@ -617,19 +617,19 @@ $ctrl.hasCustomRoles = viewerRolesService.getCustomRoles().length > 0; $ctrl.customRoles = []; function loadCustomRoles() { - const username = $ctrl.viewerDetails.twitchData.displayName; + const userId = $ctrl.viewerDetails.twitchData.id; const viewerRoles = viewerRolesService.getCustomRoles(); $ctrl.hasCustomRolesAvailable = viewerRoles - .filter(r => !r.viewers.some(v => v.toLowerCase() === username.toLowerCase())) + .filter(r => !r.viewers.some(v => v.id === userId)) .length > 0; - $ctrl.customRoles = viewerRoles.filter(vr => vr.viewers.some(v => v.toLowerCase() === username.toLowerCase())); + $ctrl.customRoles = viewerRoles.filter(vr => vr.viewers.some(v => v.id === userId)); } $ctrl.openAddCustomRoleModal = () => { - const username = $ctrl.viewerDetails.twitchData.displayName; + const userId = $ctrl.viewerDetails.twitchData.id; const options = viewerRolesService.getCustomRoles() - .filter(r => !r.viewers.some(v => v.toLowerCase() === username.toLowerCase())) + .filter(r => !r.viewers.some(v => v.id === userId)) .map((r) => { return { id: r.id, @@ -649,15 +649,19 @@ return; } - const username = $ctrl.viewerDetails.twitchData.displayName; + const user = { + id: $ctrl.viewerDetails.twitchData.id, + username: $ctrl.viewerDetails.twitchData.username, + displayName: $ctrl.viewerDetails.twitchData.displayName + }; - viewerRolesService.addUserToRole(roleId, username); + viewerRolesService.addViewerToRole(roleId, user); loadCustomRoles(); }); }; - $ctrl.removeUserFromRole = (roleId, roleName) => { - const username = $ctrl.viewerDetails.twitchData.displayName; + $ctrl.removeViewerFromRole = (roleId, roleName) => { + const userId = $ctrl.viewerDetails.twitchData.id; utilityService.showConfirmationModal({ title: "Remove Viewer", question: `Are you sure you want to remove the role ${roleName}?`, @@ -665,7 +669,7 @@ confirmBtnType: "btn-danger" }).then((confirmed) => { if (confirmed) { - viewerRolesService.removeUserFromRole(roleId, username); + viewerRolesService.removeViewerFromRole(roleId, userId); loadCustomRoles(); } }); @@ -695,7 +699,7 @@ const displayName = $ctrl.isTwitchOrNewUser() && $ctrl.viewerDetails.twitchData ? $ctrl.viewerDetails.twitchData.displayName : - $ctrl.viewerDetails.firebotData.username; + $ctrl.viewerDetails.firebotData.displayName; utilityService .showConfirmationModal({ diff --git a/src/gui/app/services/viewer-roles.service.js b/src/gui/app/services/viewer-roles.service.js index 5a3cb5ec9..1558e02df 100644 --- a/src/gui/app/services/viewer-roles.service.js +++ b/src/gui/app/services/viewer-roles.service.js @@ -15,14 +15,14 @@ const firebotRoleConstants = require("../../shared/firebot-roles"); let teamRoles = []; service.loadCustomRoles = async function() { - const roles = await backendCommunicator.fireEventAsync("getCustomRoles"); + const roles = await backendCommunicator.fireEventAsync("get-custom-roles"); if (roles != null) { customRoles = roles; } }; service.loadCustomRoles(); - backendCommunicator.on("custom-role-update", () => { + backendCommunicator.on("custom-roles-updated", () => { service.loadCustomRoles(); }); @@ -34,8 +34,8 @@ const firebotRoleConstants = require("../../shared/firebot-roles"); return customRoles[id]; }; - service.addUserToRole = function(roleId, username) { - if (!roleId || !username) { + service.addViewerToRole = function(roleId, viewer) { + if (!roleId || !viewer) { return; } @@ -44,16 +44,16 @@ const firebotRoleConstants = require("../../shared/firebot-roles"); return; } - if (role.viewers.some(v => v.toLowerCase() === username.toLowerCase())) { + if (role.viewers.some(v => v.id === viewer.id)) { return; } - role.viewers.push(username); + role.viewers.push(viewer); service.saveCustomRole(role); }; - service.removeUserFromRole = function(roleId, username) { - if (!roleId || !username) { + service.removeViewerFromRole = function(roleId, userId) { + if (!roleId || !userId) { return; } @@ -62,11 +62,11 @@ const firebotRoleConstants = require("../../shared/firebot-roles"); return; } - if (!role.viewers.some(v => v.toLowerCase() === username.toLowerCase())) { + if (!role.viewers.some(v => v.id === userId)) { return; } - role.viewers = role.viewers.filter(v => v.toLowerCase() !== username.toLowerCase()); + role.viewers = role.viewers.filter(v => v.id !== userId); service.saveCustomRole(role); }; @@ -76,7 +76,7 @@ const firebotRoleConstants = require("../../shared/firebot-roles"); } customRoles[role.id] = role; - backendCommunicator.fireEvent("saveCustomRole", role); + backendCommunicator.fireEvent("save-custom-role", role); }; service.deleteCustomRole = function(roleId) { @@ -85,11 +85,11 @@ const firebotRoleConstants = require("../../shared/firebot-roles"); } delete customRoles[roleId]; - backendCommunicator.fireEvent("deleteCustomRole", roleId); + backendCommunicator.fireEvent("delete-custom-role", roleId); }; service.loadTeamRoles = async function() { - teamRoles = await backendCommunicator.fireEventAsync("getTeamRoles"); + teamRoles = await backendCommunicator.fireEventAsync("get-team-roles"); }; service.loadTeamRoles(); diff --git a/src/server/api/v1/controllers/customRolesApiController.ts b/src/server/api/v1/controllers/customRolesApiController.ts index 5a503eb0d..0b7b9bb66 100644 --- a/src/server/api/v1/controllers/customRolesApiController.ts +++ b/src/server/api/v1/controllers/customRolesApiController.ts @@ -85,7 +85,11 @@ export async function addUserToCustomRole(req: Request, res: Response): Promise< }); } - customRolesManager.addViewerToRole(customRole.id, metadata.username); + customRolesManager.addViewerToRole(customRole.id, { + id: metadata._id, + username: metadata.username, + displayName: metadata.displayName + }); return res.status(201).send(); } @@ -131,7 +135,7 @@ export async function removeUserFromCustomRole(req: Request, res: Response): Pro }); } - customRolesManager.removeViewerFromRole(customRole.id, metadata.username); + customRolesManager.removeViewerFromRole(customRole.id, metadata._id); return res.status(204).send(); } \ No newline at end of file diff --git a/src/server/api/v1/controllers/viewersApiController.js b/src/server/api/v1/controllers/viewersApiController.js index 558b4cfed..8c2fa62f3 100644 --- a/src/server/api/v1/controllers/viewersApiController.js +++ b/src/server/api/v1/controllers/viewersApiController.js @@ -34,7 +34,7 @@ exports.getUserMetadata = async function(req, res) { }); } - const customRoles = customRolesManager.getAllCustomRolesForViewer(metadata.username) ?? []; + const customRoles = customRolesManager.getAllCustomRolesForViewer(metadata._id) ?? []; metadata.customRoles = customRoles; return res.json(metadata); @@ -107,7 +107,7 @@ exports.getUserCustomRoles = async function(req, res) { }); } - const customRoles = customRolesManager.getAllCustomRolesForViewer(metadata.username) ?? []; + const customRoles = customRolesManager.getAllCustomRolesForViewer(metadata._id) ?? []; return res.json(customRoles); }; diff --git a/src/types/roles.d.ts b/src/types/roles.d.ts new file mode 100644 index 000000000..bd5d05922 --- /dev/null +++ b/src/types/roles.d.ts @@ -0,0 +1,4 @@ +export interface FirebotRole { + id: string, + name: string +} \ No newline at end of file diff --git a/src/types/viewers.d.ts b/src/types/viewers.d.ts index f09cf1aff..01198db42 100644 --- a/src/types/viewers.d.ts +++ b/src/types/viewers.d.ts @@ -21,7 +21,7 @@ export interface FirebotViewer { export interface BasicViewer { id: string; username: string; - twitchRoles?: string[]; displayName?: string; + twitchRoles?: string[]; profilePicUrl?: string; } \ No newline at end of file From 06ff29d21d09ae83def751808fa9e7cbf11bdc8a Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 02:56:58 -0500 Subject: [PATCH 002/113] fix(roles): better migration logic --- src/backend/roles/custom-roles-manager.ts | 37 ++++++++++++++++++----- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/backend/roles/custom-roles-manager.ts b/src/backend/roles/custom-roles-manager.ts index daf8429dd..e098cfa63 100644 --- a/src/backend/roles/custom-roles-manager.ts +++ b/src/backend/roles/custom-roles-manager.ts @@ -60,13 +60,34 @@ class CustomRolesManager { viewers: [] }; - const users = await twitchApi.users.getUsersByNames(legacyRole.viewers); - for (const user of users) { - newCustomRole.viewers.push({ - id: user.id, - username: user.name, - displayName: user.displayName - }); + const usernameRegex = new RegExp("^[a-z0-9_]+$", "i"); + const viewersToMigrate: string[] = []; + const failedMigration: string[] = []; + + for (const viewer of legacyRole.viewers) { + if (usernameRegex.test(viewer) === true) { + viewersToMigrate.push(viewer.toLowerCase()); + } else { + failedMigration.push(viewer); + } + } + + const users = await twitchApi.users.getUsersByNames(viewersToMigrate); + for (const viewer of viewersToMigrate) { + const user = users.find(u => u.name === viewer); + if (user != null) { + newCustomRole.viewers.push({ + id: user.id, + username: user.name, + displayName: user.displayName + }); + } else { + failedMigration.push(viewer); + } + } + + if (failedMigration.length > 0) { + logger.warn(`Could not migrate the following viewers in custom role ${newCustomRole.name}: ${failedMigration.join(", ")}`); } this.saveCustomRole(newCustomRole); @@ -124,7 +145,7 @@ class CustomRolesManager { id: user.id, username: user.name, displayName: user.displayName - } + }; } this.saveCustomRole(customRole); From f7c8fb1911183bf4f4b8e12d7bec1c3143404c5b Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 10:35:43 -0500 Subject: [PATCH 003/113] feat(roles): show separate username for non-English display names in custom role mgmt --- src/gui/app/directives/modals/misc/viewer-seach-modal.js | 4 ++-- .../app/directives/modals/roles/addOrEditCustomRoleModal.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gui/app/directives/modals/misc/viewer-seach-modal.js b/src/gui/app/directives/modals/misc/viewer-seach-modal.js index 8ce900590..ca35a499f 100644 --- a/src/gui/app/directives/modals/misc/viewer-seach-modal.js +++ b/src/gui/app/directives/modals/misc/viewer-seach-modal.js @@ -18,12 +18,12 @@ - {{$select.selected.displayName}} +
{{$select.selected.displayName}} ({{$select.selected.username}})
-
{{channel.displayName}}
+
{{channel.displayName}} ({{channel.username}})
diff --git a/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js b/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js index 3ef457e7c..8f372460e 100644 --- a/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js +++ b/src/gui/app/directives/modals/roles/addOrEditCustomRoleModal.js @@ -38,7 +38,7 @@
-
{{viewer.displayName}}
+
{{viewer.displayName}} ({{viewer.username}})
From d96198744c95f87beb4c915348a7081dee24fd33 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 11:27:24 -0500 Subject: [PATCH 004/113] fix(roles): check user login name for role check before falling back to display name --- .../events/filters/builtin/viewer-roles.js | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/src/backend/events/filters/builtin/viewer-roles.js b/src/backend/events/filters/builtin/viewer-roles.js index 90880b51e..784b743c4 100644 --- a/src/backend/events/filters/builtin/viewer-roles.js +++ b/src/backend/events/filters/builtin/viewer-roles.js @@ -28,7 +28,7 @@ module.exports = { ], comparisonTypes: ["include", "doesn't include"], valueType: "preset", - presetValues: viewerRolesService => { + presetValues: (viewerRolesService) => { return viewerRolesService .getCustomRoles() .concat(viewerRolesService.getTwitchRoles()) @@ -59,47 +59,53 @@ module.exports = { return filterSettings.value; }, - predicate: async (filterSettings, eventData) => { + predicate: async (filterSettings, eventData) => { const { comparisonType, value } = filterSettings; const { eventMeta } = eventData; - const username = eventMeta.username; - if (username == null || username === "") { - return false; - } - - const user = await twitchApi.users.getUserByName(username); - if (user == null) { + const { username, userIdName } = eventMeta; + if (!username && !userIdName) { return false; } - /** @type {string[]} */ - let twitchUserRoles = eventMeta.twitchUserRoles; - if (twitchUserRoles == null) { - twitchUserRoles = await chatRolesManager.getUsersChatRoles(user.id); - } - - const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(user.id) || []; - const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(user.id) || []; - const userTwitchRoles = (twitchUserRoles || []) - .map(twitchRolesManager.mapTwitchRole); - - const allRoles = [ - ...userTwitchRoles, - ...userTeamRoles, - ...userCustomRoles - ].filter(r => r != null); - - const hasRole = allRoles.some(r => r.id === value); - - switch (comparisonType) { - case "include": - return hasRole; - case "doesn't include": - return !hasRole; - default: + try { + const user = await twitchApi.users.getUserByName(userIdName ?? username.toLowerCase()); + if (user == null) { return false; + } + + /** @type {string[]} */ + let twitchUserRoles = eventMeta.twitchUserRoles; + if (twitchUserRoles == null) { + twitchUserRoles = await chatRolesManager.getUsersChatRoles(user.id); + } + + const userCustomRoles = customRolesManager.getAllCustomRolesForViewer(user.id) || []; + const userTeamRoles = await teamRolesManager.getAllTeamRolesForViewer(user.id) || []; + const userTwitchRoles = (twitchUserRoles || []) + .map(twitchRolesManager.mapTwitchRole); + + const allRoles = [ + ...userTwitchRoles, + ...userTeamRoles, + ...userCustomRoles + ].filter(r => r != null); + + const hasRole = allRoles.some(r => r.id === value); + + switch (comparisonType) { + case "include": + return hasRole; + case "doesn't include": + return !hasRole; + default: + return false; + } + } catch { + // Silently fail } + + return false; } }; \ No newline at end of file From a17d79ace16a7114ed8cf717a64ac35310754434 Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk Date: Tue, 20 Feb 2024 17:27:56 +0100 Subject: [PATCH 005/113] fix(variables): textPadStart and textPadEnd usages (#2416) --- src/backend/variables/builtin/text/text-pad-end.ts | 2 +- src/backend/variables/builtin/text/text-pad-start.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/variables/builtin/text/text-pad-end.ts b/src/backend/variables/builtin/text/text-pad-end.ts index bd665ad44..6fb8f48fa 100644 --- a/src/backend/variables/builtin/text/text-pad-end.ts +++ b/src/backend/variables/builtin/text/text-pad-end.ts @@ -5,7 +5,7 @@ const model : ReplaceVariable = { definition: { handle: "textPadEnd", description: "Pads the end of text", - usage: "replace[input, count, countIsLength, padChar]", + usage: "textPadEnd[input, count, countIsLength, padChar]", examples: [ { usage: "textPadEnd[input, count, $false, \" \"]", diff --git a/src/backend/variables/builtin/text/text-pad-start.ts b/src/backend/variables/builtin/text/text-pad-start.ts index 96cfed00f..1fb57376a 100644 --- a/src/backend/variables/builtin/text/text-pad-start.ts +++ b/src/backend/variables/builtin/text/text-pad-start.ts @@ -5,7 +5,7 @@ const model : ReplaceVariable = { definition: { handle: "textPadStart", description: "Pads the start of text", - usage: "replace[input, count, countIsLength, padChar]", + usage: "textPadStart[input, count, countIsLength, padChar]", examples: [ { usage: "textPadStart[input, count, $false, \" \"]", From c81ea204cc88770d7e6cad99593ae478fd415baa Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 12:14:32 -0500 Subject: [PATCH 006/113] feat: show Unicode display name separately in most instances --- .../events/builtin/twitch-event-source.js | 72 ++++++++++++++----- src/gui/app/controllers/viewers.controller.js | 2 +- .../chat/feed items/chat-message.js | 12 +++- .../modals/viewers/viewerDetailsModal.js | 5 +- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/backend/events/builtin/twitch-event-source.js b/src/backend/events/builtin/twitch-event-source.js index 038e247fd..31143b85c 100644 --- a/src/backend/events/builtin/twitch-event-source.js +++ b/src/backend/events/builtin/twitch-event-source.js @@ -18,7 +18,9 @@ module.exports = { activityFeed: { icon: "fad fa-siren-on", getMessage: (eventData) => { - return `**${eventData.username}** raided with **${eventData.viewerCount}** viewer(s)`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** raided with **${eventData.viewerCount}** viewer(s)`; } } }, @@ -34,7 +36,9 @@ module.exports = { activityFeed: { icon: "fas fa-heart", getMessage: (eventData) => { - return `**${eventData.username}** followed`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** followed`; } } }, @@ -64,7 +68,9 @@ module.exports = { activityFeed: { icon: "fas fa-star", getMessage: (eventData) => { - return `**${eventData.username}** ${eventData.isResub ? 'resubscribed' : 'subscribed'} for **${eventData.totalMonths} month(s)** ${eventData.subPlan === 'Prime' ? + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** ${eventData.isResub ? 'resubscribed' : 'subscribed'} for **${eventData.totalMonths} month(s)** ${eventData.subPlan === 'Prime' ? "with **Twitch Prime**" : `at **Tier ${eventData.subPlan.replace("000", "")}**`}`; } } @@ -89,7 +95,9 @@ module.exports = { activityFeed: { icon: "fas fa-star", getMessage: (eventData) => { - return `**${eventData.username}** upgraded their Prime sub at **Tier ${eventData.subPlan.replace("000", "")}!**`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** upgraded their Prime sub at **Tier ${eventData.subPlan.replace("000", "")}!**`; } } }, @@ -179,7 +187,9 @@ module.exports = { activityFeed: { icon: "fas fa-star", getMessage: (eventData) => { - return `**${eventData.username}** upgraded their gift sub at **Tier ${eventData.subPlan.replace("000", "")}!**`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** upgraded their gift sub at **Tier ${eventData.subPlan.replace("000", "")}!**`; } } }, @@ -198,7 +208,9 @@ module.exports = { activityFeed: { icon: "fad fa-diamond", getMessage: (eventData) => { - return `**${eventData.username}** cheered **${eventData.bits}** bits. A total of **${eventData.totalBits}** were cheered by **${eventData.username}** in the channel.`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** cheered **${eventData.bits}** bits. They have cheered a total of **${eventData.totalBits}** in the channel.`; } } }, @@ -248,7 +260,9 @@ module.exports = { activityFeed: { icon: "fad fa-diamond", getMessage: (eventData) => { - return `**${eventData.username}** unlocked the **${eventData.badgeTier}** bits badge in your channel!`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** unlocked the **${eventData.badgeTier}** bits badge in your channel!`; } } }, @@ -264,7 +278,9 @@ module.exports = { activityFeed: { icon: "fad fa-house-return", getMessage: (eventData) => { - return `**${eventData.username}** arrived`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** arrived`; } } }, @@ -292,7 +308,9 @@ module.exports = { activityFeed: { icon: "fad fa-sparkles", getMessage: (eventData) => { - return `**${eventData.username}** has chatted in your channel for the very first time`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** has chatted in your channel for the very first time`; } } }, @@ -322,10 +340,12 @@ module.exports = { icon: "fad fa-gavel", getMessage: (eventData) => { let message; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); if (eventData.modReason) { - message = `**${eventData.username}** was banned by **${eventData.moderator}**. Reason: **${eventData.modReason}**`; + message = `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** was banned by **${eventData.moderator}**. Reason: **${eventData.modReason}**`; } else { - message = `**${eventData.username}** was banned by **${eventData.moderator}**.`; + message = `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** was banned by **${eventData.moderator}**.`; } return message; } @@ -344,7 +364,9 @@ module.exports = { activityFeed: { icon: "fad fa-gavel", getMessage: (eventData) => { - return `**${eventData.username}** was unbanned by **${eventData.moderator}**.`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** was unbanned by **${eventData.moderator}**.`; } } }, @@ -363,7 +385,15 @@ module.exports = { activityFeed: { icon: "fad fa-stopwatch", getMessage: (eventData) => { - return `**${eventData.username}** was timed out for **${eventData.timeoutDuration} sec(s)** by ${eventData.moderator}. Reason: **${eventData.modReason}**`; + let message; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + if (eventData.modReason) { + message = `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** was timed out for **${eventData.timeoutDuration} sec(s)** by ${eventData.moderator}. Reason: **${eventData.modReason}**`; + } else { + message = `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** was timed out for **${eventData.timeoutDuration} sec(s)** by ${eventData.moderator}.`; + } + return message; } } }, @@ -385,7 +415,9 @@ module.exports = { activityFeed: { icon: "fad fa-circle", getMessage: (eventData) => { - return `**${eventData.username}** redeemed **${eventData.rewardName}**${eventData.messageText && !!eventData.messageText.length ? `: *${eventData.messageText}*` : ''}`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** redeemed **${eventData.rewardName}**${eventData.messageText && !!eventData.messageText.length ? `: *${eventData.messageText}*` : ''}`; } } }, @@ -409,7 +441,9 @@ module.exports = { activityFeed: { icon: "fad fa-comment-alt", getMessage: (eventData) => { - return `**${eventData.username}** sent your **${eventData.sentTo}** account the following whisper: ${eventData.message}`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** sent your **${eventData.sentTo}** account the following whisper: ${eventData.message}`; } } }, @@ -815,7 +849,9 @@ module.exports = { activityFeed: { icon: "fad fa-bullhorn", getMessage: (eventData) => { - return `**${eventData.moderator}** sent a shoutout to **${eventData.username}**`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.moderator}** sent a shoutout to **${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}**`; } } }, @@ -832,7 +868,9 @@ module.exports = { activityFeed: { icon: "fad fa-bullhorn", getMessage: (eventData) => { - return `**${eventData.username}** shouted out your channel to ${eventData.viewerCount} viewers`; + const showUserIdName = eventData.userIdName + && eventData.username.toLowerCase() !== eventData.userIdName.toLowerCase(); + return `**${eventData.username}${showUserIdName ? ` (${eventData.userIdName})` : ""}** shouted out your channel to ${eventData.viewerCount} viewers`; } } }, diff --git a/src/gui/app/controllers/viewers.controller.js b/src/gui/app/controllers/viewers.controller.js index 2ca998ce8..41efdc2bf 100644 --- a/src/gui/app/controllers/viewers.controller.js +++ b/src/gui/app/controllers/viewers.controller.js @@ -74,7 +74,7 @@ 'min-width': '125px' }, sortable: true, - cellTemplate: `{{data.displayName || data.username}}`, + cellTemplate: `{{data.displayName || data.username}} ({{data.username}})`, cellController: () => {} }, { diff --git a/src/gui/app/directives/chat/feed items/chat-message.js b/src/gui/app/directives/chat/feed items/chat-message.js index e1cb2c443..8c4645dbf 100644 --- a/src/gui/app/directives/chat/feed items/chat-message.js +++ b/src/gui/app/directives/chat/feed items/chat-message.js @@ -120,6 +120,12 @@ ng-show="$ctrl.showPronoun && $ctrl.pronouns.pronounCache[$ctrl.message.username] != null" >{{$ctrl.pronouns.pronounCache[$ctrl.message.username]}} {{$ctrl.message.username}} +  ({{$ctrl.message.userIdName}}) `, enabled: false }, - ...actions.map(a => { + ...actions.map((a) => { let html = ""; if (a.name === "Remove VIP") { html = ` @@ -409,7 +415,7 @@ confirmLabel: "Ban", confirmBtnType: "btn-danger" }) - .then(confirmed => { + .then((confirmed) => { if (confirmed) { backendCommunicator.fireEvent("update-user-banned-status", { username: userName, shouldBeBanned: true }); } @@ -426,7 +432,7 @@ confirmLabel: "Unmod", confirmBtnType: "btn-danger" }) - .then(confirmed => { + .then((confirmed) => { if (confirmed) { chatMessagesService.changeModStatus(userName, false); } diff --git a/src/gui/app/directives/modals/viewers/viewerDetailsModal.js b/src/gui/app/directives/modals/viewers/viewerDetailsModal.js index 564830ff0..f35ed2414 100644 --- a/src/gui/app/directives/modals/viewers/viewerDetailsModal.js +++ b/src/gui/app/directives/modals/viewers/viewerDetailsModal.js @@ -35,7 +35,10 @@
-
+
+
{{$ctrl.viewerDetails.twitchData.username}}
+
+
{{$ctrl.getAccountAge($ctrl.viewerDetails.twitchData.creationDate)}}
From 4dc76b8d7ba8bda71a98d5e4ee838043a52086f1 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 12:37:59 -0500 Subject: [PATCH 007/113] feat: Unicode username support in chatter list/autocomplete --- src/backend/chat/chat-listeners/active-user-handler.js | 2 +- .../app/directives/chat/chat-autocomplete-menu.component.js | 4 +++- src/gui/app/directives/chat/chat-user-category.js | 2 +- src/gui/scss/core/_chat.scss | 3 +++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/backend/chat/chat-listeners/active-user-handler.js b/src/backend/chat/chat-listeners/active-user-handler.js index 196cb3bbc..168f11298 100644 --- a/src/backend/chat/chat-listeners/active-user-handler.js +++ b/src/backend/chat/chat-listeners/active-user-handler.js @@ -156,7 +156,7 @@ async function updateUserOnlineStatus(userDetails, updateDb = false) { frontendCommunicator.send("twitch:chat:user-joined", { id: userDetails.id, - username: userDetails.displayName, + username: userDetails.username, displayName: userDetails.displayName, roles: roles, profilePicUrl: userDetails.profilePicUrl, diff --git a/src/gui/app/directives/chat/chat-autocomplete-menu.component.js b/src/gui/app/directives/chat/chat-autocomplete-menu.component.js index 773970df3..3e6cd8e14 100644 --- a/src/gui/app/directives/chat/chat-autocomplete-menu.component.js +++ b/src/gui/app/directives/chat/chat-autocomplete-menu.component.js @@ -248,7 +248,9 @@ function buildChatUserItems() { return chatMessagesService.chatUsers.map(user => ({ - display: user.username, + display: user.username.toLowerCase() !== user.displayName.toLowerCase() + ? `${user.displayName} (${user.username})` + : user.displayName, text: `@${user.username}` })); } diff --git a/src/gui/app/directives/chat/chat-user-category.js b/src/gui/app/directives/chat/chat-user-category.js index 94bdd8fa3..66d764b94 100644 --- a/src/gui/app/directives/chat/chat-user-category.js +++ b/src/gui/app/directives/chat/chat-user-category.js @@ -30,7 +30,7 @@ class="chat-user-name clickable" ng-click="showUserDetailsModal(user.id)" > - {{user.username}} + {{user.displayName}} ({{user.username}})
diff --git a/src/gui/scss/core/_chat.scss b/src/gui/scss/core/_chat.scss index 51ac0580c..cc297193a 100644 --- a/src/gui/scss/core/_chat.scss +++ b/src/gui/scss/core/_chat.scss @@ -647,6 +647,9 @@ } .chat-user-name { color: #b381ff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &:hover { text-decoration: underline; cursor: pointer; From 2f79769f8a657c96059e79f40a329db19cae27c3 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 13:23:07 -0500 Subject: [PATCH 008/113] chore(vars): wrap role vars in try-catch to account for user lookup failure --- .../variables/builtin/user/roles/has-role.ts | 16 +++-- .../variables/builtin/user/roles/has-roles.ts | 30 +++++---- .../builtin/user/roles/user-roles.ts | 67 ++++++++++--------- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/backend/variables/builtin/user/roles/has-role.ts b/src/backend/variables/builtin/user/roles/has-role.ts index 7bbe249b4..c5246f420 100644 --- a/src/backend/variables/builtin/user/roles/has-role.ts +++ b/src/backend/variables/builtin/user/roles/has-role.ts @@ -31,12 +31,18 @@ const model : ReplaceVariable = { return false; } - const user = await twitchApi.users.getUserByName(username); - if (user == null) { - return false; - } + try { + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return false; + } - return await roleHelpers.viewerHasRoleByName(user.id, role); + return await roleHelpers.viewerHasRoleByName(user.id, role); + } catch { + // Silently fail + } + + return false; } }; export default model; \ No newline at end of file diff --git a/src/backend/variables/builtin/user/roles/has-roles.ts b/src/backend/variables/builtin/user/roles/has-roles.ts index 69e9d63a0..0862a75cc 100644 --- a/src/backend/variables/builtin/user/roles/has-roles.ts +++ b/src/backend/variables/builtin/user/roles/has-roles.ts @@ -50,20 +50,26 @@ const model : ReplaceVariable = { return false; } - const user = await twitchApi.users.getUserByName(username); - if (user == null) { - return false; - } - - const userRoles = await roleHelpers.getAllRolesForViewer(user.id); - - // any - if (respective === "any") { - return userRoles.some(r => roles.includes(r.name)); + try { + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return false; + } + + const userRoles = await roleHelpers.getAllRolesForViewer(user.id); + + // any + if (respective === "any") { + return userRoles.some(r => roles.includes(r.name)); + } + + // all + return roles.length === userRoles.filter(r => roles.includes(r.name)).length; + } catch { + // Silently fail } - // all - return roles.length === userRoles.filter(r => roles.includes(r.name)).length; + return false; } }; diff --git a/src/backend/variables/builtin/user/roles/user-roles.ts b/src/backend/variables/builtin/user/roles/user-roles.ts index 0936ead5d..8624b36a9 100644 --- a/src/backend/variables/builtin/user/roles/user-roles.ts +++ b/src/backend/variables/builtin/user/roles/user-roles.ts @@ -68,39 +68,44 @@ const model : ReplaceVariable = { roleType = (`${roleType}`).toLowerCase(); } - const user = await twitchApi.users.getUserByName(username); - if (user == null) { - return []; + try { + const user = await twitchApi.users.getUserByName(username); + if (user == null) { + return []; + } + + const userRoles = await roleHelpers.getAllRolesForViewerNameSpaced(user.id); + + Object + .keys(userRoles) + .forEach((key: string) => { + userRoles[key] = userRoles[key].map(r => r.name); + }); + + if (roleType === "all") { + return [ + userRoles.twitchRoles || [], + userRoles.teamRoles || [], + userRoles.firebotRoles || [], + userRoles.customRoles || [] + ]; + } + if (roleType === "twitch") { + return userRoles.twitchRoles; + } + if (roleType === "team") { + return userRoles.teamRoles; + } + if (roleType === "firebot") { + return userRoles.firebotRoles; + } + if (roleType === "custom") { + return userRoles.customRoles; + } + } catch { + // Silently fail } - const userRoles = await roleHelpers.getAllRolesForViewerNameSpaced(user.id); - - Object - .keys(userRoles) - .forEach((key: string) => { - userRoles[key] = userRoles[key].map(r => r.name); - }); - - if (roleType === "all") { - return [ - userRoles.twitchRoles || [], - userRoles.teamRoles || [], - userRoles.firebotRoles || [], - userRoles.customRoles || [] - ]; - } - if (roleType === "twitch") { - return userRoles.twitchRoles; - } - if (roleType === "team") { - return userRoles.teamRoles; - } - if (roleType === "firebot") { - return userRoles.firebotRoles; - } - if (roleType === "custom") { - return userRoles.customRoles; - } return []; } }; From eeff65d333e9056ae3c68287ff33257fd88e83a1 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 18:29:40 -0500 Subject: [PATCH 009/113] feat(rewards): backend, partial frontend of reward redemption mgmt --- .../channel-rewards/channel-reward-manager.ts | 121 +++++++++----- ...pprove-reject-channel-reward-redemption.ts | 12 +- .../twitch-api/resource/channel-rewards.ts | 104 +++++++++++- src/gui/app/app-main.js | 8 + .../controllers/channel-rewards.controller.js | 6 +- .../app/services/channel-rewards.service.js | 57 +++++-- src/gui/app/templates/_channel-rewards.html | 152 ++++++++++++------ 7 files changed, 348 insertions(+), 112 deletions(-) diff --git a/src/backend/channel-rewards/channel-reward-manager.ts b/src/backend/channel-rewards/channel-reward-manager.ts index efb9eb7e7..72a68a288 100644 --- a/src/backend/channel-rewards/channel-reward-manager.ts +++ b/src/backend/channel-rewards/channel-reward-manager.ts @@ -4,13 +4,69 @@ import accountAccess from "../common/account-access"; import profileManager from "../common/profile-manager"; import frontendCommunicator from "../common/frontend-communicator"; import twitchApi from "../twitch-api/api"; -import { CustomReward } from "../twitch-api/resource/channel-rewards"; +import { CustomReward, RewardRedemption, RewardRedemptionApprovalRequest } from "../twitch-api/resource/channel-rewards"; import { EffectTrigger } from "../../shared/effect-constants"; import { RewardRedemptionMetadata, SavedChannelReward } from "../../types/channel-rewards"; class ChannelRewardManager { channelRewards: Record = {}; + private _channelRewardRedemptions: Record = {}; + + constructor() { + frontendCommunicator.onAsync("get-channel-reward-count", + twitchApi.channelRewards.getTotalChannelRewardCount); + + frontendCommunicator.onAsync("get-channel-rewards", async () => Object.values(this.channelRewards)); + + frontendCommunicator.onAsync("save-channel-reward", + (channelReward: SavedChannelReward) => this.saveChannelReward(channelReward)); + + frontendCommunicator.onAsync("save-all-channel-rewards", + async (data: { channelRewards: SavedChannelReward[]; updateTwitch: boolean}) => + await this.saveAllChannelRewards(data.channelRewards, data.updateTwitch)); + + frontendCommunicator.onAsync("sync-channel-rewards", async (): Promise => { + await this.loadChannelRewards(); + return Object.values(this.channelRewards); + }); + + frontendCommunicator.onAsync("delete-channel-reward", async (channelRewardId: string) => { + await this.deleteChannelReward(channelRewardId); + }); + + frontendCommunicator.on("manually-trigger-reward", (channelRewardId: string) => { + const savedReward = this.channelRewards[channelRewardId]; + + if (savedReward == null) { + return; + } + + const accountAccess = require("../common/account-access"); + + this.triggerChannelReward(channelRewardId, { + messageText: "Testing reward", + redemptionId: "test-redemption-id", + rewardId: savedReward.id, + rewardCost: savedReward.twitchData.cost, + rewardImage: savedReward.twitchData.image ? savedReward.twitchData.image.url4x : savedReward.twitchData.defaultImage.url4x, + rewardName: savedReward.twitchData.title, + username: accountAccess.getAccounts().streamer.displayName + }, true); + }); + + frontendCommunicator.onAsync("refresh-channel-reward-redemptions", async () => { + await this.refreshChannelRewardRedemptions(); + }); + + frontendCommunicator.onAsync("approve-reject-channel-reward-redemption", async (request: RewardRedemptionApprovalRequest) => { + await this.approveOrRejectChannelRewardRedemption(request); + }); + + frontendCommunicator.onAsync("approve-reject-channel-reward-all-redemptions", async (request: RewardRedemptionApprovalRequest) => { + await this.approveOrRejectAllRedemptionsForChannelReward(request); + }); + } getChannelRewardsDb(): JsonDB { return profileManager @@ -40,7 +96,7 @@ class ChannelRewardManager { // Determine new manageable rewards const newManageableChannelRewards = twitchManageableRewards .filter(nr => rewards.every(r => r.id !== nr.id)) - .map(nr => { + .map((nr) => { return { id: nr.id, manageable: true, @@ -59,7 +115,7 @@ class ChannelRewardManager { // Determine new unmanageable rewards const newTwitchUnmanageableRewards: SavedChannelReward[] = twitchUnmanageableRewards .filter(ur => rewards.every(r => r.id !== ur.id)) - .map(ur => { + .map((ur) => { return { id: ur.id, manageable: false, @@ -68,7 +124,7 @@ class ChannelRewardManager { }); // Sync current reward Twitch data/manageability status, remove deleted rewards, then add new rewards - const syncedRewards: Record = rewards.map(r => { + const syncedRewards: Record = rewards.map((r) => { const rewardTwitchData = twitchManageableRewards.find(tc => tc.id === r.id); // If we have a match, this is a manageable reward @@ -225,49 +281,38 @@ class ChannelRewardManager { console.log(`error when running effects: ${reason}`); } } -} -const channelRewardManager = new ChannelRewardManager(); - -frontendCommunicator.onAsync("getChannelRewardCount", - twitchApi.channelRewards.getTotalChannelRewardCount); + async refreshChannelRewardRedemptions(): Promise { + if (accountAccess.getAccounts().streamer.broadcasterType === "") { + return; + } -frontendCommunicator.onAsync("getChannelRewards", async () => Object.values(channelRewardManager.channelRewards)); + this._channelRewardRedemptions = await twitchApi.channelRewards.getOpenChannelRewardRedemptions(); -frontendCommunicator.onAsync("saveChannelReward", - (channelReward: SavedChannelReward) => channelRewardManager.saveChannelReward(channelReward)); + frontendCommunicator.send("channel-reward-redemptions-updated", this.getChannelRewardRedemptions()); + } -frontendCommunicator.onAsync("saveAllChannelRewards", - async (data: { channelRewards: SavedChannelReward[]; updateTwitch: boolean}) => - await channelRewardManager.saveAllChannelRewards(data.channelRewards, data.updateTwitch)); + getChannelRewardRedemptions(): Record { + return this._channelRewardRedemptions ?? {}; + } -frontendCommunicator.onAsync("syncChannelRewards", async (): Promise => { - await channelRewardManager.loadChannelRewards(); - return Object.values(channelRewardManager.channelRewards); -}); + async approveOrRejectChannelRewardRedemption(request: RewardRedemptionApprovalRequest): Promise { + const result = await twitchApi.channelRewards.approveOrRejectChannelRewardRedemption(request); -frontendCommunicator.onAsync("deleteChannelReward", async (channelRewardId: string) => { - await channelRewardManager.deleteChannelReward(channelRewardId); -}); + if (result === true) { + await this.refreshChannelRewardRedemptions(); + } + } -frontendCommunicator.on("manuallyTriggerReward", (channelRewardId: string) => { - const savedReward = channelRewardManager.channelRewards[channelRewardId]; + async approveOrRejectAllRedemptionsForChannelReward(request: RewardRedemptionApprovalRequest): Promise { + const result = await twitchApi.channelRewards.approveOrRejectAllRedemptionsForChannelReward(request); - if (savedReward == null) { - return; + if (result === true) { + await this.refreshChannelRewardRedemptions(); + } } +} - const accountAccess = require("../common/account-access"); - - channelRewardManager.triggerChannelReward(channelRewardId, { - messageText: "Testing reward", - redemptionId: "test-redemption-id", - rewardId: savedReward.id, - rewardCost: savedReward.twitchData.cost, - rewardImage: savedReward.twitchData.image ? savedReward.twitchData.image.url4x : savedReward.twitchData.defaultImage.url4x, - rewardName: savedReward.twitchData.title, - username: accountAccess.getAccounts().streamer.displayName - }, true); -}); +const channelRewardManager = new ChannelRewardManager(); export = channelRewardManager; \ No newline at end of file diff --git a/src/backend/effects/builtin/twitch/approve-reject-channel-reward-redemption.ts b/src/backend/effects/builtin/twitch/approve-reject-channel-reward-redemption.ts index 64a4ef784..97a7fae8b 100644 --- a/src/backend/effects/builtin/twitch/approve-reject-channel-reward-redemption.ts +++ b/src/backend/effects/builtin/twitch/approve-reject-channel-reward-redemption.ts @@ -1,6 +1,6 @@ import { EffectType } from "../../../../types/effects"; import { EffectCategory } from "../../../../shared/effect-constants"; -import twitchApi from "../../../twitch-api/api"; +import channelRewardManager from "../../../channel-rewards/channel-reward-manager"; const model: EffectType<{ rewardId: string; @@ -63,11 +63,11 @@ const model: EffectType<{ }, optionsController: () => {}, onTriggerEvent: async ({ effect }) => { - return await twitchApi.channelRewards.approveOrRejectChannelRewardRedemption( - effect.rewardId, - effect.redemptionId, - effect.approve - ); + return await channelRewardManager.approveOrRejectChannelRewardRedemption({ + rewardId: effect.rewardId, + redemptionId: effect.redemptionId, + approve: effect.approve + }); } }; diff --git a/src/backend/twitch-api/resource/channel-rewards.ts b/src/backend/twitch-api/resource/channel-rewards.ts index 08847e8b5..386acbf1c 100644 --- a/src/backend/twitch-api/resource/channel-rewards.ts +++ b/src/backend/twitch-api/resource/channel-rewards.ts @@ -1,6 +1,13 @@ import logger from "../../logwrapper"; import accountAccess from "../../common/account-access"; -import { ApiClient, HelixCustomReward, HelixCreateCustomRewardData, HelixUpdateCustomRewardData } from "@twurple/api"; +import { + ApiClient, + HelixCustomReward, + HelixCreateCustomRewardData, + HelixUpdateCustomRewardData, + HelixCustomRewardRedemption, + HelixCustomRewardRedemptionFilter +} from "@twurple/api"; export interface ImageSet { url1x: string; @@ -40,6 +47,22 @@ export interface CustomReward { cooldownExpiresAt?: Date; } +export interface RewardRedemption { + id: string; + rewardId: string; + redemptionDate: Date; + userId: string; + userName: string; + userDisplayName: string; + rewardMessage?: string; +} + +export interface RewardRedemptionApprovalRequest { + rewardId: string; + redemptionId?: string; + approve?: boolean; +} + export class TwitchChannelRewardsApi { private _streamerClient: ApiClient; private _botClient: ApiClient; @@ -147,6 +170,18 @@ export class TwitchChannelRewardsApi { }; } + private mapCustomRewardRedemptionResponse(redemption: HelixCustomRewardRedemption): RewardRedemption { + return { + id: redemption.id, + rewardId: redemption.rewardId, + redemptionDate: redemption.redemptionDate, + userId: redemption.userId, + userName: redemption.userName, + userDisplayName: redemption.userDisplayName, + rewardMessage: redemption.userInput + }; + } + /** * Get an array of custom channel rewards * @param {boolean} onlyManageable - Only get rewards manageable by Firebot @@ -250,16 +285,43 @@ export class TwitchChannelRewardsApi { } } - async approveOrRejectChannelRewardRedemption(rewardId: string, redemptionId: string, approve = true): Promise { + async getOpenChannelRewardRedemptions(): Promise> { + const redemptions: Record = {}; + + try { + const rewards = await this.getManageableCustomChannelRewards(); + const filter: HelixCustomRewardRedemptionFilter = { + newestFirst: true + }; + + for (const reward of rewards) { + const response = await this._streamerClient.channelPoints.getRedemptionsForBroadcasterPaginated( + accountAccess.getAccounts().streamer.userId, + reward.id, + "UNFULFILLED", + filter + ).getAll(); + + redemptions[reward.id] = response.map(r => this.mapCustomRewardRedemptionResponse(r)); + } + } catch (error) { + logger.warn(`There was an error retrieving channel reward redemptions.`, error); + } + + return redemptions; + } + + async approveOrRejectChannelRewardRedemption(request: RewardRedemptionApprovalRequest): Promise { + const approve = request?.approve ?? true; try { const response = await this._streamerClient.channelPoints.updateRedemptionStatusByIds( accountAccess.getAccounts().streamer.userId, - rewardId, - [redemptionId], + request.rewardId, + [request.redemptionId], approve ? "FULFILLED" : "CANCELED" ); - logger.debug(`Redemption ${redemptionId} for channel reward ${rewardId} was ${response[0].isFulfilled ? "approved" : "rejected"}`); + logger.debug(`Redemption ${request.redemptionId} for channel reward ${request.rewardId} was ${response[0].isFulfilled ? "approved" : "rejected"}`); return true; } catch (error) { @@ -267,4 +329,36 @@ export class TwitchChannelRewardsApi { return false; } } + + async approveOrRejectAllRedemptionsForChannelReward(request: RewardRedemptionApprovalRequest): Promise { + const approve = request?.approve ?? true; + try { + const filter: HelixCustomRewardRedemptionFilter = { + newestFirst: true + }; + + const redemptions = await this._streamerClient.channelPoints.getRedemptionsForBroadcasterPaginated( + accountAccess.getAccounts().streamer.userId, + request.rewardId, + "UNFULFILLED", + filter + ).getAll(); + + for (const redemption of redemptions) { + if (await this.approveOrRejectChannelRewardRedemption({ + rewardId: redemption.rewardId, + redemptionId: redemption.id, + approve: approve + }) !== true) { + logger.warn(`Could not complete ${approve ? "approving" : "rejecting"} all channel reward redemptions for ${request.rewardId}`); + return false; + } + } + + return true; + } catch (error) { + logger.error(`Failed to ${approve ? "approve" : "reject"} all channel reward redemptions for ${request.rewardId}`, error.message); + return false; + } + } } \ No newline at end of file diff --git a/src/gui/app/app-main.js b/src/gui/app/app-main.js index c3170f6f4..09ce9cd20 100644 --- a/src/gui/app/app-main.js +++ b/src/gui/app/app-main.js @@ -157,6 +157,7 @@ effectQueuesService.loadEffectQueues(); channelRewardsService.loadChannelRewards(); + channelRewardsService.refreshChannelRewardRedemptions(); sortTagsService.loadSortTags(); @@ -616,6 +617,13 @@ }; }); + app.filter('timeAgo', function() { + return function(input, unit) { + const count = moment().diff(moment(input), unit); + return `${count} ${count === 1 ? unit.slice(0, -1) : unit} ago`; + }; + }); + app.filter('commify', function() { return function(input) { return input ? input.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : ""; diff --git a/src/gui/app/controllers/channel-rewards.controller.js b/src/gui/app/controllers/channel-rewards.controller.js index 73999e96e..0eee5bc5a 100644 --- a/src/gui/app/controllers/channel-rewards.controller.js +++ b/src/gui/app/controllers/channel-rewards.controller.js @@ -10,6 +10,8 @@ ) { $scope.channelRewardsService = channelRewardsService; + $scope.activeChannelRewardTab = 0; + $scope.canUseChannelRewards = () => accountAccess.accounts["streamer"].loggedIn && (accountAccess.accounts["streamer"].broadcasterType === "affiliate" || accountAccess.accounts["streamer"].broadcasterType === "partner"); @@ -21,7 +23,7 @@ channelRewardsService.saveAllRewards(items); }; - $scope.headers = [ + $scope.rewardHeaders = [ { headerStyles: { 'width': '50px' @@ -98,7 +100,7 @@ confirmLabel: "Delete", confirmBtnType: "btn-danger" }) - .then(confirmed => { + .then((confirmed) => { if (confirmed) { channelRewardsService.deleteChannelReward(item.id); } diff --git a/src/gui/app/services/channel-rewards.service.js b/src/gui/app/services/channel-rewards.service.js index 158c1d8f2..e34a9f754 100644 --- a/src/gui/app/services/channel-rewards.service.js +++ b/src/gui/app/services/channel-rewards.service.js @@ -9,6 +9,7 @@ const service = {}; service.channelRewards = []; + service.redemptions = []; service.selectedSortTag = null; @@ -24,8 +25,8 @@ } service.loadChannelRewards = () => { - $q.when(backendCommunicator.fireEventAsync("getChannelRewards")) - .then(channelRewards => { + $q.when(backendCommunicator.fireEventAsync("get-channel-rewards")) + .then((channelRewards) => { if (channelRewards) { service.channelRewards = channelRewards; } @@ -33,8 +34,8 @@ }; service.saveChannelReward = (channelReward) => { - return $q.when(backendCommunicator.fireEventAsync("saveChannelReward", channelReward)) - .then(savedReward => { + return $q.when(backendCommunicator.fireEventAsync("save-channel-reward", channelReward)) + .then((savedReward) => { if (savedReward) { updateChannelReward(savedReward); return true; @@ -45,7 +46,7 @@ service.saveAllRewards = (channelRewards, updateTwitch = false) => { service.channelRewards = channelRewards; - backendCommunicator.fireEvent("saveAllChannelRewards", { + backendCommunicator.fireEvent("save-all-channel-rewards", { updateTwitch: updateTwitch, channelRewards: channelRewards }); @@ -53,7 +54,7 @@ service.deleteChannelReward = (channelRewardId) => { service.channelRewards = service.channelRewards.filter(cr => cr.id !== channelRewardId); - backendCommunicator.fireEvent("deleteChannelReward", channelRewardId); + backendCommunicator.fireEvent("delete-channel-reward", channelRewardId); }; service.showAddOrEditRewardModal = (reward) => { @@ -68,7 +69,7 @@ }; service.manuallyTriggerReward = (itemId) => { - backendCommunicator.fireEvent("manuallyTriggerReward", itemId); + backendCommunicator.fireEvent("manually-trigger-reward", itemId); }; service.channelRewardNameExists = (name) => { @@ -95,7 +96,7 @@ copiedReward.twitchData.title = copiedReward.twitchData.title.substring(0, 45); - service.saveChannelReward(copiedReward).then(successful => { + service.saveChannelReward(copiedReward).then((successful) => { if (successful) { ngToast.create({ className: 'success', @@ -115,8 +116,8 @@ currentlySyncing = true; - $q.when(backendCommunicator.fireEventAsync("syncChannelRewards")) - .then(channelRewards => { + $q.when(backendCommunicator.fireEventAsync("sync-channel-rewards")) + .then((channelRewards) => { if (channelRewards) { service.channelRewards = channelRewards; } @@ -124,10 +125,46 @@ }); }; + service.loadingRedemptions = false; + service.refreshChannelRewardRedemptions = () => { + if (service.loadingRedemptions === true) { + return; + } + + service.loadingRedemptions = true; + + $q.when(backendCommunicator.fireEventAsync("refresh-channel-reward-redemptions")) + .then((redemptions) => { + if (redemptions) { + service.redemptions = redemptions; + } + service.loadingRedemptions = false; + }); + }; + + service.approveOrRejectChannelRewardRedemption = (rewardId, redemptionId, approve = true) => { + backendCommunicator.send("approve-reject-channel-reward-redemption", { + rewardId, + redemptionId, + approve + }); + }; + + service.approveOrRejectAllRedemptionsForChannelReward = (rewardId, approve = true) => { + backendCommunicator.send("approve-reject-channel-reward-all-redemptions", { + rewardId, + approve + }); + }; + backendCommunicator.on("channel-reward-updated", (channelReward) => { updateChannelReward(channelReward); }); + backendCommunicator.on("channel-reward-redemptions-updated", (redemptions) => { + service.redemptions = redemptions; + }); + return service; }); }()); diff --git a/src/gui/app/templates/_channel-rewards.html b/src/gui/app/templates/_channel-rewards.html index 867bfbf36..27e558f04 100644 --- a/src/gui/app/templates/_channel-rewards.html +++ b/src/gui/app/templates/_channel-rewards.html @@ -1,64 +1,114 @@ - - -
+ +
+ - - - - -
-
- Reward Limit - - - {{channelRewardsService.channelRewards.length}} / 50 - -
-
-
+ + + +
+
+ Reward Limit + + + {{channelRewardsService.channelRewards.length}} / 50 + +
+
+
+
+
+
+
+
+ +
+
+
Loading redemptions...
+
+
+

{{rewardId}}

+
+
{{redemption.redemptionDate | timeAgo: "days"}}: {{redemption.userDisplayName}}{{(redemption.userDisplayName.toLowerCase() !== redemption.userName.toLowerCase() ? " (" + redemption.userName + ")" : "")}}
+
{{redemption.rewardMessage}}
- - +
+
In order to use Channel Rewards, please login with either an affiliate or partner account.
-
\ No newline at end of file +
\ No newline at end of file From f743c0aa50ed903759c968c16092bc6b82e0e462 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 20 Feb 2024 22:25:48 -0500 Subject: [PATCH 010/113] fix(chat): fix autocomplete --- .../app/directives/chat/chat-autocomplete-menu.component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/app/directives/chat/chat-autocomplete-menu.component.js b/src/gui/app/directives/chat/chat-autocomplete-menu.component.js index 3e6cd8e14..052945291 100644 --- a/src/gui/app/directives/chat/chat-autocomplete-menu.component.js +++ b/src/gui/app/directives/chat/chat-autocomplete-menu.component.js @@ -248,7 +248,7 @@ function buildChatUserItems() { return chatMessagesService.chatUsers.map(user => ({ - display: user.username.toLowerCase() !== user.displayName.toLowerCase() + display: user.username?.toLowerCase() !== user.displayName?.toLowerCase() ? `${user.displayName} (${user.username})` : user.displayName, text: `@${user.username}` @@ -334,7 +334,7 @@ const token = currentWord.text[0]; - categories.forEach(c => { + categories.forEach((c) => { if (token === c.token && (!c.onlyStart || currentWord.index === 0)) { const minQueryLength = c.minQueryLength || 0; if (currentWord.text.length >= minQueryLength) { From 5e0f33de03eec0b7564d81d70fa6686050a16dfd Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk Date: Wed, 21 Feb 2024 12:43:44 +0100 Subject: [PATCH 011/113] fix(quick actions): Fix renaming quick actions showing a UI error and not updating (#2419) --- src/backend/quick-actions/quick-action-manager.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/backend/quick-actions/quick-action-manager.js b/src/backend/quick-actions/quick-action-manager.js index 58c53fac8..914307ad3 100644 --- a/src/backend/quick-actions/quick-action-manager.js +++ b/src/backend/quick-actions/quick-action-manager.js @@ -54,11 +54,10 @@ class QuickActionManager extends JsonDbManager { return; } const quickActionSettings = settings.getQuickActionSettings(); - if (Object.keys(quickActionSettings).includes(quickAction.id)) { - return; + if (!Object.keys(quickActionSettings).includes(quickAction.id)) { + quickActionSettings[quickAction.id] = { enabled: true, position: Object.keys(quickActionSettings).length }; + settings.setQuickActionSettings(quickActionSettings); } - quickActionSettings[quickAction.id] = { enabled: true, position: Object.keys(quickActionSettings).length }; - settings.setQuickActionSettings(quickActionSettings); if (notify) { this.triggerUiRefresh(); } From 50f390369ae6da98ba0e2e4337fae75473de9c98 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 21 Feb 2024 14:05:24 -0500 Subject: [PATCH 012/113] fix(chat): more better autocomplete handling --- src/gui/app/directives/chat/chat-autocomplete-menu.component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/app/directives/chat/chat-autocomplete-menu.component.js b/src/gui/app/directives/chat/chat-autocomplete-menu.component.js index 052945291..2838a0a4a 100644 --- a/src/gui/app/directives/chat/chat-autocomplete-menu.component.js +++ b/src/gui/app/directives/chat/chat-autocomplete-menu.component.js @@ -248,7 +248,7 @@ function buildChatUserItems() { return chatMessagesService.chatUsers.map(user => ({ - display: user.username?.toLowerCase() !== user.displayName?.toLowerCase() + display: user.username && user.username.toLowerCase() !== user.displayName.toLowerCase() ? `${user.displayName} (${user.username})` : user.displayName, text: `@${user.username}` From d3f565ca99b83bfbe35a0b565e22b16a046468e3 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 21 Feb 2024 14:41:38 -0500 Subject: [PATCH 013/113] fix(roles): use channel search to migrate Unicode display name --- src/backend/roles/custom-roles-manager.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/backend/roles/custom-roles-manager.ts b/src/backend/roles/custom-roles-manager.ts index e098cfa63..13408d4ab 100644 --- a/src/backend/roles/custom-roles-manager.ts +++ b/src/backend/roles/custom-roles-manager.ts @@ -62,11 +62,28 @@ class CustomRolesManager { const usernameRegex = new RegExp("^[a-z0-9_]+$", "i"); const viewersToMigrate: string[] = []; + const unicodeViewers: string[] = []; const failedMigration: string[] = []; for (const viewer of legacyRole.viewers) { if (usernameRegex.test(viewer) === true) { viewersToMigrate.push(viewer.toLowerCase()); + } else { + unicodeViewers.push(viewer); + } + } + + // Maybe channel search gives us the Unicode users + for (const viewer of unicodeViewers) { + const results = await twitchApi.streamerClient.search.searchChannels(viewer); + const channel = results.data?.find(c => c.displayName.toLowerCase() === viewer.toLowerCase()); + + if (channel && channel.displayName.toLowerCase() === viewer.toLowerCase()) { + newCustomRole.viewers.push({ + id: channel.id, + username: channel.name, + displayName: channel.displayName + }); } else { failedMigration.push(viewer); } From 55b37459db907c77cbcba147ab5879dd18960bee Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 21 Feb 2024 15:30:31 -0500 Subject: [PATCH 014/113] fix(roles): fix legacy role import --- src/backend/import/setups/setup-importer.js | 2 +- .../v4/areas/v4-viewergroups-importer.js | 2 +- src/backend/roles/custom-roles-manager.ts | 139 ++++++++++-------- 3 files changed, 83 insertions(+), 60 deletions(-) diff --git a/src/backend/import/setups/setup-importer.js b/src/backend/import/setups/setup-importer.js index 208706a74..8a35d6802 100644 --- a/src/backend/import/setups/setup-importer.js +++ b/src/backend/import/setups/setup-importer.js @@ -166,7 +166,7 @@ async function importSetup(setup, selectedCurrency) { // viewer roles const roles = setup.components.viewerRoles || []; for (const role of roles) { - customRolesManager.saveCustomRole(role); + await customRolesManager.importCustomRole(role); } customRolesManager.triggerUiRefresh(); diff --git a/src/backend/import/v4/areas/v4-viewergroups-importer.js b/src/backend/import/v4/areas/v4-viewergroups-importer.js index f9ca586dd..e6dc2eb9b 100644 --- a/src/backend/import/v4/areas/v4-viewergroups-importer.js +++ b/src/backend/import/v4/areas/v4-viewergroups-importer.js @@ -38,7 +38,7 @@ exports.run = async () => { viewers: viewerGroup.users }; - customRolesManager.saveCustomRole(customRole); + await customRolesManager.importCustomRole(customRole); } customRolesManager.triggerUiRefresh(); diff --git a/src/backend/roles/custom-roles-manager.ts b/src/backend/roles/custom-roles-manager.ts index 13408d4ab..5b3143dd1 100644 --- a/src/backend/roles/custom-roles-manager.ts +++ b/src/backend/roles/custom-roles-manager.ts @@ -35,10 +35,12 @@ class CustomRolesManager { frontendCommunicator.on("save-custom-role", (role: CustomRole) => { this.saveCustomRole(role); + this.triggerUiRefresh(); }); frontendCommunicator.on("delete-custom-role", (roleId: string) => { this.deleteCustomRole(roleId); + this.triggerUiRefresh(); }); } @@ -52,63 +54,7 @@ class CustomRolesManager { const legacyCustomRoles: Record = legacyCustomRolesDb.getData("/"); for (const legacyRole of Object.values(legacyCustomRoles)) { - logger.info(`Migrating custom role ${legacyRole.name}`); - - const newCustomRole: CustomRole = { - id: legacyRole.id, - name: legacyRole.name, - viewers: [] - }; - - const usernameRegex = new RegExp("^[a-z0-9_]+$", "i"); - const viewersToMigrate: string[] = []; - const unicodeViewers: string[] = []; - const failedMigration: string[] = []; - - for (const viewer of legacyRole.viewers) { - if (usernameRegex.test(viewer) === true) { - viewersToMigrate.push(viewer.toLowerCase()); - } else { - unicodeViewers.push(viewer); - } - } - - // Maybe channel search gives us the Unicode users - for (const viewer of unicodeViewers) { - const results = await twitchApi.streamerClient.search.searchChannels(viewer); - const channel = results.data?.find(c => c.displayName.toLowerCase() === viewer.toLowerCase()); - - if (channel && channel.displayName.toLowerCase() === viewer.toLowerCase()) { - newCustomRole.viewers.push({ - id: channel.id, - username: channel.name, - displayName: channel.displayName - }); - } else { - failedMigration.push(viewer); - } - } - - const users = await twitchApi.users.getUsersByNames(viewersToMigrate); - for (const viewer of viewersToMigrate) { - const user = users.find(u => u.name === viewer); - if (user != null) { - newCustomRole.viewers.push({ - id: user.id, - username: user.name, - displayName: user.displayName - }); - } else { - failedMigration.push(viewer); - } - } - - if (failedMigration.length > 0) { - logger.warn(`Could not migrate the following viewers in custom role ${newCustomRole.name}: ${failedMigration.join(", ")}`); - } - - this.saveCustomRole(newCustomRole); - logger.info(`Finished migrating custom role ${newCustomRole.name}`); + await this.importLegacyCustomRole(legacyRole); } logger.info("Deleting legacy custom roles database"); @@ -116,9 +62,81 @@ class CustomRolesManager { logger.info("Legacy custom role migration complete"); } catch (error) { - logger.error("Unexpected error during custom role migration", error); + logger.error("Unexpected error during legacy custom role migration", error); + } + } + } + + async importLegacyCustomRole(legacyRole: LegacyCustomRole) { + logger.info(`Migrating legacy custom role ${legacyRole.name}`); + + const newCustomRole: CustomRole = { + id: legacyRole.id, + name: legacyRole.name, + viewers: [] + }; + + const usernameRegex = new RegExp("^[a-z0-9_]+$", "i"); + const viewersToMigrate: string[] = []; + const unicodeViewers: string[] = []; + const failedMigration: string[] = []; + + for (const viewer of legacyRole.viewers) { + if (usernameRegex.test(viewer) === true) { + viewersToMigrate.push(viewer.toLowerCase()); + } else { + unicodeViewers.push(viewer); + } + } + + // Maybe channel search gives us the Unicode users + for (const viewer of unicodeViewers) { + const results = await twitchApi.streamerClient.search.searchChannels(viewer); + const channel = results.data?.find(c => c.displayName.toLowerCase() === viewer.toLowerCase()); + + if (channel && channel.displayName.toLowerCase() === viewer.toLowerCase()) { + newCustomRole.viewers.push({ + id: channel.id, + username: channel.name, + displayName: channel.displayName + }); + } else { + failedMigration.push(viewer); + } + } + + const users = await twitchApi.users.getUsersByNames(viewersToMigrate); + for (const viewer of viewersToMigrate) { + const user = users.find(u => u.name === viewer); + if (user != null) { + newCustomRole.viewers.push({ + id: user.id, + username: user.name, + displayName: user.displayName + }); + } else { + failedMigration.push(viewer); } } + + if (failedMigration.length > 0) { + logger.warn(`Could not migrate the following viewers in legacy custom role ${newCustomRole.name}: ${failedMigration.join(", ")}`); + } + + this.saveCustomRole(newCustomRole); + logger.info(`Finished migrating legacy custom role ${newCustomRole.name}`); + } + + async importCustomRole(role: LegacyCustomRole | CustomRole) { + if (role == null) { + return; + } + + if (role.viewers?.length && !(role as CustomRole).viewers[0].id) { + await this.importLegacyCustomRole(role as LegacyCustomRole); + } else { + this.saveCustomRole(role as CustomRole); + } } private getCustomRolesDb(): JsonDB { @@ -175,6 +193,11 @@ class CustomRolesManager { return; } + if (role.viewers?.length && !role.viewers[0].id) { + logger.error(`Cannot save custom role ${role} as it is in an older format`); + return; + } + this._customRoles[role.id] = role; try { From ecf5347f20d2b8c86fdcfc5a03af7c11880de93a Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 21 Feb 2024 16:48:12 -0500 Subject: [PATCH 015/113] fix: fix Stream Preview window destroy --- src/backend/app-management/electron/window-management.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/app-management/electron/window-management.js b/src/backend/app-management/electron/window-management.js index b208f13ae..ae12e8581 100644 --- a/src/backend/app-management/electron/window-management.js +++ b/src/backend/app-management/electron/window-management.js @@ -83,8 +83,8 @@ function createStreamPreviewWindow() { streamPreviewWindowState.manage(streamPreview); streamPreview.on("close", () => { - if (!view.isDestroyed()) { - view.destroy(); + if (!view.webContents.isDestroyed()) { + view.webContents.destroy(); } }); } From 7b52faae6382e435ff25d1eb71d87002743ca934 Mon Sep 17 00:00:00 2001 From: CKY- Date: Wed, 21 Feb 2024 16:55:58 -0700 Subject: [PATCH 016/113] fix:missing autodisconnect #2423 --- src/backend/chat/twitch-chat.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/backend/chat/twitch-chat.ts b/src/backend/chat/twitch-chat.ts index f605240e1..f35625447 100644 --- a/src/backend/chat/twitch-chat.ts +++ b/src/backend/chat/twitch-chat.ts @@ -117,6 +117,7 @@ class TwitchChat extends EventEmitter { }); this._streamerOutgoingingChatClient.irc.onRegister(() => { this._streamerOutgoingingChatClient.join(streamer.username); + frontendCommunicator.send("twitch:chat:autodisconnected", false); }); this._streamerIncomingChatClient.irc.onPasswordError((event) => { @@ -134,14 +135,14 @@ class TwitchChat extends EventEmitter { this._streamerIncomingChatClient.irc.onDisconnect((manual, reason) => { if (!manual) { - logger.error("Chat disconnected unexpectedly", reason); + logger.error("Incomeing Chat disconnected unexpectedly", reason); frontendCommunicator.send("twitch:chat:autodisconnected", true); } }); this._streamerOutgoingingChatClient.irc.onDisconnect((manual, reason) => { if (!manual) { - logger.error("Chat disconnected unexpectedly", reason); + logger.error("Outgoing Chat disconnected unexpectedly", reason); frontendCommunicator.send("twitch:chat:autodisconnected", true); } }); From 293db5a6d6cde3fdc0c015e34d94710b4644debb Mon Sep 17 00:00:00 2001 From: CKY- Date: Wed, 21 Feb 2024 16:57:24 -0700 Subject: [PATCH 017/113] fix typo --- src/backend/chat/twitch-chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/chat/twitch-chat.ts b/src/backend/chat/twitch-chat.ts index f35625447..340a75a6d 100644 --- a/src/backend/chat/twitch-chat.ts +++ b/src/backend/chat/twitch-chat.ts @@ -135,7 +135,7 @@ class TwitchChat extends EventEmitter { this._streamerIncomingChatClient.irc.onDisconnect((manual, reason) => { if (!manual) { - logger.error("Incomeing Chat disconnected unexpectedly", reason); + logger.error("Incoming Chat disconnected unexpectedly", reason); frontendCommunicator.send("twitch:chat:autodisconnected", true); } }); From 4d3d49a24f73aaee6f6941ebf30ef25521ef90fc Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 21 Feb 2024 21:53:54 -0500 Subject: [PATCH 018/113] chore(chat): send chat messages over REST --- src/backend/auth/twitch-auth.ts | 4 +- src/backend/chat/twitch-chat.ts | 55 ++++++--------------- src/backend/twitch-api/resource/chat.ts | 63 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/backend/auth/twitch-auth.ts b/src/backend/auth/twitch-auth.ts index 9615c8579..2b315037d 100644 --- a/src/backend/auth/twitch-auth.ts +++ b/src/backend/auth/twitch-auth.ts @@ -81,6 +81,7 @@ class TwitchAuthProviders { 'user:read:chat', 'user:read:follows', 'user:read:subscriptions', + 'user:write:chat', 'whispers:edit', 'whispers:read' ] @@ -105,6 +106,7 @@ class TwitchAuthProviders { 'moderator:manage:announcements', 'user:manage:whispers', 'user:read:chat', + 'user:write:chat', 'whispers:edit', 'whispers:read' ] @@ -141,7 +143,7 @@ async function getUserCurrent(accessToken: string) { return null; } -authManager.on("auth-success", async authData => { +authManager.on("auth-success", async (authData) => { const { providerId, tokenData } = authData; if (providerId === twitchAuthProviders.streamerAccountProviderId diff --git a/src/backend/chat/twitch-chat.ts b/src/backend/chat/twitch-chat.ts index 340a75a6d..80c147e91 100644 --- a/src/backend/chat/twitch-chat.ts +++ b/src/backend/chat/twitch-chat.ts @@ -37,15 +37,13 @@ interface UserVipRequest { } class TwitchChat extends EventEmitter { - private _streamerIncomingChatClient: ChatClient; - private _streamerOutgoingingChatClient: ChatClient; + private _streamerChatClient: ChatClient; private _botChatClient: ChatClient; constructor() { super(); - this._streamerIncomingChatClient = null; - this._streamerOutgoingingChatClient = null; + this._streamerChatClient = null; this._botChatClient = null; } @@ -54,8 +52,7 @@ class TwitchChat extends EventEmitter { */ get chatIsConnected(): boolean { return ( - this._streamerIncomingChatClient?.irc?.isConnected === true && - this._streamerOutgoingingChatClient?.irc?.isConnected === true + this._streamerChatClient?.irc?.isConnected === true ); } @@ -63,13 +60,9 @@ class TwitchChat extends EventEmitter { * Disconnects the streamer and bot from chat */ async disconnect(emitDisconnectEvent = true): Promise { - if (this._streamerIncomingChatClient != null) { - this._streamerIncomingChatClient.quit(); - this._streamerIncomingChatClient = null; - } - if (this._streamerOutgoingingChatClient != null) { - this._streamerOutgoingingChatClient.quit(); - this._streamerOutgoingingChatClient = null; + if (this._streamerChatClient != null) { + this._streamerChatClient.quit(); + this._streamerChatClient = null; } if (this._botChatClient != null && this._botChatClient?.irc?.isConnected === true) { this._botChatClient.quit(); @@ -102,25 +95,17 @@ class TwitchChat extends EventEmitter { await this.disconnect(false); try { - this._streamerIncomingChatClient = new ChatClient({ - authProvider: streamerAuthProvider, - requestMembershipEvents: true - }); - this._streamerOutgoingingChatClient = new ChatClient({ + this._streamerChatClient = new ChatClient({ authProvider: streamerAuthProvider, requestMembershipEvents: true }); - this._streamerIncomingChatClient.irc.onRegister(() => { - this._streamerIncomingChatClient.join(streamer.username); - frontendCommunicator.send("twitch:chat:autodisconnected", false); - }); - this._streamerOutgoingingChatClient.irc.onRegister(() => { - this._streamerOutgoingingChatClient.join(streamer.username); + this._streamerChatClient.irc.onRegister(() => { + this._streamerChatClient.join(streamer.username); frontendCommunicator.send("twitch:chat:autodisconnected", false); }); - this._streamerIncomingChatClient.irc.onPasswordError((event) => { + this._streamerChatClient.irc.onPasswordError((event) => { logger.error("Failed to connect to chat", event); frontendCommunicator.send( "error", @@ -129,26 +114,18 @@ class TwitchChat extends EventEmitter { this.disconnect(true); }); - this._streamerIncomingChatClient.irc.onConnect(() => { + this._streamerChatClient.irc.onConnect(() => { this.emit("connected"); }); - this._streamerIncomingChatClient.irc.onDisconnect((manual, reason) => { + this._streamerChatClient.irc.onDisconnect((manual, reason) => { if (!manual) { logger.error("Incoming Chat disconnected unexpectedly", reason); frontendCommunicator.send("twitch:chat:autodisconnected", true); } }); - this._streamerOutgoingingChatClient.irc.onDisconnect((manual, reason) => { - if (!manual) { - logger.error("Outgoing Chat disconnected unexpectedly", reason); - frontendCommunicator.send("twitch:chat:autodisconnected", true); - } - }); - - this._streamerIncomingChatClient.connect(); - this._streamerOutgoingingChatClient.connect(); + this._streamerChatClient.connect(); await chatHelpers.handleChatConnect(); @@ -188,7 +165,7 @@ class TwitchChat extends EventEmitter { } try { - twitchChatListeners.setupChatListeners(this._streamerIncomingChatClient, this._botChatClient); + twitchChatListeners.setupChatListeners(this._streamerChatClient, this._botChatClient); } catch (error) { logger.error("Error setting up chat listeners", error); } @@ -200,12 +177,10 @@ class TwitchChat extends EventEmitter { * @param {string} accountType The type of account to whisper with ('streamer' or 'bot') */ async _say(message: string, accountType: string, replyToId?: string): Promise { - const chatClient = accountType === "bot" ? this._botChatClient : this._streamerOutgoingingChatClient; try { logger.debug(`Sending message as ${accountType}.`); - const streamer = accountAccess.getAccounts().streamer; - chatClient.say(streamer.username, message, replyToId ? { replyTo: replyToId } : undefined); + await twitchApi.chat.sendChatMessage(message, replyToId ?? undefined, accountType === "bot"); } catch (error) { logger.error(`Error attempting to send message with ${accountType}`, error); } diff --git a/src/backend/twitch-api/resource/chat.ts b/src/backend/twitch-api/resource/chat.ts index 1d0147cf0..11e172767 100644 --- a/src/backend/twitch-api/resource/chat.ts +++ b/src/backend/twitch-api/resource/chat.ts @@ -34,6 +34,69 @@ export class TwitchChatApi { return chatters; } + /** + * Sends a chat message to the streamer's chat. + * + * @param message Chat message to send. + * @param replyToMessageId The ID of the message this should be replying to. Leave as null for non replies. + * @param sendAsBot If the chat message should be sent as the bot or not. + * If this is set to `false`, the chat message will be sent as the streamer. + * @returns `true` if sending the chat message was successful or `false` if it failed + */ + async sendChatMessage(message: string, replyToMessageId = null, sendAsBot = false): Promise { + if (!message?.length) { + return false; + } + + try { + const streamerUserId: string = accountAccess.getAccounts().streamer.userId; + const willSendAsBot: boolean = sendAsBot === true + && accountAccess.getAccounts().bot?.userId != null + && this._botClient != null; + const senderUserId: string = willSendAsBot === true ? + accountAccess.getAccounts().bot.userId : + accountAccess.getAccounts().streamer.userId; + + // TODO: This next section is a shim until Twurple 7.1.0+ when we get a friendly function call + const client: ApiClient = willSendAsBot === true + ? this._botClient + : this._streamerClient; + + const result = await client.callApi<{ + data: [{ + is_sent: boolean, + drop_reason?: { + message: string + } + }] + }>({ + type: 'helix', + url: 'chat/messages', + method: 'POST', + userId: senderUserId, + canOverrideScopedUserContext: true, + query: { + broadcaster_id: streamerUserId, // eslint-disable-line camelcase + sender_id: senderUserId // eslint-disable-line camelcase + }, + jsonBody: { + message: message, + reply_parent_message_id: replyToMessageId ?? undefined // eslint-disable-line camelcase + } + }); + + if (result.data[0].is_sent !== true) { + logger.error(`Twitch dropped chat message. Reason: ${result.data[0].drop_reason.message}`); + } + + return result.data[0].is_sent; + } catch (error) { + logger.error(`Unable to send ${sendAsBot === true ? "bot" : "steamer"} chat message`, error); + } + + return false; + } + /** * Sends an announcement to the streamer's chat. * From 64927e389246675022380e87814ba015e9253377 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 22 Feb 2024 21:06:11 -0500 Subject: [PATCH 019/113] fix: cleanup fronendCommunicator errors when renderWindow destroyed --- src/backend/common/frontend-communicator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/common/frontend-communicator.ts b/src/backend/common/frontend-communicator.ts index 87be9a122..aa586acd6 100644 --- a/src/backend/common/frontend-communicator.ts +++ b/src/backend/common/frontend-communicator.ts @@ -23,7 +23,7 @@ class FrontendCommunicator implements FrontendCommunicatorModule { } send(eventName: string, data?: unknown): void { - if (globalThis.renderWindow != null) { + if (globalThis.renderWindow?.webContents?.isDestroyed() === false) { globalThis.renderWindow.webContents.send(eventName, data); } } From b20dad7e6127b01c281e792b3b654131b0e0bc8d Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 22 Feb 2024 21:18:31 -0500 Subject: [PATCH 020/113] fix: renderWindow destroyed issue in HttpServerManager --- src/server/http-server-manager.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/server/http-server-manager.js b/src/server/http-server-manager.js index 9e779a688..230e81dbc 100644 --- a/src/server/http-server-manager.js +++ b/src/server/http-server-manager.js @@ -394,10 +394,12 @@ setInterval(() => { : manager.defaultWebsocketServerInstance.clients.size > 0; if (clientsConnected !== manager.overlayHasClients) { - renderWindow.webContents.send("overlayStatusUpdate", { - clientsConnected: clientsConnected, - serverStarted: manager.isDefaultServerStarted - }); + if (renderWindow?.webContents?.isDestroyed() === false) { + renderWindow.webContents.send("overlayStatusUpdate", { + clientsConnected: clientsConnected, + serverStarted: manager.isDefaultServerStarted + }); + } manager.overlayHasClients = clientsConnected; } }, 3000); From b4c61d62d965a28565bac2cab01bee5b5a21de43 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 22 Feb 2024 22:11:08 -0500 Subject: [PATCH 021/113] feat(vars): add pretty print option to $convertToJSON (#2422) --- .../variables/builtin/utility/convert-to-json.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/backend/variables/builtin/utility/convert-to-json.ts b/src/backend/variables/builtin/utility/convert-to-json.ts index db1004295..b93c899f1 100644 --- a/src/backend/variables/builtin/utility/convert-to-json.ts +++ b/src/backend/variables/builtin/utility/convert-to-json.ts @@ -5,19 +5,26 @@ const model : ReplaceVariable = { definition: { handle: "convertToJSON", description: "Converts a raw value into JSON text", - usage: "convertToJSON[raw value]", + usage: "convertToJSON[rawValue]", + examples: [ + { + usage: "convertToJSON[rawValue, true]", + description: "Converts a raw value into pretty-printed JSON text" + } + ], categories: [VariableCategory.ADVANCED], possibleDataOutput: [OutputDataType.TEXT] }, evaluator: ( trigger: Trigger, - jsonText: unknown + jsonText: unknown, + prettyPrint?: string ) : string => { if (jsonText == null) { return "null"; } try { - return JSON.stringify(jsonText); + return JSON.stringify(jsonText, null, prettyPrint === "true" ? 4 : null); } catch (ignore) { return "null"; } From fb185b342ec7dbd7e85c07d80b2387ade2e84fd8 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 23 Feb 2024 08:50:35 -0500 Subject: [PATCH 022/113] fix: try catch batch user requests from Twitch --- src/backend/twitch-api/resource/users.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/backend/twitch-api/resource/users.ts b/src/backend/twitch-api/resource/users.ts index 028763b9f..1c4fd50b2 100644 --- a/src/backend/twitch-api/resource/users.ts +++ b/src/backend/twitch-api/resource/users.ts @@ -19,7 +19,11 @@ export class TwitchUsersApi { const users: HelixUser[] = []; for (let x = 0; x < userIds.length; x += 100) { const userBatch = userIds.slice(x, x + 100); - users.push(...await this._streamerClient.users.getUsersByIds(userBatch)); + try { + users.push(...await this._streamerClient.users.getUsersByIds(userBatch)); + } catch (error) { + logger.error(`Error trying to get users by ID from Twitch API`, error); + } } return users; } @@ -32,7 +36,11 @@ export class TwitchUsersApi { const users: HelixUser[] = []; for (let x = 0; x < usernames.length; x += 100) { const userBatch = usernames.slice(x, x + 100); - users.push(...await this._streamerClient.users.getUsersByNames(userBatch)); + try { + users.push(...await this._streamerClient.users.getUsersByNames(userBatch)); + } catch (error) { + logger.error(`Error trying to get users by name from Twitch API`, error); + } } return users; } From e6a13114cbc8224ef30a2f50727c3df61dda06c9 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sat, 24 Feb 2024 18:03:56 -0500 Subject: [PATCH 023/113] chore(chat): update chat settings UI --- .../controllers/chat-messages.controller.js | 6 +- .../modals/chat/chat-settings-modal.js | 304 +++++++++--------- src/gui/scss/core/_chat.scss | 10 +- 3 files changed, 163 insertions(+), 157 deletions(-) diff --git a/src/gui/app/controllers/chat-messages.controller.js b/src/gui/app/controllers/chat-messages.controller.js index c427e4d60..21ecc4848 100644 --- a/src/gui/app/controllers/chat-messages.controller.js +++ b/src/gui/app/controllers/chat-messages.controller.js @@ -37,7 +37,7 @@ $scope.threadDetails = null; function getThreadMessages(threadOrReplyMessageId) { - return chatMessagesService.chatQueue.filter(chatItem => { + return chatMessagesService.chatQueue.filter((chatItem) => { return chatItem.type === "message" && (chatItem.data.id === threadOrReplyMessageId || chatItem.data.replyParentMessageId === threadOrReplyMessageId || chatItem.data.threadParentMessageId === threadOrReplyMessageId); }).map(ci => ci.data); } @@ -118,7 +118,7 @@ $scope.showChatSettingsModal = () => { utilityService.showModal({ component: "chatSettingsModal", - size: "sm", + size: "md", backdrop: true, dismissCallback: getUpdatedChatSettings, closeCallback: getUpdatedChatSettings @@ -183,7 +183,7 @@ currrentHistoryIndex = -1; }; - $scope.onMessageFieldKeypress = $event => { + $scope.onMessageFieldKeypress = ($event) => { const keyCode = $event.which || $event.keyCode; if (keyCode === 38) { //up arrow diff --git a/src/gui/app/directives/modals/chat/chat-settings-modal.js b/src/gui/app/directives/modals/chat/chat-settings-modal.js index 8b603ba16..d8142188e 100644 --- a/src/gui/app/directives/modals/chat/chat-settings-modal.js +++ b/src/gui/app/directives/modals/chat/chat-settings-modal.js @@ -8,63 +8,63 @@
- diff --git a/src/gui/scss/core/_$importer.scss b/src/gui/scss/core/_$importer.scss index eb1377311..d0811aeb4 100644 --- a/src/gui/scss/core/_$importer.scss +++ b/src/gui/scss/core/_$importer.scss @@ -19,4 +19,5 @@ @import 'effects'; @import 'bootstrap-overrides'; @import 'title-bar'; -@import 'modals'; \ No newline at end of file +@import 'modals'; +@import 'channel-rewards'; \ No newline at end of file diff --git a/src/gui/scss/core/_bootstrap-overrides.scss b/src/gui/scss/core/_bootstrap-overrides.scss index 53526a972..88d7b1c38 100644 --- a/src/gui/scss/core/_bootstrap-overrides.scss +++ b/src/gui/scss/core/_bootstrap-overrides.scss @@ -144,18 +144,29 @@ textarea.form-control { // Buttons .btn { - &.btn-lg { - height: 40px; - font-size: 14px; - } border-radius: 8px; padding: 9px 12px; transform: scale(1.0) translateZ(0); transition-property: transform; transition-duration: 0.3s; + &.btn-lg { + height: 40px; + font-size: 14px; + } + &.btn-sm { + padding: 5px 10px !important; + font-size: 12px; + line-height: 1.5; + } + &.btn-xs { + padding: 3px 6px !important; + font-size: 12px !important; + line-height: 1.5 !important; + } &:hover { // transform: scale(1.02) translateZ(0); } + } .btn.dropdown-toggle, .btn.form-control { @@ -190,6 +201,16 @@ textarea.form-control { border: 1px solid transparent; background-color: $default-btn-bg-color; color: $default-btn-text-color; + &:disabled { + background-color: $default-btn-bg-color; + color: $default-btn-text-color; + opacity: 0.5; + &:hover { + background-color: $default-btn-bg-color; + color: $default-btn-text-color; + opacity: 0.75; + }; + } &:hover { background-color: $default-btn-hover-bg; // border-color: $default-btn-hover-border; diff --git a/src/gui/scss/core/_channel-rewards.scss b/src/gui/scss/core/_channel-rewards.scss new file mode 100644 index 000000000..a8bc3c50a --- /dev/null +++ b/src/gui/scss/core/_channel-rewards.scss @@ -0,0 +1,96 @@ +.queue-manager-container { + width: 100%; + height: 100%; + position: relative; + + .queue-loader-overlay { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: #00000080; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + } + + .queue-manager-content { + display: flex; + flex-direction: row; + height: 100%; + + .queue-rewards-column { + flex-basis: 30%; + border-right: 2px solid rgb(128 128 128 / 0.33); + overflow-y: auto; + padding: 10px 10px; + } + + .queue-redemptions-column { + flex-basis: 70%; + display: flex; + flex-direction: column; + + .queue-redemptions-list { + overflow-y: auto; + padding: 10px 15px; + flex-grow: 1; + } + + .queue-footer { + background-color: $fb-table-row-bg; + flex-basis: 50px; + flex-shrink: 0; + display: flex; + justify-content: end; + align-items: center; + column-gap: 10px; + padding-right: 10px; + } + } + } +} + +.queue-reward-wrapper { + padding: 10px; + border-radius: 5px; + margin-bottom: 10px; + &.selected { + background-color: adjust-color($light-background-color, $red: 20, $green: 20, $blue: 20); + } + &:hover { + background-color: adjust-color($light-background-color, $red: 20, $green: 20, $blue: 20); + } +} + +.queue-reward-item { + display: flex; + flex-direction: row; + .queue-reward-image { + width: 40px; + height: 40px; + margin-right: 10px; + border-radius: 10px; + padding: 5px; + } +} + +.queue-redemption-item { + margin-bottom: 15px; + border-bottom: 1px solid rgb(128 128 128 / 33%); + padding-bottom: 10px; + .queue-reward-name { + font-weight: bold; + font-size: 16px; + } +} + +.reward-queue-modal-content { + height: 500px; + padding: 0; + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; + overflow: hidden; +} From ced9323f6cf2eda61d106a0b6bf4540d1fbf2685 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Fri, 8 Mar 2024 03:07:48 -0600 Subject: [PATCH 053/113] feat: support Restrictions for Channel Rewards #2452 --- .../channel-rewards/channel-reward-manager.ts | 63 ++++++++- .../directives/controls/firebot-checkbox.js | 5 +- .../add-edit-channel-reward.js | 130 ++++++++++-------- .../restrictions/restrictionsSection.js | 6 +- .../app/services/channel-rewards.service.js | 1 - src/types/channel-rewards.d.ts | 5 +- src/types/commands.d.ts | 15 +- src/types/restrictions.d.ts | 13 ++ 8 files changed, 163 insertions(+), 75 deletions(-) create mode 100644 src/types/restrictions.d.ts diff --git a/src/backend/channel-rewards/channel-reward-manager.ts b/src/backend/channel-rewards/channel-reward-manager.ts index 9fe5c8541..f1bee5012 100644 --- a/src/backend/channel-rewards/channel-reward-manager.ts +++ b/src/backend/channel-rewards/channel-reward-manager.ts @@ -7,7 +7,7 @@ import twitchApi from "../twitch-api/api"; import { CustomReward, RewardRedemption, RewardRedemptionsApprovalRequest } from "../twitch-api/resource/channel-rewards"; import { EffectTrigger } from "../../shared/effect-constants"; import { RewardRedemptionMetadata, SavedChannelReward } from "../../types/channel-rewards"; - +import { TriggerType } from "../common/EffectType"; class ChannelRewardManager { channelRewards: Record = {}; @@ -265,6 +265,67 @@ class ChannelRewardManager { return; } + const restrictionData = savedReward.restrictionData; + if (restrictionData) { + logger.debug("Reward has restrictions...checking them."); + const restrictionsManager = require("../restrictions/restriction-manager"); + const triggerData = { + type: TriggerType.CHANNEL_REWARD, + metadata + }; + + const shouldAutoApproveOrReject = savedReward.manageable && + !savedReward.twitchData.shouldRedemptionsSkipRequestQueue && + savedReward.autoApproveRedemptions; + + try { + await restrictionsManager.runRestrictionPredicates(triggerData, savedReward.restrictionData); + logger.debug("Restrictions passed!"); + if (shouldAutoApproveOrReject) { + logger.debug("auto accepting redemption"); + this.approveOrRejectChannelRewardRedemptions({ + rewardId, + redemptionIds: [metadata.redemptionId], + approve: true + }); + } + } catch (restrictionReason) { + let reason; + if (Array.isArray(restrictionReason)) { + reason = restrictionReason.join(", "); + } else { + reason = restrictionReason; + } + + logger.debug(`${metadata.username} could not use Reward '${savedReward.twitchData.title}' because: ${reason}`); + if (restrictionData.sendFailMessage || restrictionData.sendFailMessage == null) { + + const restrictionMessage = restrictionData.useCustomFailMessage ? + restrictionData.failMessage : + "Sorry @{user}, you cannot use this channel reward because: {reason}"; + + const twitchChat = require("../chat/twitch-chat"); + await twitchChat.sendChatMessage( + restrictionMessage + .replace("{user}", metadata.username) + .replace("{reason}", reason) + ); + } + + if (shouldAutoApproveOrReject) { + logger.debug("auto rejecting redemption"); + this.approveOrRejectChannelRewardRedemptions({ + rewardId, + redemptionIds: [metadata.redemptionId], + approve: false + }); + } + + return false; + } + } + + const effectRunner = require("../common/effect-runner"); const processEffectsRequest = { diff --git a/src/gui/app/directives/controls/firebot-checkbox.js b/src/gui/app/directives/controls/firebot-checkbox.js index 4d9a718c2..811e068cf 100644 --- a/src/gui/app/directives/controls/firebot-checkbox.js +++ b/src/gui/app/directives/controls/firebot-checkbox.js @@ -8,11 +8,12 @@ label: "@", tooltip: "@?", model: "=", - style: "@?" + style: "@?", + disabled: " {{$ctrl.label}} - +
` diff --git a/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js b/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js index 9e9491d22..ab69ee4c4 100644 --- a/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js +++ b/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js @@ -13,13 +13,13 @@
{{$ctrl.reward.twitchData.title}}
-
- +
- +

This reward was either created outside of Firebot, or by an older version of Firebot, so its settings cannot be changed here. You can however still create effects for it. If you want to update settings for this reward, you can do so on Twitch.

@@ -31,30 +31,30 @@
-
- +
Chat messages cannot be longer than 500 characters. This message will get automatically chunked into multiple messages if it's too long after all replace variables have been populated.
- -
-
- To - -
+ + +
+
-

ProTip: To whisper the associated user, put $user in the whisper field.

-
- +

ProTip: To whisper the associated user, put $user in the whisper field.

+
+
`, - optionsController: () => {}, + optionsController: ($scope) => { + $scope.showWhisperInput = $scope.effect.whisper != null && $scope.effect.whisper !== '' + }, optionsValidator: effect => { const errors = []; if (effect.message == null || effect.message === "") { @@ -56,6 +75,10 @@ const effect = { messageId = trigger.metadata.eventData?.chatMessage?.id; } + if (effect.me) { + effect.message = `/me ${effect.message}`; + } + await twitchChat.sendChatMessage(effect.message, effect.whisper, effect.chatter, !effect.whisper && effect.sendAsReply ? messageId : undefined); return true; From a816d54f08616f9a8ec6883858a1747d952ae1dd Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk <70665154+dennisrijsdijk@users.noreply.github.com> Date: Sat, 9 Mar 2024 01:49:25 +0100 Subject: [PATCH 068/113] Follow Restriction Improvements (#2456) * chore: add streamer/mod warning for follow check restriction * feat: follow check for streamer * chore: update time-input to go up to years, force maximum time unit to days on add-edit-channel-reward * feat: follow age for follow restriction * fix: invalid users causing a fatal exception in follow check --------- Co-authored-by: Erik Bigler --- src/backend/common/user-access.js | 28 +++++++-- .../restrictions/builtin/follow-check.js | 58 ++++++++++++++++--- src/backend/twitch-api/resource/users.ts | 21 ++++--- src/gui/app/directives/controls/time-input.js | 47 +++++++++++---- .../add-edit-channel-reward.js | 1 + 5 files changed, 123 insertions(+), 32 deletions(-) diff --git a/src/backend/common/user-access.js b/src/backend/common/user-access.js index a200624dc..e06cb0b01 100644 --- a/src/backend/common/user-access.js +++ b/src/backend/common/user-access.js @@ -13,27 +13,43 @@ const teamRolesManager = require("../roles/team-roles-manager"); const followCache = new NodeCache({ stdTTL: 10, checkperiod: 10 }); -async function userFollowsChannels(username, channelNames) { +async function userFollowsChannels(username, channelNames, durationInSeconds = 0) { let userfollowsAllChannels = true; for (const channelName of channelNames) { - let userFollowsChannel = false; + /** + * @type {import('@twurple/api').HelixChannelFollower | boolean} + */ + let userFollow; // check cache first const cachedFollow = followCache.get(`${username}:${channelName}`); if (cachedFollow !== undefined) { - userFollowsChannel = cachedFollow; + userFollow = cachedFollow; } else { - userFollowsChannel = await twitchApi.users.doesUserFollowChannel(username, channelName); + userFollow = await twitchApi.users.getUserChannelFollow(username, channelName); // set cache value - followCache.set(`${username}:${channelName}`, userFollowsChannel); + followCache.set(`${username}:${channelName}`, userFollow); } - if (!userFollowsChannel) { + if (!userFollow) { userfollowsAllChannels = false; break; } + + if (userFollow === true) { // streamer follow + continue; + } + + if (durationInSeconds > 0) { + const followTime = Math.round(userFollow.followDate.getTime() / 1000); + const currentTime = Math.round(new Date().getTime() / 1000); + if ((currentTime - followTime) < durationInSeconds) { + userfollowsAllChannels = false; + break; + } + } } return userfollowsAllChannels; diff --git a/src/backend/restrictions/builtin/follow-check.js b/src/backend/restrictions/builtin/follow-check.js index db13e4906..5d5259f15 100644 --- a/src/backend/restrictions/builtin/follow-check.js +++ b/src/backend/restrictions/builtin/follow-check.js @@ -9,14 +9,53 @@ const model = { }, optionsTemplate: `
- `, + + optionsController: ($scope) => { + if ($scope.restriction.checkMode == null) { + $scope.restriction.checkMode = "custom"; + } + }, + optionsValueDisplay: (restriction) => { - const value = restriction.value; + const value = restriction.checkMode === "custom" ? restriction.value : "Follows my channel"; if (value == null) { return ""; @@ -30,11 +69,12 @@ const model = { predicate: async (trigger, restrictionData) => { return new Promise(async (resolve, reject) => { const userAccess = require("../../common/user-access"); + const accountAccess = require("../../common/account-access"); const triggerUsername = trigger.metadata.username || ""; - const followListString = restrictionData.value || ""; + const followListString = restrictionData.checkMode === "custom" ? restrictionData.value || "" : accountAccess.getAccounts().streamer.username; - if (triggerUsername === "", followListString === "") { + if (triggerUsername === "" || followListString === "") { return resolve(); } @@ -42,13 +82,15 @@ const model = { .filter(f => f != null) .map(f => f.toLowerCase().trim()); - const followCheck = await userAccess.userFollowsChannels(triggerUsername, followCheckList); + const seconds = restrictionData.useFollowAge ? restrictionData.followAgeSeconds : 0; + + const followCheck = await userAccess.userFollowsChannels(triggerUsername, followCheckList, seconds); if (followCheck) { return resolve(); } - return reject(`You must be following: ${restrictionData.value}`); + return reject(`You must be following: ${followListString}`); }); } }; diff --git a/src/backend/twitch-api/resource/users.ts b/src/backend/twitch-api/resource/users.ts index 1c4fd50b2..371b00076 100644 --- a/src/backend/twitch-api/resource/users.ts +++ b/src/backend/twitch-api/resource/users.ts @@ -1,6 +1,6 @@ import accountAccess from "../../common/account-access"; import logger from "../../logwrapper"; -import { ApiClient, HelixUser, UserIdResolvable } from "@twurple/api"; +import {ApiClient, HelixChannelFollower, HelixUser, UserIdResolvable} from "@twurple/api"; export class TwitchUsersApi { private _streamerClient: ApiClient; @@ -59,7 +59,12 @@ export class TwitchUsersApi { return followData.data[0].followDate; } - async doesUserFollowChannel(username: string, channelName: string): Promise { + /** + * @param username + * @param channelName + * @returns {Promise} true when username === channelName, false when not following + */ + async getUserChannelFollow(username: string, channelName: string): Promise { if (username == null || channelName == null) { return false; } @@ -68,25 +73,25 @@ export class TwitchUsersApi { return true; } - const streamerData = accountAccess.getAccounts().streamer; - const [user, channel] = await this.getUsersByNames([username, channelName]); - if (user.id == null || channel.id == null) { + if (user?.id == null || channel?.id == null) { return false; } try { const userFollowResponse = await this._streamerClient.channels.getChannelFollowers(channel.id, user.id); - const userFollow = userFollowResponse?.data?.length === 1; - - return userFollow ?? false; + return userFollowResponse?.data?.[0]; } catch (err) { logger.error(`Failed to check if ${username} follows ${channelName}`, err.message); return false; } } + async doesUserFollowChannel(username: string, channelName: string): Promise { + return await this.getUserChannelFollow(username, channelName) !== false; + } + async blockUser(userId: UserIdResolvable, reason?: 'spam' | 'harassment' | 'other'): Promise { if (userId == null) { return false; diff --git a/src/gui/app/directives/controls/time-input.js b/src/gui/app/directives/controls/time-input.js index 2e7c4ce68..7c2258f7a 100644 --- a/src/gui/app/directives/controls/time-input.js +++ b/src/gui/app/directives/controls/time-input.js @@ -9,7 +9,8 @@ ngModel: "<", validationError: " { + if ($ctrl.maxTimeUnit != null && $ctrl.timeUnits.includes($ctrl.maxTimeUnit)) { + $ctrl.timeUnits.length = $ctrl.timeUnits.findIndex(unit => unit === $ctrl.maxTimeUnit) + 1; + } + if ($ctrl.ngModel != null) { $ctrl.selectedTimeUnit = determineTimeUnit($ctrl.ngModel); diff --git a/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js b/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js index ab69ee4c4..9a62d6d95 100644 --- a/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js +++ b/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js @@ -119,6 +119,7 @@
Date: Sat, 9 Mar 2024 16:58:12 -0600 Subject: [PATCH 069/113] feat: implement sortable columns for firebot-item-table, refactor commands to use firebot-item-table --- .../controllers/channel-rewards.controller.js | 4 + .../app/controllers/commands.controller.js | 227 +++++++++--------- .../app/controllers/counters.controller.js | 8 + .../controllers/effect-queues.controller.js | 6 + src/gui/app/controllers/events.controller.js | 4 + .../preset-effect-lists.controller.js | 2 + src/gui/app/controllers/timers.controller.js | 6 + .../firebot-item-table.html | 48 +++- .../firebot-item-table/firebot-item-table.js | 71 +++++- src/gui/app/templates/chat/_commands.html | 214 ++--------------- src/gui/scss/core/_controls.scss | 21 ++ 11 files changed, 284 insertions(+), 327 deletions(-) diff --git a/src/gui/app/controllers/channel-rewards.controller.js b/src/gui/app/controllers/channel-rewards.controller.js index 0eee5bc5a..a6869eebe 100644 --- a/src/gui/app/controllers/channel-rewards.controller.js +++ b/src/gui/app/controllers/channel-rewards.controller.js @@ -41,12 +41,16 @@ headerStyles: { 'min-width': '125px' }, + dataField: "twitchData.title", + sortable: true, cellTemplate: `{{data.twitchData.title}} `, cellController: () => {} }, { name: "COST", icon: "fa-coin", + dataField: "twitchData.cost", + sortable: true, cellTemplate: `{{data.twitchData.cost}}`, cellController: () => {} }, diff --git a/src/gui/app/controllers/commands.controller.js b/src/gui/app/controllers/commands.controller.js index 5472614bb..352f53f2d 100644 --- a/src/gui/app/controllers/commands.controller.js +++ b/src/gui/app/controllers/commands.controller.js @@ -11,8 +11,7 @@ listenerService, viewerRolesService, objectCopyHelper, - sortTagsService, - effectQueuesService + sortTagsService ) { // Cache commands on app load. commandsService.refreshCommands(); @@ -22,52 +21,6 @@ $scope.commandsService = commandsService; $scope.sts = sortTagsService; - function filterCommands() { - return triggerSearchFilter(sortTagSearchFilter(commandsService.getCustomCommands(), sortTagsService.getSelectedSortTag("commands")), commandsService.customCommandSearch); - } - - $scope.filteredCommands = filterCommands(); - - $scope.getPermissionType = (command) => { - - const permissions = command.restrictionData && command.restrictionData.restrictions && - command.restrictionData.restrictions.find(r => r.type === "firebot:permissions"); - - if (permissions) { - if (permissions.mode === "roles") { - return "Roles"; - } else if (permissions.mode === "viewer") { - return "Viewer"; - } - } else { - return "None"; - } - }; - - $scope.getPermissionTooltip = (command) => { - - const permissions = command.restrictionData && command.restrictionData.restrictions && - command.restrictionData.restrictions.find(r => r.type === "firebot:permissions"); - - if (permissions) { - if (permissions.mode === "roles") { - const roleIds = permissions.roleIds; - let output = "None selected"; - if (roleIds.length > 0) { - output = roleIds - .filter(id => viewerRolesService.getRoleById(id) != null) - .map(id => viewerRolesService.getRoleById(id).name) - .join(", "); - } - return `Roles (${output})`; - } else if (permissions.mode === "viewer") { - return `Viewer (${permissions.username ? permissions.username : 'No name'})`; - } - } else { - return "This command is available to everyone"; - } - }; - $scope.manuallyTriggerCommand = (id) => { listenerService.fireEvent( listenerService.EventType.COMMAND_MANUAL_TRIGGER, @@ -143,6 +96,10 @@ commandsService.resetCooldownsForCommand(command.id); }; + $scope.saveAllCommands = (commands) => { + commandsService.saveAllCustomCommands(commands ?? commandsService.commandsCache.customCommands); + }; + $scope.sortableOptions = { handle: ".dragHandle", 'ui-preserve-size': true, @@ -154,97 +111,145 @@ return; } - commandsService.saveAllCustomCommands(commandsService.commandsCache.customCommands); + $scope.saveAllCommands(); } }; - $scope.addToEffectQueue = (command, queueId) => { - if (command == null) { - return; - } - - if (command.effects) { - command.effects.queue = queueId; - } - - commandsService.saveCustomCommand(command); - }; - - $scope.clearEffectQueue = (command) => { - command.effects.queue = null; - }; - - $scope.getEffectQueueMenuOptions = (command) => { - const queues = effectQueuesService.getEffectQueues(); - if (command.effects != null && queues != null && queues.length > 0) { - const children = queues.map((q) => { - const isSelected = command.effects.queue && command.effects.queue === q.id; - return { - html: ` ${q.name}`, - click: () => { - $scope.addToEffectQueue(command, q.id); - } - }; - }); - - const hasEffectQueue = command.effects.queue != null && command.effects.queue !== ""; - children.push({ - html: ` None`, - click: () => { - $scope.clearEffectQueue(command); - }, - hasTopDivider: true - }); - - return children; - } - }; - - $scope.commandMenuOptions = (command) => { - const options = [ + $scope.commandMenuOptions = (item) => { + const command = item; + return [ { html: ` Edit`, - click: ($itemScope) => { - const command = $itemScope.command; + click: () => { $scope.openAddOrEditCustomCommandModal(command); } }, { html: ` Clear Cooldowns`, - click: ($itemScope) => { - const command = $itemScope.command; + click: () => { $scope.resetCooldownsForCommand(command); } }, { - html: ` ${command.active ? "Disable Command" : "Enable Command"}`, - click: ($itemScope) => { - const command = $itemScope.command; + html: ` ${item.active ? "Disable Command" : "Enable Command"}`, + click: () => { $scope.toggleCustomCommandActiveState(command); } }, { html: ` Duplicate`, - click: ($itemScope) => { - const command = $itemScope.command; + click: () => { $scope.duplicateCustomCommand(command); } }, { html: ` Delete`, - click: ($itemScope) => { - const command = $itemScope.command; + click: () => { $scope.deleteCustomCommand(command); } - }, - { - text: `Effect Queues...`, - children: $scope.getEffectQueueMenuOptions(command), - hasTopDivider: true } ]; - - return options; }; + + $scope.customCommandHeaders = [ + { + name: "TRIGGER", + icon: "fa-exclamation", + dataField: "trigger", + sortable: true, + cellClass: "command-trigger-cell", + cellTemplate: ` + {{data.trigger}} + + + + + `, + cellController: () => {} + }, + { + name: "COOLDOWNS", + icon: "fa-clock", + cellTemplate: ` + + + {{data.cooldown.global ? data.cooldown.global + "s" : "-" }} + + + {{data.cooldown.user ? data.cooldown.user + "s" : "-" }} + + `, + cellController: () => {} + }, + { + name: "PERMISSIONS", + icon: "lock-alt", + cellTemplate: ` + {{getPermissionType(data)}} + + `, + cellController: ($scope, viewerRolesService) => { + $scope.getPermissionType = (command) => { + + const permissions = command.restrictionData && command.restrictionData.restrictions && + command.restrictionData.restrictions.find(r => r.type === "firebot:permissions"); + + if (permissions) { + if (permissions.mode === "roles") { + return "Roles"; + } else if (permissions.mode === "viewer") { + return "Viewer"; + } + } else { + return "None"; + } + }; + + $scope.getPermissionTooltip = (command) => { + + const permissions = command.restrictionData && command.restrictionData.restrictions && + command.restrictionData.restrictions.find(r => r.type === "firebot:permissions"); + + if (permissions) { + if (permissions.mode === "roles") { + const roleIds = permissions.roleIds; + let output = "None selected"; + if (roleIds.length > 0) { + output = roleIds + .filter(id => viewerRolesService.getRoleById(id) != null) + .map(id => viewerRolesService.getRoleById(id).name) + .join(", "); + } + return `Roles (${output})`; + } else if (permissions.mode === "viewer") { + return `Viewer (${permissions.username ? permissions.username : 'No name'})`; + } + } else { + return "This command is available to everyone"; + } + }; + } + } + ]; }); }()); diff --git a/src/gui/app/controllers/counters.controller.js b/src/gui/app/controllers/counters.controller.js index cf51ad4d6..f996cf392 100644 --- a/src/gui/app/controllers/counters.controller.js +++ b/src/gui/app/controllers/counters.controller.js @@ -14,21 +14,29 @@ { name: "NAME", icon: "fa-user", + dataField: "name", + sortable: true, cellTemplate: `{{data.name}}` }, { name: "VALUE", icon: "fa-tally", + dataField: "value", + sortable: true, cellTemplate: `{{data.value}}` }, { name: "MINIMUM", icon: "fa-arrow-to-bottom", + dataField: "minimum", + sortable: true, cellTemplate: `{{data.minimum ? data.minimum : 'n/a'}}` }, { name: "MAXIMUM", icon: "fa-arrow-to-top", + dataField: "maximum", + sortable: true, cellTemplate: `{{data.maximum ? data.maximum : 'n/a'}}` } ]; diff --git a/src/gui/app/controllers/effect-queues.controller.js b/src/gui/app/controllers/effect-queues.controller.js index 93c302f39..6673ff1dc 100644 --- a/src/gui/app/controllers/effect-queues.controller.js +++ b/src/gui/app/controllers/effect-queues.controller.js @@ -22,12 +22,16 @@ { name: "NAME", icon: "fa-user", + dataField: "name", + sortable: true, cellTemplate: `{{data.name}}`, cellController: () => {} }, { name: "MODE", icon: "fa-bring-forward", + dataField: "mode", + sortable: true, cellTemplate: `{{getQueueModeName(data.mode)}}`, cellController: ($scope) => { $scope.getQueueModeName = (modeId) => { @@ -39,6 +43,8 @@ { name: "INTERVAL/DELAY", icon: "fa-clock", + dataField: "interval", + sortable: true, cellTemplate: `{{(data.mode === 'interval' || data.mode === 'auto') ? (data.interval || 0) + 's' : 'n/a'}}`, cellController: () => {} }, diff --git a/src/gui/app/controllers/events.controller.js b/src/gui/app/controllers/events.controller.js index 1f68cf4e2..123dcab10 100644 --- a/src/gui/app/controllers/events.controller.js +++ b/src/gui/app/controllers/events.controller.js @@ -29,6 +29,8 @@ headerStyles: { 'min-width': '150px' }, + dataField: "name", + sortable: true, cellTemplate: `{{data.name}}`, cellController: () => {} }, @@ -38,6 +40,8 @@ headerStyles: { 'min-width': '100px' }, + dataField: "eventId", + sortable: true, cellTemplate: `{{data.eventId && data.sourceId ? friendlyEventTypeName(data.sourceId, data.eventId) : "No Type"}}`, diff --git a/src/gui/app/controllers/preset-effect-lists.controller.js b/src/gui/app/controllers/preset-effect-lists.controller.js index 1e9866b6b..65c5c57f9 100644 --- a/src/gui/app/controllers/preset-effect-lists.controller.js +++ b/src/gui/app/controllers/preset-effect-lists.controller.js @@ -17,6 +17,8 @@ { name: "NAME", icon: "fa-user", + dataField: "name", + sortable: true, cellTemplate: `{{data.name}}`, cellController: () => {} }, diff --git a/src/gui/app/controllers/timers.controller.js b/src/gui/app/controllers/timers.controller.js index 7ce29a38f..86f9f538c 100644 --- a/src/gui/app/controllers/timers.controller.js +++ b/src/gui/app/controllers/timers.controller.js @@ -29,6 +29,8 @@ headerStyles: { 'min-width': '175px' }, + dataField: "name", + sortable: true, cellTemplate: `{{data.name}}`, cellController: () => {} }, @@ -38,6 +40,8 @@ headerStyles: { 'min-width': '100px' }, + dataField: "interval", + sortable: true, cellTemplate: `{{data.interval}}`, cellController: () => {} }, @@ -111,6 +115,8 @@ headerStyles: { 'min-width': '175px' }, + dataField: "name", + sortable: true, cellTemplate: `{{data.name}}`, cellController: () => {} }, diff --git a/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.html b/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.html index 706030c04..272816a28 100644 --- a/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.html +++ b/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.html @@ -55,20 +55,40 @@ -
- +
+ {{header.name}} + + +
- - TAGS + +
+ + + + + TAGS + + + + +
@@ -78,7 +98,7 @@ @@ -95,6 +115,7 @@
- - + + + +
-
- -
- -
-
- - + -
- -
- - - -
-
-
-
- No custom commands saved. You should make one! :) - -
-
- No custom commands found. -
- - - - - - - - - - - - - - - - - - - - - - - -
TRIGGER COOLDOWNS PERMISSIONS TAGS
- - - - - {{command.trigger}} - - - - - - - - {{command.cooldown.global ? command.cooldown.global + "s" : - "-" }} - - - {{command.cooldown.user ? - command.cooldown.user + "s" : "-" }} - - - {{getPermissionType(command)}} - - - - - -
- {{command.active ? "Enabled" : "Disabled"}} -
-
-
- - - -
- -
-
-
-
+ + Clear All Cooldowns + + +
-
+
Date: Sun, 10 Mar 2024 15:33:14 -0500 Subject: [PATCH 070/113] fix: firebot-item-table column widths changing after dragging to reorder --- src/gui/app/controllers/commands.controller.js | 15 --------------- .../misc/firebot-item-table/firebot-item-table.js | 9 ++++++++- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/gui/app/controllers/commands.controller.js b/src/gui/app/controllers/commands.controller.js index 352f53f2d..8e88d2400 100644 --- a/src/gui/app/controllers/commands.controller.js +++ b/src/gui/app/controllers/commands.controller.js @@ -100,21 +100,6 @@ commandsService.saveAllCustomCommands(commands ?? commandsService.commandsCache.customCommands); }; - $scope.sortableOptions = { - handle: ".dragHandle", - 'ui-preserve-size': true, - stop: (e, ui) => { - console.log(e, ui); - if (sortTagsService.getSelectedSortTag("commands") != null && - (commandsService.customCommandSearch == null || - commandsService.customCommandSearch.length < 1)) { - return; - } - - $scope.saveAllCommands(); - } - }; - $scope.commandMenuOptions = (item) => { const command = item; return [ diff --git a/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.js b/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.js index 1271806a7..9082ba58e 100644 --- a/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.js +++ b/src/gui/app/directives/misc/firebot-item-table/firebot-item-table.js @@ -78,7 +78,14 @@ $ctrl.sortableOptions = { handle: ".dragHandle", 'ui-preserve-size': true, - stop: () => { + stop: (_e, ui) => { + //reset the width of the children that "ui-preserve-size" sets + const item = angular.element(ui.item); + item.children().each(function() { + const $el = angular.element(this); + $el.css("width", ""); + }); + if (sortTagsService.getSelectedSortTag($ctrl.sortTagContext) != null && ($scope.searchQuery == null || $scope.searchQuery.length < 1)) { From aa4a11e4e1e1d626396844845d6cfec4c45b2c8c Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Mon, 11 Mar 2024 16:08:15 -0500 Subject: [PATCH 071/113] feat: overhaul how sort tags are displayed and managed in firebot-item-tables --- .../app/directives/controls/sort-tags-row.js | 99 ++++++++++++------- src/gui/scss/core/_controls.scss | 34 ++++--- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/src/gui/app/directives/controls/sort-tags-row.js b/src/gui/app/directives/controls/sort-tags-row.js index d930a7e0b..e0b50f833 100644 --- a/src/gui/app/directives/controls/sort-tags-row.js +++ b/src/gui/app/directives/controls/sort-tags-row.js @@ -9,30 +9,71 @@ onUpdate: "&" }, template: ` -
- - {{tag.name}} - - - -
+
+
+ +
+ +
`, - controller: function($scope, sortTagsService) { + controller: function($scope, $element, sortTagsService) { const $ctrl = this; + $scope.getSortTags = () => sortTagsService.getSortTagsForItem($ctrl.context, $ctrl.item.sortTags); + + $scope.getSortTagNames = () => $scope.getSortTags().map(t => t.name).join("
"); + + $scope.getOverflowTagCount = () => { + const allTags = $element.find(".sort-tags").children().toArray(); + return Math.max(allTags.reduce((acc, child) => { + const parent = child.parentNode; + if ((child.offsetLeft - parent.offsetLeft > parent.offsetWidth) || + (child.offsetTop - parent.offsetTop > parent.offsetHeight)) { + acc++; + } + return acc; + }, 0), 0); + }; + + $scope.hasOverflow = () => { + return $scope.getOverflowTagCount() > 0; + }; + $scope.sts = sortTagsService; $ctrl.removeSortTag = (tagId) => { @@ -47,20 +88,12 @@ } }; - $ctrl.getSortTagsContextMenu = () => { - if ($ctrl.item.sortTags == null) { - $ctrl.item.sortTags = []; + $ctrl.toggleSortTag = (sortTag) => { + if ($ctrl.item.sortTags.some(id => id === sortTag.id)) { + $ctrl.removeSortTag(sortTag.id); + } else { + $ctrl.addSortTag(sortTag); } - - const sortTags = sortTagsService.getSortTags($ctrl.context).filter(st => !$ctrl.item.sortTags.includes(st.id)); - return sortTags.map(st => { - return { - html: ` ${st.name}`, - click: () => { - $ctrl.addSortTag(st); - } - }; - }); }; $ctrl.$onInit = () => { diff --git a/src/gui/scss/core/_controls.scss b/src/gui/scss/core/_controls.scss index a2d4d7cf1..d5196a80b 100644 --- a/src/gui/scss/core/_controls.scss +++ b/src/gui/scss/core/_controls.scss @@ -829,19 +829,23 @@ table.fb-table-alt { .sort-tags { display: flex; flex-wrap: wrap; - width: 100%; - overflow: auto; - max-height: 45.25px; + flex-basis: 80%; + overflow: hidden; + max-height: 22.25px; + + &.hidden-tags { + visibility: hidden; + } .sort-tag { display: flex; span { - background: $sort-tags-bg; + background: #808080; font-size: 10px; - line-height: 1.8; - border-radius: 15px 0 0 15px; - padding: 1px 0 1px 8px; + line-height: 1.9; + border-radius: 15px; + padding: 1px 8px 1px 8px; } button { @@ -854,12 +858,12 @@ table.fb-table-alt { } } - .sort-tag-add { - border: none; - border-radius: 15px; - padding: 2px 7px 1px 7px; - background: $sort-tags-bg; - font-size: 10px; - line-height: 1.8; - } +} +.sort-tag-add { + border: none; + border-radius: 15px; + padding: 2px 7px 1px 7px; + background: $sort-tags-bg; + font-size: 10px; + line-height: 1.8; } \ No newline at end of file From 5c226eccb525a2770106676d838fb1bbbc516e22 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Mon, 11 Mar 2024 16:12:11 -0500 Subject: [PATCH 072/113] fix: right-align sort tag dropdown menu to more gracefully handle long tag names --- src/gui/app/directives/controls/sort-tag-dropdown.component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/app/directives/controls/sort-tag-dropdown.component.js b/src/gui/app/directives/controls/sort-tag-dropdown.component.js index b4bbe362f..05b1e5db0 100644 --- a/src/gui/app/directives/controls/sort-tag-dropdown.component.js +++ b/src/gui/app/directives/controls/sort-tag-dropdown.component.js @@ -26,7 +26,7 @@
-
+
ARG
@@ -289,6 +292,27 @@

Subcommands


Fallback subcommand:

+
+
+ NOTE: Fallback subcommand will not trigger when no other subcommands exist +
+
+
+ ARG +
+
+ COOLDOWNS +
+
+ PERMISSIONS +
+
+
+
+
{ + }).then((confirmed) => { if (confirmed) { $ctrl.command.simple = !$ctrl.command.simple; $ctrl.command.subCommands = []; @@ -100,7 +100,7 @@ confirmBtnType: "btn-default", cancelLabel: "Not right now", cancelBtnType: "btn-default" - }).then(confirmed => { + }).then((confirmed) => { if (confirmed) { settingsService.setDefaultToAdvancedCommandMode(true); ngToast.create({ @@ -168,7 +168,7 @@ question: `Are you sure you want to delete this subcommand?`, confirmLabel: "Delete", confirmBtnType: "btn-danger" - }).then(confirmed => { + }).then((confirmed) => { if (confirmed) { if (id === "fallback-subcommand") { $ctrl.command.fallbackSubcommand = null; @@ -197,12 +197,13 @@ size: "sm", resolveObj: { arg: () => arg, + hasAnyArgs: () => !!$ctrl.command.subCommands?.length, hasNumberArg: () => $ctrl.command.subCommands && $ctrl.command.subCommands.some(sc => sc.arg === "\\d+"), hasUsernameArg: () => $ctrl.command.subCommands && $ctrl.command.subCommands.some(sc => sc.arg === "@\\w+"), hasFallbackArg: () => $ctrl.command.fallbackSubcommand != null, otherArgNames: () => $ctrl.command.subCommands && $ctrl.command.subCommands.filter(c => !c.regex && (arg ? c.arg !== arg.arg : true)).map(c => c.arg.toLowerCase()) || [] }, - closeCallback: newArg => { + closeCallback: (newArg) => { if (newArg.fallback) { $ctrl.command.fallbackSubcommand = newArg; } else { @@ -226,7 +227,7 @@ question: `Are you sure you want to delete this command?`, confirmLabel: "Delete", confirmBtnType: "btn-danger" - }).then(confirmed => { + }).then((confirmed) => { if (confirmed) { $ctrl.close({ $value: { command: $ctrl.command, action: "delete" } }); } diff --git a/src/gui/app/directives/modals/commands/addOrEditSubcommandModal.js b/src/gui/app/directives/modals/commands/addOrEditSubcommandModal.js index 880679e52..1e2b77ee2 100644 --- a/src/gui/app/directives/modals/commands/addOrEditSubcommandModal.js +++ b/src/gui/app/directives/modals/commands/addOrEditSubcommandModal.js @@ -24,7 +24,7 @@
-
Please select an arg type.
+
{{$ctrl.kindErrorText}}
@@ -53,8 +53,12 @@ controller: function($timeout) { const $ctrl = this; + $ctrl.kindErrorText = ""; $ctrl.nameErrorText = 'Please provide trigger text.'; + $ctrl.kindError = false; + $ctrl.nameError = false; + $timeout(() => { angular.element("#nameField").trigger("focus"); }, 50); @@ -70,11 +74,9 @@ $ctrl.onTypeChange = () => { $ctrl.arg.usage = null; $ctrl.arg.arg = null; + $ctrl.kindError = false; }; - $ctrl.nameError = false; - $ctrl.kindError = false; - $ctrl.argTypes = [ { type: "Custom", @@ -101,7 +103,17 @@ function validateArgType() { const type = $ctrl.arg.type; - return type != null && type.length > 0; + + if (type == null || !type.length) { + $ctrl.kindErrorText = "Please select an arg type."; + return false; + } else if (type === "Fallback" && !$ctrl.resolve.hasAnyArgs) { + $ctrl.kindErrorText = "You must add another arg type before adding a fallback."; + return false; + } + + $ctrl.kindErrorText = ""; + return true; } const numberRegex = "\\d+"; From 22738085922aa6c26311cbd2fa56a2b554348ec2 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 11 Mar 2024 21:29:42 -0400 Subject: [PATCH 074/113] fix(commands): remove duplicate prompt, add name when deleting subcommand (#2458) --- src/gui/app/directives/misc/subcommandRow.js | 11 +--------- .../addOrEditCustomCommandModal.js | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/gui/app/directives/misc/subcommandRow.js b/src/gui/app/directives/misc/subcommandRow.js index dae5322b0..8740dd44e 100644 --- a/src/gui/app/directives/misc/subcommandRow.js +++ b/src/gui/app/directives/misc/subcommandRow.js @@ -204,16 +204,7 @@ }; $ctrl.delete = () => { - utilityService.showConfirmationModal({ - title: "Delete Subcommand", - question: `Are you sure you want to delete this subcommand?`, - confirmLabel: "Delete", - confirmBtnType: "btn-danger" - }).then((confirmed) => { - if (confirmed) { - $ctrl.onDelete({ id: $ctrl.subcommand.id }); - } - }); + $ctrl.onDelete({ id: $ctrl.subcommand.id }); }; $ctrl.edit = () => { diff --git a/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.js b/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.js index 3d1e47519..e00153e69 100644 --- a/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.js +++ b/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.js @@ -163,9 +163,29 @@ }; $ctrl.deleteSubcommand = (id) => { + let name = "fallback"; + + if (id !== "fallback-subcommand") { + const subCmd = $ctrl.command.subCommands.find(c => c.id === id); + + switch (subCmd.type) { + case "Username": + name = "username"; + break; + + case "Number": + name = "number"; + break; + + case "Custom": + name = `"${subCmd.arg}"`; + break; + } + } + utilityService.showConfirmationModal({ title: "Delete Subcommand", - question: `Are you sure you want to delete this subcommand?`, + question: `Are you sure you want to delete the ${name} subcommand?`, confirmLabel: "Delete", confirmBtnType: "btn-danger" }).then((confirmed) => { From 0676e9a3b20e5b12bb6cc9b5913747f4f3929737 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 11 Mar 2024 21:45:55 -0400 Subject: [PATCH 075/113] fix: add message to tag dropdown when no sort tags created --- src/gui/app/directives/controls/sort-tags-row.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gui/app/directives/controls/sort-tags-row.js b/src/gui/app/directives/controls/sort-tags-row.js index e0b50f833..bfe33f084 100644 --- a/src/gui/app/directives/controls/sort-tags-row.js +++ b/src/gui/app/directives/controls/sort-tags-row.js @@ -47,6 +47,7 @@
+
No tags created yet
From 646ded3c081e0e2a3297b10df4cacf4cadb1ad83 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 11 Mar 2024 22:11:54 -0400 Subject: [PATCH 076/113] fix(effects): hide subcommand opts in Cooldown Command when tag option selected (#2462) --- src/backend/effects/builtin/cooldown-command.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/backend/effects/builtin/cooldown-command.js b/src/backend/effects/builtin/cooldown-command.js index 277d3b099..338597f64 100644 --- a/src/backend/effects/builtin/cooldown-command.js +++ b/src/backend/effects/builtin/cooldown-command.js @@ -16,11 +16,11 @@ const model = {
@@ -165,6 +165,18 @@ const model = { } $scope.createSubcommandOptions(); }; + + $scope.typeSelected = () => { + if ($scope.effect.selectionType === "sortTag") { + $scope.effect.commandId = null; + $scope.showSubcommands = false; + $scope.subcommands = []; + $scope.effect.subcommandId = null; + } else { + $scope.effect.sortTagId = null; + } + }; + $scope.commandSelected = (command) => { $scope.effect.commandId = command.id; $scope.getSubcommands(); From 4af867c142a3246b87c3dc23059f06230ac1d5e6 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 Mar 2024 10:54:50 -0400 Subject: [PATCH 077/113] fix: update some displayName uses --- .../chat/commands/chat-command-handler.ts | 4 +-- src/backend/common/user-access.js | 3 +- .../chat/feed items/chat-message.js | 6 ++-- src/gui/app/services/chat-messages.service.js | 32 ++++++++++--------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/backend/chat/commands/chat-command-handler.ts b/src/backend/chat/commands/chat-command-handler.ts index 58fb4566b..01a1ec4ac 100644 --- a/src/backend/chat/commands/chat-command-handler.ts +++ b/src/backend/chat/commands/chat-command-handler.ts @@ -123,13 +123,13 @@ class CommandHandler { const { streamer, bot } = accountAccess.getAccounts(); // check if chat came from the streamer and if we should ignore it. - if (command.ignoreStreamer && firebotChatMessage.username === streamer.displayName) { + if (command.ignoreStreamer && firebotChatMessage.username === streamer.username) { logger.debug("Message came from streamer and this command is set to ignore it"); return false; } // check if chat came from the bot and if we should ignore it. - if (command.ignoreBot && firebotChatMessage.username === bot.displayName) { + if (command.ignoreBot && firebotChatMessage.username === bot.username) { logger.debug("Message came from bot and this command is set to ignore it"); return false; } diff --git a/src/backend/common/user-access.js b/src/backend/common/user-access.js index e06cb0b01..04aaada47 100644 --- a/src/backend/common/user-access.js +++ b/src/backend/common/user-access.js @@ -97,7 +97,8 @@ async function getUserDetails(userId) { frontendCommunicator.send("twitch:chat:user-updated", { id: firebotUserData._id, - username: firebotUserData.displayName, + username: firebotUserData.username, + displayName: firebotUserData.displayName, roles: userRoles, profilePicUrl: firebotUserData.profilePicUrl, active: activeUserHandler.userIsActive(firebotUserData._id) diff --git a/src/gui/app/directives/chat/feed items/chat-message.js b/src/gui/app/directives/chat/feed items/chat-message.js index 07a6b5158..f784d3d74 100644 --- a/src/gui/app/directives/chat/feed items/chat-message.js +++ b/src/gui/app/directives/chat/feed items/chat-message.js @@ -393,13 +393,13 @@ return { html: html, click: () => { - $ctrl.messageActionSelected(a.name, message.username, message.userId, message.id, message.rawText); + $ctrl.messageActionSelected(a.name, message.username, message.userId, message.displayName, message.id, message.rawText); } }; })]; }; - $ctrl.messageActionSelected = (action, username, userId, msgId, rawText) => { + $ctrl.messageActionSelected = (action, username, userId, displayName, msgId, rawText) => { switch (action.toLowerCase()) { case "delete message": chatMessagesService.deleteMessage(msgId); @@ -459,7 +459,7 @@ updateChatField(`!quote add @${username} ${rawText}`); break; case "spotlight message": - chatMessagesService.highlightMessage(username, rawText); + chatMessagesService.highlightMessage(username, userId, displayName, rawText); break; case "shoutout": updateChatField(`!so @${username}`); diff --git a/src/gui/app/services/chat-messages.service.js b/src/gui/app/services/chat-messages.service.js index c5efb3441..49961f45b 100644 --- a/src/gui/app/services/chat-messages.service.js +++ b/src/gui/app/services/chat-messages.service.js @@ -50,7 +50,7 @@ // Return User List with people in role filtered out. service.getFilteredChatUserList = function() { - return service.chatUsers.filter((user) => !user.disableViewerList); + return service.chatUsers.filter(user => !user.disableViewerList); }; // Clear User List @@ -61,7 +61,7 @@ // Full Chat User Refresh // This replaces chat users with a fresh list pulled from the backend in the chat processor file. service.chatUserRefresh = function(data) { - const users = data.chatUsers.map(u => { + const users = data.chatUsers.map((u) => { u.id = u.userId; return u; }); @@ -94,7 +94,7 @@ const chatQueue = service.chatQueue; let cachedUserName = null; - chatQueue.forEach(message => { + chatQueue.forEach((message) => { // If user id matches, then mark the message as deleted. if (message.user_id === data.user_id) { if (cachedUserName == null) { @@ -120,9 +120,11 @@ } }; - service.highlightMessage = (username, rawText) => { + service.highlightMessage = (username, userId, displayName, rawText) => { backendCommunicator.fireEvent("highlight-message", { username: username, + userId: userId, + displayName: displayName, messageText: rawText }); }; @@ -297,7 +299,7 @@ // } // }, 250); - backendCommunicator.on("twitch:chat:rewardredemption", redemption => { + backendCommunicator.on("twitch:chat:rewardredemption", (redemption) => { if (settingsService.getRealChatFeed()) { const redemptionItem = { @@ -323,15 +325,15 @@ } }); - backendCommunicator.on("twitch:chat:user-joined", user => { + backendCommunicator.on("twitch:chat:user-joined", (user) => { service.chatUserJoined(user); }); - backendCommunicator.on("twitch:chat:user-left", id => { + backendCommunicator.on("twitch:chat:user-left", (id) => { service.chatUserLeft(({ id })); }); - backendCommunicator.on("twitch:chat:user-updated", user => { + backendCommunicator.on("twitch:chat:user-updated", (user) => { service.chatUserUpdated(user); }); @@ -379,25 +381,25 @@ service.chatAlertMessage(`${modUsername} cleared the chat.`); }); - backendCommunicator.on("twitch:chat:user-active", id => { + backendCommunicator.on("twitch:chat:user-active", (id) => { const user = service.chatUsers.find(u => u.id === id); if (user != null) { user.active = true; } }); - backendCommunicator.on("twitch:chat:user-inactive", id => { + backendCommunicator.on("twitch:chat:user-inactive", (id) => { const user = service.chatUsers.find(u => u.id === id); if (user != null) { user.active = false; } }); - backendCommunicator.on("twitch:chat:autodisconnected", autodisconnected => { + backendCommunicator.on("twitch:chat:autodisconnected", (autodisconnected) => { service.autodisconnected = autodisconnected; }); - backendCommunicator.on("twitch:chat:message", chatMessage => { + backendCommunicator.on("twitch:chat:message", (chatMessage) => { if (chatMessage.tagged) { soundService.playChatNotification(); @@ -451,7 +453,7 @@ const showFfzEmotes = settingsService.getShowFfzEmotes(); const showSevenTvEmotes = settingsService.getShowSevenTvEmotes(); - service.filteredEmotes = service.allEmotes.filter(e => { + service.filteredEmotes = service.allEmotes.filter((e) => { if (showBttvEmotes !== true && e.origin === "BTTV") { return false; } @@ -477,7 +479,7 @@ // This handles clears, deletions, timeouts, etc... Anything that isn't a message. listenerService.registerListener( { type: listenerService.ListenerType.CHAT_UPDATE }, - data => { + (data) => { if (settingsService.getRealChatFeed() === true) { service.chatUpdateHandler(data); } @@ -488,7 +490,7 @@ // Receives event from main process that connection has been established or disconnected. listenerService.registerListener( { type: listenerService.ListenerType.CHAT_CONNECTION_STATUS }, - isChatConnected => { + (isChatConnected) => { if (isChatConnected) { service.chatQueue = []; } From c2bbce6c74e1d584a3d03e76b9b20e9a8f26602d Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 Mar 2024 11:23:02 -0400 Subject: [PATCH 078/113] fix(commands/events): extension messages now trigger commands/events (#2433) --- src/backend/twitch-api/pubsub/pubsub-client.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backend/twitch-api/pubsub/pubsub-client.js b/src/backend/twitch-api/pubsub/pubsub-client.js index 806166a72..f06ef525b 100644 --- a/src/backend/twitch-api/pubsub/pubsub-client.js +++ b/src/backend/twitch-api/pubsub/pubsub-client.js @@ -5,6 +5,7 @@ const frontendCommunicator = require("../../common/frontend-communicator"); const firebotDeviceAuthProvider = require("../../auth/firebot-device-auth-provider"); const chatRolesManager = require("../../roles/chat-roles-manager"); const { PubSubClient } = require("@twurple/pubsub"); +const chatCommandHandler = require("../../chat/commands/chat-command-handler"); /**@type {PubSubClient} */ let pubSubClient; @@ -172,6 +173,8 @@ async function createClient() { ); frontendCommunicator.send("twitch:chat:message", firebotChatMessage); + chatCommandHandler.handleChatMessage(firebotChatMessage); + twitchEventsHandler.chatMessage.triggerChatMessage(firebotChatMessage); } }); listeners.push(chatRoomListener); From 6f54426a37fd783f90f9810beade06af428d82c0 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 Mar 2024 11:50:53 -0400 Subject: [PATCH 079/113] chore: remove unused utilityService ref --- src/gui/app/directives/misc/subcommandRow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/app/directives/misc/subcommandRow.js b/src/gui/app/directives/misc/subcommandRow.js index 8740dd44e..94e5aafd9 100644 --- a/src/gui/app/directives/misc/subcommandRow.js +++ b/src/gui/app/directives/misc/subcommandRow.js @@ -160,7 +160,7 @@
`, - controller: function(viewerRolesService, utilityService) { + controller: function(viewerRolesService) { const $ctrl = this; $ctrl.subcommandTypeTitle = ""; From f0f72f2d6e8ff41d5e8926131194277dfd609187 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 Mar 2024 11:58:19 -0400 Subject: [PATCH 080/113] fix(commands): fix subcommand symbol/tooltip after deleting another subcommand (#2460) --- src/gui/app/directives/misc/subcommandRow.js | 33 ++++++++++---------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/gui/app/directives/misc/subcommandRow.js b/src/gui/app/directives/misc/subcommandRow.js index 94e5aafd9..7f210879d 100644 --- a/src/gui/app/directives/misc/subcommandRow.js +++ b/src/gui/app/directives/misc/subcommandRow.js @@ -15,8 +15,8 @@
{{$ctrl.subcommand.regex || $ctrl.subcommand.fallback ? ($ctrl.subcommand.usage || "").split(" ")[0] : $ctrl.subcommand.arg}} - - + +
@@ -163,7 +163,21 @@ controller: function(viewerRolesService) { const $ctrl = this; - $ctrl.subcommandTypeTitle = ""; + $ctrl.subcommandTypeTitle = () => { + if ($ctrl.fullyEditable) { + if (!$ctrl.subcommand.regex) { + return "Custom"; + } else if ($ctrl.subcommand.fallback) { + return "Fallback"; + } else if ($ctrl.subcommand.arg === '\\d+') { + return "Number"; + } else if ($ctrl.subcommand.arg === '@\\w+') { + return "Username"; + } + } + + return ""; + }; $ctrl.compiledUsage = ""; $ctrl.onUsageChange = () => { @@ -187,19 +201,6 @@ if ($ctrl.subcommand.minArgs > 0) { $ctrl.adjustedMinArgs = $ctrl.subcommand.minArgs - 1; } - - if ($ctrl.fullyEditable) { - if (!$ctrl.subcommand.regex) { - $ctrl.subcommandTypeTitle = "Custom"; - } else if ($ctrl.subcommand.fallback) { - $ctrl.subcommandTypeTitle = "Fallback"; - } else if ($ctrl.subcommand.arg === '\\d+') { - $ctrl.subcommandTypeTitle = "Number"; - } else if ($ctrl.subcommand.arg === '@\\w+') { - $ctrl.subcommandTypeTitle = "Username"; - } - console.log($ctrl.subcommand.arg); - } } }; From 3924428b592fd341e763c638ea62ca854fd22e9e Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 Mar 2024 12:15:21 -0400 Subject: [PATCH 081/113] feat(effects): add fallback subcommand to Cooldown Command effect (#2461) --- src/backend/effects/builtin/cooldown-command.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/backend/effects/builtin/cooldown-command.js b/src/backend/effects/builtin/cooldown-command.js index 338597f64..6d18d684d 100644 --- a/src/backend/effects/builtin/cooldown-command.js +++ b/src/backend/effects/builtin/cooldown-command.js @@ -144,7 +144,7 @@ const model = { const options = {}; if ($scope.subcommands) { $scope.subcommands.forEach((sc) => { - options[sc.id] = sc.regex || sc.fallback ? (sc.usage || "").split(" ")[0] : sc.arg; + options[sc.id] = sc.regex || sc.fallback ? (sc.usage || (sc.fallback ? "Fallback" : "")).split(" ")[0] : sc.arg; }); } $scope.subcommandOptions = options; @@ -163,6 +163,11 @@ const model = { if (command.subCommands) { $scope.subcommands = command.subCommands; } + + if (command.fallbackSubcommand) { + $scope.subcommands.push(command.fallbackSubcommand); + } + $scope.createSubcommandOptions(); }; From 03acbc9f7ce4dc07b3b8150ce84f1cb1b641dc5e Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 Mar 2024 16:21:55 -0400 Subject: [PATCH 082/113] feat(effects): overlay refresh/Clear Effects forces video/sound effects to continue (#2116) --- src/backend/effects/builtin/play-sound.js | 33 +++++++++++++++++++---- src/backend/effects/builtin/play-video.js | 31 ++++++++++++++++++--- src/server/http-server-manager.js | 1 + 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/backend/effects/builtin/play-sound.js b/src/backend/effects/builtin/play-sound.js index 2d244a96f..307c5accc 100644 --- a/src/backend/effects/builtin/play-sound.js +++ b/src/backend/effects/builtin/play-sound.js @@ -79,7 +79,7 @@ const playSound = { $scope.effect.volume = 5; } }, - optionsValidator: effect => { + optionsValidator: (effect) => { const errors = []; if (effect.soundType === "local" || effect.soundType == null) { @@ -96,7 +96,7 @@ const playSound = { return errors; }, - onTriggerEvent: async event => { + onTriggerEvent: async (event) => { const effect = event.effect; if (effect.soundType == null) { @@ -159,8 +159,31 @@ const playSound = { const duration = await frontendCommunicator.fireEventAsync("getSoundDuration", { path: data.isUrl ? data.url : data.filepath }); - const durationInMils = (Math.round(duration) || 0) * 1000; - await wait(durationInMils); + + if (selectedOutputDevice.deviceId === "overlay") { + let currentDuration = 0; + let returnNow = false; + const overlayInstance = effect.overlayInstance ?? "Default"; + + webServer.on("overlay-connected", (instance) => { + if (instance === overlayInstance) { + returnNow = true; + } + }); + + while (currentDuration < duration) { + if (returnNow) { + return true; + } + currentDuration += 1; + + await wait(1000); + } + } else { + const durationInMils = (Math.round(duration) || 0) * 1000; + await wait(durationInMils); + } + return true; } catch (error) { return true; @@ -178,7 +201,7 @@ const playSound = { }, event: { name: "sound", - onOverlayEvent: event => { + onOverlayEvent: (event) => { const data = event; const token = encodeURIComponent(data.resourceToken); const resourcePath = `http://${ diff --git a/src/backend/effects/builtin/play-video.js b/src/backend/effects/builtin/play-video.js index c3ad0845e..372a79bca 100644 --- a/src/backend/effects/builtin/play-video.js +++ b/src/backend/effects/builtin/play-video.js @@ -361,6 +361,31 @@ const playVideo = { } } + const overlayInstance = data.overlayInstance ?? "Default"; + + async function waitFunction(duration) { + let currentDuration = 0; + let returnNow = false; + + function overlayConnectedCallback(instance) { + if (instance === overlayInstance) { + webServer.off("overlay-connected", overlayConnectedCallback); + returnNow = true; + } + } + + webServer.on("overlay-connected", overlayConnectedCallback); + + while (currentDuration < duration) { + if (returnNow) { + return; + } + + currentDuration += 1; + await wait(1000); + } + } + if (effect.videoType === "Twitch Clip" || effect.videoType === "Random Twitch Clip") { const twitchApi = require("../../twitch-api/api"); const client = twitchApi.streamerClient; @@ -436,7 +461,7 @@ const playVideo = { }); if (effect.wait) { - await util.wait(effectDuration * 1000); + await waitFunction(effectDuration); } return true; @@ -484,7 +509,7 @@ const playVideo = { function callbackDuration({name, data}) { if (name === `play-video:callback:duration:${resourceToken}`) { webServer.off("overlay-event", callbackDuration); - wait(data.duration).then(resolve); + waitFunction(Math.ceil(data.duration / 1000)).then(resolve); } } @@ -497,7 +522,7 @@ const playVideo = { webServer.on("overlay-event", callbackDuration); }); } else { - waitPromise = wait(data.videoDuration * 1000); + waitPromise = waitFunction(data.videoDuration); } } diff --git a/src/server/http-server-manager.js b/src/server/http-server-manager.js index 952008c77..690bac125 100644 --- a/src/server/http-server-manager.js +++ b/src/server/http-server-manager.js @@ -186,6 +186,7 @@ class HttpServerManager extends EventEmitter { eventManager.triggerEvent("firebot", "overlay-connected", { instanceName: event.data.instanceName }); + this.emit("overlay-connected", event.data.instanceName); } else { this.emit("overlay-event", event); } From 4c498ac772a827253ecc85d029b1e92aae0b066f Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 Mar 2024 18:33:10 -0400 Subject: [PATCH 083/113] feat(effects): add option for continue on overlay refresh (#2116) --- src/backend/common/settings-access.js | 16 ++++++++-- src/backend/effects/builtin/play-sound.js | 3 +- src/backend/effects/builtin/play-video.js | 31 ++++++++++--------- .../settings/categories/overlay-settings.js | 13 ++++++++ src/gui/app/services/settings.service.js | 9 ++++++ 5 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/backend/common/settings-access.js b/src/backend/common/settings-access.js index 08756d34c..aceeedd67 100644 --- a/src/backend/common/settings-access.js +++ b/src/backend/common/settings-access.js @@ -57,13 +57,16 @@ function handleCorruptSettingsFile() { })); } -function getDataFromFile(path, forceCacheUpdate = false) { +function getDataFromFile(path, forceCacheUpdate = false, defaultValue = undefined) { try { if (settingsCache[path] == null || forceCacheUpdate) { const data = getSettingsFile().getData(path); - settingsCache[path] = data; + settingsCache[path] = data ?? defaultValue; } } catch (err) { + if (defaultValue !== undefined) { + settingsCache[path] = defaultValue; + } if (err.name !== "DataError") { logger.warn(err); if ( @@ -161,6 +164,15 @@ settings.setOverlayInstances = function(ois) { pushDataToFile("/settings/overlayInstances", ois); }; +settings.getForceOverlayEffectsToContinueOnRefresh = function() { + const forceOverlayEffectsToContinueOnRefresh = getDataFromFile("/settings/forceOverlayEffectsToContinueOnRefresh", false, true); + return forceOverlayEffectsToContinueOnRefresh === true; +}; + +settings.setForceOverlayEffectsToContinueOnRefresh = function(value) { + pushDataToFile("/settings/forceOverlayEffectsToContinueOnRefresh", value); +}; + settings.backupKeepAll = function() { const backupKeepAll = getDataFromFile("/settings/backupKeepAll"); return backupKeepAll != null ? backupKeepAll : false; diff --git a/src/backend/effects/builtin/play-sound.js b/src/backend/effects/builtin/play-sound.js index 307c5accc..cc0b6630f 100644 --- a/src/backend/effects/builtin/play-sound.js +++ b/src/backend/effects/builtin/play-sound.js @@ -160,7 +160,8 @@ const playSound = { path: data.isUrl ? data.url : data.filepath }); - if (selectedOutputDevice.deviceId === "overlay") { + if (selectedOutputDevice.deviceId === "overlay" + && settings.getForceOverlayEffectsToContinueOnRefresh() === true) { let currentDuration = 0; let returnNow = false; const overlayInstance = effect.overlayInstance ?? "Default"; diff --git a/src/backend/effects/builtin/play-video.js b/src/backend/effects/builtin/play-video.js index 372a79bca..2dc4999ad 100644 --- a/src/backend/effects/builtin/play-video.js +++ b/src/backend/effects/builtin/play-video.js @@ -364,25 +364,26 @@ const playVideo = { const overlayInstance = data.overlayInstance ?? "Default"; async function waitFunction(duration) { - let currentDuration = 0; - let returnNow = false; + if (settings.getForceOverlayEffectsToContinueOnRefresh() === true) { + let currentDuration = 0; + let returnNow = false; - function overlayConnectedCallback(instance) { - if (instance === overlayInstance) { - webServer.off("overlay-connected", overlayConnectedCallback); - returnNow = true; - } - } + webServer.on("overlay-connected", (instance) => { + if (instance === overlayInstance) { + returnNow = true; + } + }); - webServer.on("overlay-connected", overlayConnectedCallback); + while (currentDuration < duration) { + if (returnNow) { + return; + } - while (currentDuration < duration) { - if (returnNow) { - return; + currentDuration += 1; + await wait(1000); } - - currentDuration += 1; - await wait(1000); + } else { + await wait(duration * 1000); } } diff --git a/src/gui/app/directives/settings/categories/overlay-settings.js b/src/gui/app/directives/settings/categories/overlay-settings.js index 1566b45f6..9709db5fe 100644 --- a/src/gui/app/directives/settings/categories/overlay-settings.js +++ b/src/gui/app/directives/settings/categories/overlay-settings.js @@ -37,6 +37,19 @@ /> + + + + Date: Wed, 13 Mar 2024 17:51:43 -0500 Subject: [PATCH 084/113] feat: "magic" variable category listing potentially relevant custom variables, effect outputs, and preset list args #2466 --- .../conditional-effects/switch-statement.js | 178 ++++++++++++++++++ src/backend/effects/builtin/show-text.js | 5 +- src/gui/app/directives/controls/effectList.js | 155 +++++++++++++-- .../directives/misc/replaceVariableMenu.js | 92 ++++++++- src/gui/app/directives/misc/subcommandRow.js | 1 + .../add-edit-channel-reward.js | 7 +- .../add-or-edit-custom-quick-action-modal.js | 5 +- .../addOrEditCustomCommandModal.html | 1 + .../editSystemCommandModal.html | 1 + .../modals/counters/add-edit-counter-modal.js | 27 ++- .../add-edit-preset-effect-list-modal.js | 7 +- .../modals/events/addOrEditEventModal.js | 15 +- .../addOrEditHotkey/addOrEditHotkeyModal.html | 8 +- .../add-or-edit-scheduled-task-modal.js | 9 +- .../addOrEditTimer/addOrEditTimerModal.js | 9 +- src/gui/scss/core/_global.scss | 5 +- 16 files changed, 490 insertions(+), 35 deletions(-) create mode 100644 src/backend/effects/builtin/conditional-effects/switch-statement.js diff --git a/src/backend/effects/builtin/conditional-effects/switch-statement.js b/src/backend/effects/builtin/conditional-effects/switch-statement.js new file mode 100644 index 000000000..5325e78ac --- /dev/null +++ b/src/backend/effects/builtin/conditional-effects/switch-statement.js @@ -0,0 +1,178 @@ +"use strict"; + +const { EffectCategory } = require('../../../../shared/effect-constants'); +const effectRunner = require("../../../common/effect-runner"); +const conditionManager = require("./conditions/condition-manager"); +const builtinConditionTypeLoader = require("./conditions/builtin-condition-loader"); + +builtinConditionTypeLoader.registerConditionTypes(); + +const model = { + definition: { + id: "firebot:conditional-effects", + name: "Switch Statement", + description: "Conditionally run effects based on a value", + categories: [EffectCategory.ADVANCED, EffectCategory.SCRIPTING], + icon: "fad fa-ballot-check", + dependencies: [] + }, + globalSettings: {}, + optionsTemplate: ` + + +
+
+ + +
Then run the following effects:
+ + +
+ +
+
+
+
+ + + +
+ + +
+ If none of the above conditions pass, run the following effects: +
+ +
+ +
+
+ + + + + `, + optionsController: ($scope, utilityService) => { + + $scope.sortableOptions = { + handle: ".dragHandle", + stop: () => {} + }; + + $scope.openFirst = false; + if ($scope.effect.ifs == null) { + $scope.effect.ifs = [{ + conditionData: null, + effectData: null + }]; + $scope.openFirst = true; + } + + $scope.addIf = () => { + $scope.effect.ifs.push({ + conditionData: null, + effectData: null + }); + }; + + $scope.deleteClauseAtIndex = $index => { + utilityService.showConfirmationModal({ + title: "Remove Clause", + question: `Are you sure you want to remove this ${$index === 0 ? 'IF' : 'IF ELSE'} clause?`, + confirmLabel: "Remove", + confirmBtnType: "btn-danger" + }).then(confirmed => { + if (confirmed) { + $scope.effect.ifs.splice($index, 1); + } + }); + }; + + $scope.effectListUpdated = (effects, index) => { + const ifCondition = $scope.effect.ifs[index]; + if (ifCondition) { + ifCondition.effectData = effects; + } + }; + + $scope.otherwiseEffectListUpdated = (effects) => { + $scope.effect.otherwiseEffectData = effects; + }; + }, + optionsValidator: () => { + const errors = []; + return errors; + }, + onTriggerEvent: event => { + return new Promise(async (resolve) => { + // What should this do when triggered. + const { effect, trigger, outputs } = event; + + let effectsToRun = null; + if (effect.ifs != null) { + for (const ifCondition of effect.ifs) { + if (ifCondition.conditionData == null || ifCondition.effectData == null) { + continue; + } + + const didPass = await conditionManager.runConditions(ifCondition.conditionData, { + ...trigger, + effectOutputs: outputs + }); + if (didPass) { + effectsToRun = ifCondition.effectData; + break; + } + } + } + + if (effectsToRun == null) { + effectsToRun = effect.otherwiseEffectData; + } + + if (effectsToRun != null) { + const processEffectsRequest = { + trigger: event.trigger, + effects: effectsToRun, + outputs: outputs + }; + + effectRunner.processEffects(processEffectsRequest) + .then(result => { + if (result != null && result.success === true) { + if (result.stopEffectExecution) { + return resolve({ + success: true, + outputs: effect.bubbleOutputs ? result.outputs : undefined, + execution: { + stop: true, + bubbleStop: true + } + }); + } + } + resolve({ + success: true, + outputs: effect.bubbleOutputs ? result?.outputs : undefined + }); + }); + } else { + resolve(true); + } + }); + } +}; + +module.exports = model; diff --git a/src/backend/effects/builtin/show-text.js b/src/backend/effects/builtin/show-text.js index 667c4236a..3ec9f6fe1 100644 --- a/src/backend/effects/builtin/show-text.js +++ b/src/backend/effects/builtin/show-text.js @@ -180,15 +180,14 @@ const showText = { $scope.editor = editor; }; - $scope.onVariableInsert = (variable) => { + $scope.onVariableInsert = (text) => { if ($scope.editor == null) { return; } $scope.editor.summernote('restoreRange'); $scope.editor.summernote("focus"); $timeout(() => { - const display = variable.usage ? variable.usage : variable.handle; - $scope.editor.summernote("insertText", `$${display}`); + $scope.editor.summernote("insertText", text); }, 100); }; diff --git a/src/gui/app/directives/controls/effectList.js b/src/gui/app/directives/controls/effectList.js index 9f1caddf1..dca23fe4f 100644 --- a/src/gui/app/directives/controls/effectList.js +++ b/src/gui/app/directives/controls/effectList.js @@ -201,7 +201,7 @@ ctrl.effectsData.id = uuidv1(); } - ctrl.effectsData.list.forEach(e => { + ctrl.effectsData.list.forEach((e) => { if (e.active == null) { e.active = true; } @@ -365,7 +365,7 @@ function getSharedEffects(code) { return $http.get(`https://bytebin.lucko.me/${code}`) - .then(resp => { + .then((resp) => { if (resp.status === 200) { return JSON.parse(unescape(JSON.stringify(resp.data))); } @@ -383,7 +383,7 @@ saveText: "Add", inputPlaceholder: "Enter code", validationFn: (shareCode) => { - return new Promise(async resolve => { + return new Promise(async (resolve) => { if (shareCode == null || shareCode.trim().length < 1) { resolve(false); } @@ -414,7 +414,7 @@ effectDefinitions = await effectHelperService.getAllEffectDefinitions(); }; - ctrl.getEffectNameById = id => { + ctrl.getEffectNameById = (id) => { if (!effectDefinitions || effectDefinitions.length < 1) { return ""; } @@ -527,7 +527,7 @@ objectCopyHelper.copyEffects(ctrl.effectsData.list); }; - ctrl.openNewEffectModal = index => { + ctrl.openNewEffectModal = (index) => { utilityService.showModal({ component: "addNewEffectModal", backdrop: true, @@ -536,7 +536,7 @@ trigger: () => ctrl.trigger, triggerMeta: () => ctrl.triggerMeta }, - closeCallback: resp => { + closeCallback: (resp) => { if (resp == null) { return; } @@ -559,8 +559,141 @@ }); }; + function mergeArraysWithoutDuplicates(initialArray, arrayToAdd, keyToCheck) { + const nonDupes = arrayToAdd.filter((item) => { + return !initialArray.some((i) => { + return i[keyToCheck] === item[keyToCheck]; + }); + }); + return [...initialArray, ...nonDupes]; + } + + function stringCanBeShorthand(str) { + return /^[a-zA-Z]{3,}$/.test(str); + } + + function checkEffectListForMagicVariables(effects, ignoreEffectId) { + const magicVariables = { + customVariables: [], + effectOutputs: [] + }; + + if (!effects || !Array.isArray(effects)) { + return; + } + + for (const effect of effects) { + if (effect == null || typeof (effect) !== "object" || effect.id === ignoreEffectId) { + continue; + } + + if (effect.type === "firebot:customvariable" || effect.name?.length) { + const canBeShorthand = stringCanBeShorthand(effect.name); + magicVariables.customVariables = mergeArraysWithoutDuplicates(magicVariables.customVariables, [{ + name: effect.name, + handle: canBeShorthand ? `$$${effect.name}` : `$customVariable[${effect.name}]`, + effectLabel: effect.effectLabel, + examples: [ + ...(canBeShorthand ? [ + { + handle: `$$${effect.name}["path", "to", "property"]`, + description: `Access a property of "${effect.name}"` + }, + { + handle: `$customVariable[${effect.name}]`, + description: `Long hand version of "${effect.name}"` + } + ] + : []), + { + handle: `$customVariable[${effect.name}, "path.to.property"]`, + description: `Access a property of "${effect.name}" using long hand` + } + ] + }], "name"); + continue; + } + + const effectDefinition = effectDefinitions.find(e => e.id === effect.type); + if (effectDefinition != null && effectDefinition.outputs?.length) { + const customOutputNames = effect.outputNames || {}; + + const outputs = effectDefinition.outputs.map((output) => { + const name = customOutputNames[output.defaultName] ?? output.defaultName; + const canBeShorthand = stringCanBeShorthand(name); + return { + name, + handle: canBeShorthand ? `$&${name}` : `$effectOutput[${name}]`, + label: output.label, + description: output.description, + effectLabel: `${effectDefinition.name}${effect.effectLabel ? ` (${effect.effectLabel})` : ""}`, + examples: [ + ...(canBeShorthand ? [ + { + handle: `$&${name}["path", "to", "property"]`, + description: `Access a property of "${name}"` + }, + { + handle: `$effectOutput[${name}]`, + description: `Long hand version of "${name}"` + } + ] + : []), + { + handle: `$effectOutput[${name}, "path.to.property"]`, + description: `Access a property of "${name}" using long hand` + } + ] + }; + }); + + magicVariables.effectOutputs = mergeArraysWithoutDuplicates(magicVariables.effectOutputs, outputs, "name"); + + } + + for (const value of Object.values(effect)) { + if (Array.isArray(value)) { + const result = checkEffectListForMagicVariables(value, ignoreEffectId); + magicVariables.customVariables = mergeArraysWithoutDuplicates(magicVariables.customVariables, result.customVariables, "name"); + magicVariables.effectOutputs = mergeArraysWithoutDuplicates(magicVariables.effectOutputs, result.effectOutputs, "name"); + } + } + } + + return magicVariables; + } + + + function determineMagicVariables(ignoreEffectId) { + const magicVariables = { + customVariables: [], + effectOutputs: [], + presetListArgs: ctrl.triggerMeta?.presetListArgs?.map((a) => { + const canBeShorthand = stringCanBeShorthand(a.name); + return { + name: a.name, + handle: canBeShorthand ? `$#${a.name}` : `$presetListArg[${a.name}]`, + examples: canBeShorthand ? [ + { + handle: `$presetListArg[${a.name}]`, + description: "Long hand version of the preset list argument" + } + ] : undefined + }; + }) || [] + }; + + const effectsToCheck = ctrl.triggerMeta?.rootEffects?.list || ctrl.effectsData.list; + const effectsResult = checkEffectListForMagicVariables(effectsToCheck, ignoreEffectId); + magicVariables.customVariables = mergeArraysWithoutDuplicates(magicVariables.customVariables, effectsResult.customVariables, "name"); + magicVariables.effectOutputs = mergeArraysWithoutDuplicates(magicVariables.effectOutputs, effectsResult.effectOutputs, "name"); + + return magicVariables; + } + ctrl.openEditEffectModal = (effect, index, trigger, isNew) => { - utilityService.showEditEffectModal(effect, index, trigger, response => { + const magicVariables = determineMagicVariables(effect.id); + utilityService.showEditEffectModal(effect, index, trigger, (response) => { if (response.action === "add") { ctrl.effectsData.list.splice(index + 1, 0, response.effect); } else if (response.action === "update") { @@ -569,7 +702,7 @@ ctrl.removeEffectAtIndex(response.index); } ctrl.effectsUpdate(); - }, ctrl.triggerMeta, isNew); + }, { ...(ctrl.triggerMeta ?? {}), magicVariables }, isNew); }; //effect queue @@ -627,14 +760,14 @@ ctrl.showAddEditEffectQueueModal = (queueId) => { effectQueuesService.showAddEditEffectQueueModal(queueId) - .then(id => { + .then((id) => { ctrl.effectsData.queue = id; }); }; ctrl.showDeleteEffectQueueModal = (queueId) => { effectQueuesService.showDeleteEffectQueueModal(queueId) - .then(confirmed => { + .then((confirmed) => { if (confirmed) { ctrl.effectsData.queue = undefined; } @@ -649,7 +782,7 @@ saveText: "Save", inputPlaceholder: "Enter secs", validationFn: (value) => { - return new Promise(resolve => { + return new Promise((resolve) => { if (value == null || value < 0) { return resolve(false); } diff --git a/src/gui/app/directives/misc/replaceVariableMenu.js b/src/gui/app/directives/misc/replaceVariableMenu.js index 475612a58..5e6e3d8f4 100644 --- a/src/gui/app/directives/misc/replaceVariableMenu.js +++ b/src/gui/app/directives/misc/replaceVariableMenu.js @@ -26,6 +26,14 @@ $scope.variables = []; + $scope.magicVariables = { + customVariables: [], + effectOutputs: [], + presetListArgs: [] + }; + + $scope.hasMagicVariables = false; + $scope.activeCategory = "common"; $scope.setActiveCategory = (category) => { $scope.activeCategory = category; @@ -33,7 +41,9 @@ $scope.categories = Object.values(VariableCategory); $scope.searchUpdated = () => { - $scope.activeCategory = null; + if ($scope.activeCategory !== "magic") { + $scope.activeCategory = null; + } }; const parseMarkdown = (text) => { @@ -58,6 +68,11 @@ function getVariables() { const { trigger, triggerMeta } = findTriggerDataScope(); + if (triggerMeta?.magicVariables) { + $scope.magicVariables = triggerMeta.magicVariables; + $scope.hasMagicVariables = Object.values($scope.magicVariables).some(v => v.length > 0); + } + if (!$scope.disableVariableMenu) { $scope.variables = replaceVariableService.getVariablesForTrigger({ type: trigger, @@ -99,23 +114,26 @@ } }; - $scope.addVariable = (variable) => { + $scope.insertText = (text) => { if ($scope.onVariableInsert != null) { - $scope.onVariableInsert({ variable: variable}); + $scope.onVariableInsert(text); $scope.toggleMenu(); } else { const currentModel = $scope.modelValue ? $scope.modelValue : ""; const insertIndex = $element.prop("selectionStart") || currentModel.length; - const display = variable.usage ? variable.usage : variable.handle; - - const updatedModel = insertAt(currentModel, `$${display}`, insertIndex); + const updatedModel = insertAt(currentModel, text, insertIndex); $scope.modelValue = updatedModel; } }; + $scope.addVariable = (variable) => { + const display = variable.usage ? variable.usage : variable.handle; + $scope.insertText(`$${display}`); + }; + }, link: function(scope, element) { @@ -146,7 +164,7 @@ } const menu = angular.element(` -
+
@@ -157,6 +175,14 @@
Categories
+
+
Magic
+
All
@@ -164,8 +190,8 @@
{{category}}
-
-
+
+
\${{variable.usage ? variable.usage : variable.handle}}
@@ -177,6 +203,54 @@
+
+ +
+
Custom Variables
+
+
{{variable.handle}}
+
+ +
+
{{example.handle}}
+
+
+
+
+
+
+ +
+
Effect Outputs
+
+
{{variable.handle}}
+
{{variable.description}}
+
Effect: {{variable.effectLabel}}
+
+ +
+
{{example.handle}}
+
+
+
+
+
+
+ +
+
Preset List Args
+
+
{{variable.handle}}
+
+
+
` diff --git a/src/gui/app/directives/misc/subcommandRow.js b/src/gui/app/directives/misc/subcommandRow.js index 8740dd44e..9fd38d9ee 100644 --- a/src/gui/app/directives/misc/subcommandRow.js +++ b/src/gui/app/directives/misc/subcommandRow.js @@ -143,6 +143,7 @@ header="What should this subcommand do?" effects="$ctrl.subcommand.effects" trigger="command" + trigger-meta="{ rootEffects: $ctrl.subcommand.effects }" update="$ctrl.effectListUpdated(effects)" is-array="true" > diff --git a/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js b/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js index 9a62d6d95..a8be8bcf6 100644 --- a/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js +++ b/src/gui/app/directives/modals/channel-rewards/add-edit-channel-reward.js @@ -224,7 +224,12 @@
- +
diff --git a/src/gui/app/directives/modals/chat/add-or-edit-custom-quick-action-modal.js b/src/gui/app/directives/modals/chat/add-or-edit-custom-quick-action-modal.js index 61e81a70e..a54801822 100644 --- a/src/gui/app/directives/modals/chat/add-or-edit-custom-quick-action-modal.js +++ b/src/gui/app/directives/modals/chat/add-or-edit-custom-quick-action-modal.js @@ -87,7 +87,6 @@ $ctrl.presetEffectLists = presetEffectListsService.getPresetEffectLists(); $ctrl.listType = "custom"; - $ctrl.triggerMeta = {}; $ctrl.effectListUpdated = (effects) => { $ctrl.quickAction.effectList = effects; @@ -119,6 +118,10 @@ $ctrl.isNewQuickAction = true; } + $ctrl.triggerMeta = { + rootEffects: $ctrl.quickAction.effectList + }; + if ($ctrl.isNewQuickAction && $ctrl.quickAction.id == null) { $ctrl.quickAction.id = uuidv1(); } diff --git a/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.html b/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.html index 5e8a78efb..9d3e24bbe 100644 --- a/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.html +++ b/src/gui/app/directives/modals/commands/addOrEditCustomCommand/addOrEditCustomCommandModal.html @@ -244,6 +244,7 @@

header="What should this command do?" effects="$ctrl.command.effects" trigger="command" + trigger-meta="{ rootEffects: $ctrl.command.effects }" update="$ctrl.effectListUpdated(effects)" is-array="true" > diff --git a/src/gui/app/directives/modals/commands/editSystemCommand/editSystemCommandModal.html b/src/gui/app/directives/modals/commands/editSystemCommand/editSystemCommandModal.html index 7a29e6c44..99c1331b5 100644 --- a/src/gui/app/directives/modals/commands/editSystemCommand/editSystemCommandModal.html +++ b/src/gui/app/directives/modals/commands/editSystemCommand/editSystemCommandModal.html @@ -151,6 +151,7 @@

diff --git a/src/gui/app/directives/modals/effects/add-edit-preset-effect-list-modal.js b/src/gui/app/directives/modals/effects/add-edit-preset-effect-list-modal.js index e864b1385..d34d8493b 100644 --- a/src/gui/app/directives/modals/effects/add-edit-preset-effect-list-modal.js +++ b/src/gui/app/directives/modals/effects/add-edit-preset-effect-list-modal.js @@ -37,7 +37,12 @@
- +
diff --git a/src/gui/app/directives/modals/events/addOrEditEventModal.js b/src/gui/app/directives/modals/events/addOrEditEventModal.js index 82a35a9db..18e6edcd0 100644 --- a/src/gui/app/directives/modals/events/addOrEditEventModal.js +++ b/src/gui/app/directives/modals/events/addOrEditEventModal.js @@ -62,7 +62,15 @@
- +
diff --git a/src/gui/app/directives/modals/scheduled-tasks/add-or-edit-scheduled-task-modal.js b/src/gui/app/directives/modals/scheduled-tasks/add-or-edit-scheduled-task-modal.js index 8ffb6f1dd..f03e42163 100644 --- a/src/gui/app/directives/modals/scheduled-tasks/add-or-edit-scheduled-task-modal.js +++ b/src/gui/app/directives/modals/scheduled-tasks/add-or-edit-scheduled-task-modal.js @@ -51,7 +51,14 @@
- +

ProTip: If you want to have this scheduled effect list display a single chat message at a time, try the Run Random Effect or Run Sequential Effect diff --git a/src/gui/app/directives/modals/timers/addOrEditTimer/addOrEditTimerModal.js b/src/gui/app/directives/modals/timers/addOrEditTimer/addOrEditTimerModal.js index 4a20968bf..37df9398a 100644 --- a/src/gui/app/directives/modals/timers/addOrEditTimer/addOrEditTimerModal.js +++ b/src/gui/app/directives/modals/timers/addOrEditTimer/addOrEditTimerModal.js @@ -42,7 +42,14 @@

- +

ProTip: If you want to have this timer display a single chat message at a time, try the Run Random Effect or Run Sequential Effect diff --git a/src/gui/scss/core/_global.scss b/src/gui/scss/core/_global.scss index 0a8f390ef..f86b429c4 100644 --- a/src/gui/scss/core/_global.scss +++ b/src/gui/scss/core/_global.scss @@ -52,7 +52,7 @@ background: #060707; position: absolute; right: 0px; - top: -307px; + top: -341px; z-index: 3000; border-radius: 8px; &.below { @@ -66,6 +66,9 @@ bottom: unset; top: unset; } + &.has-magic-vars { + top: -376px; + } } .variables-btn { From 148e9324b2ab63a59513c0b2d35daaead778c7f9 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Wed, 13 Mar 2024 21:39:35 -0500 Subject: [PATCH 085/113] fix: tweaks to hopefully ensure variable menu never clips the top of the app --- src/gui/app/directives/misc/replaceVariableMenu.js | 9 +++++++++ src/gui/scss/core/_global.scss | 12 ++++++------ src/gui/scss/core/_title-bar.scss | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/gui/app/directives/misc/replaceVariableMenu.js b/src/gui/app/directives/misc/replaceVariableMenu.js index 5e6e3d8f4..4840c85b1 100644 --- a/src/gui/app/directives/misc/replaceVariableMenu.js +++ b/src/gui/app/directives/misc/replaceVariableMenu.js @@ -73,6 +73,14 @@ $scope.hasMagicVariables = Object.values($scope.magicVariables).some(v => v.length > 0); } + $timeout(function() { + const offset = $element.offset(); + const menuHeight = ($scope.hasMagicVariables ? 385 : 350) + 10; // 10px to account for app title bar + if (offset.top <= menuHeight && ($scope.menuPosition === "above" || $scope.menuPosition == null)) { + $scope.menuPosition = "under"; + } + }, 0, false); + if (!$scope.disableVariableMenu) { $scope.variables = replaceVariableService.getVariablesForTrigger({ type: trigger, @@ -255,6 +263,7 @@

` ); + $compile(menu)(scope); menu.insertAfter(element); diff --git a/src/gui/scss/core/_global.scss b/src/gui/scss/core/_global.scss index f86b429c4..f2d315123 100644 --- a/src/gui/scss/core/_global.scss +++ b/src/gui/scss/core/_global.scss @@ -55,19 +55,19 @@ top: -341px; z-index: 3000; border-radius: 8px; + &.has-magic-vars { + top: -376px; + } &.below { - top: 41px; + top: 41px !important; } &.bottom { bottom: 41px; - top: unset; + top: unset !important; } &.under { bottom: unset; - top: unset; - } - &.has-magic-vars { - top: -376px; + top: unset !important; } } diff --git a/src/gui/scss/core/_title-bar.scss b/src/gui/scss/core/_title-bar.scss index 2f98dc762..efa7bf387 100644 --- a/src/gui/scss/core/_title-bar.scss +++ b/src/gui/scss/core/_title-bar.scss @@ -1,5 +1,5 @@ .modal-dialog { - margin-top: 60px !important; + margin-top: 115px !important; } .ng-toast { From 5c9ea4a8397c2daca2ef2737cd169bbd1ff10b4e Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Wed, 13 Mar 2024 22:27:16 -0500 Subject: [PATCH 086/113] fix: absolutely position the "what are these" tooltip for magic vars --- src/gui/app/directives/misc/replaceVariableMenu.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/app/directives/misc/replaceVariableMenu.js b/src/gui/app/directives/misc/replaceVariableMenu.js index 4840c85b1..1dc404938 100644 --- a/src/gui/app/directives/misc/replaceVariableMenu.js +++ b/src/gui/app/directives/misc/replaceVariableMenu.js @@ -211,8 +211,8 @@
-
-
+
+
Date: Sat, 16 Mar 2024 02:27:03 -0600 Subject: [PATCH 087/113] Fix: toggle connection effect --- .../app-management/electron/window-management.js | 4 ++-- src/backend/effects/builtin/toggle-connection.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/backend/app-management/electron/window-management.js b/src/backend/app-management/electron/window-management.js index d063bf555..f08199b45 100644 --- a/src/backend/app-management/electron/window-management.js +++ b/src/backend/app-management/electron/window-management.js @@ -435,7 +435,7 @@ async function createMainWindow() { ); // wait for the main window's content to load, then show it - mainWindow.webContents.on("did-finish-load", () => { + mainWindow.webContents.on("did-finish-load", async () => { createTray(mainWindow); @@ -449,7 +449,7 @@ async function createMainWindow() { } const startupScriptsManager = require("../../common/handlers/custom-scripts/startup-scripts-manager"); - startupScriptsManager.runStartupScripts(); + await startupScriptsManager.runStartupScripts(); const eventManager = require("../../events/EventManager"); eventManager.triggerEvent("firebot", "firebot-started", { diff --git a/src/backend/effects/builtin/toggle-connection.js b/src/backend/effects/builtin/toggle-connection.js index d6e0a9e10..e8ec2667f 100644 --- a/src/backend/effects/builtin/toggle-connection.js +++ b/src/backend/effects/builtin/toggle-connection.js @@ -72,12 +72,12 @@ const toggleConnection = { $scope.effect.services = []; } - $scope.serviceIsSelected = (serviceId) => $scope.effect.services.some(s => s.id === serviceId); + $scope.serviceIsSelected = serviceId => $scope.effect.services.some(s => s.id === serviceId); $scope.toggleServiceSelected = (serviceId) => { if ($scope.serviceIsSelected(serviceId)) { $scope.effect.services = $scope.effect.services.filter( - (s) => s.id !== serviceId + s => s.id !== serviceId ); } else { $scope.effect.services.push({ @@ -92,7 +92,7 @@ const toggleConnection = { action ) => { const service = $scope.effect.services.find( - (s) => s.id === serviceId + s => s.id === serviceId ); if (service != null) { service.action = action; @@ -101,7 +101,7 @@ const toggleConnection = { $scope.getConnectionActionDisplay = (serviceId) => { const service = $scope.effect.services.find( - (s) => s.id === serviceId + s => s.id === serviceId ); if (service == null) { return ""; @@ -153,7 +153,7 @@ const toggleConnection = { .getAllIntegrationDefinitions() .filter(i => integrationManager.integrationIsConnectable(i.id)) .map(i => ({ - id: i.id, + id: `integration.${i.id}`, action: effect.allAction })) ]; From ea271d62b907fd245e4c3bd2200b6cad3fcc0d50 Mon Sep 17 00:00:00 2001 From: CKY- Date: Sat, 16 Mar 2024 05:14:55 -0600 Subject: [PATCH 088/113] feat: Command menu hide item --- src/gui/app/controllers/commands.controller.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/gui/app/controllers/commands.controller.js b/src/gui/app/controllers/commands.controller.js index 8e88d2400..2246b5c3e 100644 --- a/src/gui/app/controllers/commands.controller.js +++ b/src/gui/app/controllers/commands.controller.js @@ -37,6 +37,15 @@ commandsService.saveCustomCommand(command); }; + $scope.toggleCustomCommandVisibilityState = (command) => { + if (command == null) { + return; + } + + command.hidden = !command.hidden; + commandsService.saveCustomCommand(command); + }; + $scope.deleteCustomCommand = (command) => { utilityService.showConfirmationModal({ title: "Delete Command", @@ -121,6 +130,12 @@ $scope.toggleCustomCommandActiveState(command); } }, + { + html: ``, + click: () => { + $scope.toggleCustomCommandVisibilityState(command); + } + }, { html: ` Duplicate`, click: () => { From baeb37bbf2374fcc33a042251b3cc703d81dff5f Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 10:43:11 -0400 Subject: [PATCH 089/113] chore: Twurple 7.1.0 --- package-lock.json | 234 ++++++++++++++++++++++++---------------------- package.json | 10 +- 2 files changed, 129 insertions(+), 115 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5cf9100f4..d568012f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,11 @@ "@crowbartools/firebot-custom-scripts-types": "^5.53.2-6", "@nut-tree/nut-js": "^3.1.1", "@seald-io/nedb": "^4.0.4", - "@twurple/api": "^7.0.6", - "@twurple/auth": "^7.0.6", - "@twurple/chat": "^7.0.6", - "@twurple/eventsub-ws": "^7.0.6", - "@twurple/pubsub": "^7.0.6", + "@twurple/api": "^7.1.0", + "@twurple/auth": "^7.1.0", + "@twurple/chat": "^7.1.0", + "@twurple/eventsub-ws": "^7.1.0", + "@twurple/pubsub": "^7.1.0", "@zunderscore/elgato-light-control": "^1.1.2", "angular": "^1.8.0", "angular-animate": "^1.7.8", @@ -1118,9 +1118,9 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" }, "node_modules/@d-fischer/connection": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@d-fischer/connection/-/connection-8.0.5.tgz", - "integrity": "sha512-F/rMmwVTE9/Rq2BzEU8CRoEVRvpUiSvazt56XgRp15oAbT9GC4D4CIfd4YlPxD5j63ueUjs0b+RDEN7BULrpRQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@d-fischer/connection/-/connection-9.0.0.tgz", + "integrity": "sha512-Mljp/EbaE+eYWfsFXUOk+RfpbHgrWGL/60JkAvjYixw6KREfi5r17XdUiXe54ByAQox6jwgdN2vebdmW1BT+nQ==", "dependencies": { "@d-fischer/isomorphic-ws": "^7.0.0", "@d-fischer/logger": "^4.2.1", @@ -1161,17 +1161,17 @@ } }, "node_modules/@d-fischer/isomorphic-ws": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@d-fischer/isomorphic-ws/-/isomorphic-ws-7.0.0.tgz", - "integrity": "sha512-bydCy1tKvPKvyF0KeDvN1aiAZA4CzQVa2gHifNQczW9Czl89vZ4QHnJMjUcTboWKecbnz5mGiM9PjKA1Xx2Dyg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@d-fischer/isomorphic-ws/-/isomorphic-ws-7.0.2.tgz", + "integrity": "sha512-xK+qIJUF0ne3dsjq5Y3BviQ4M+gx9dzkN+dPP7abBMje4YRfow+X9jBgeEoTe5e+Q6+8hI9R0b37Okkk8Vf0hQ==", "peerDependencies": { "ws": "^8.2.0" } }, "node_modules/@d-fischer/logger": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@d-fischer/logger/-/logger-4.2.1.tgz", - "integrity": "sha512-D/QHXhdz1nt80SYPTC9VsnCBb9kfKUWUnxqvSbwWXuWSt2JNDUQZNwvRuhuxHSNYPd1IxD68/Ex8O5gkzLT14w==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@d-fischer/logger/-/logger-4.2.3.tgz", + "integrity": "sha512-mJUx9OgjrNVLQa4od/+bqnmD164VTCKnK5B4WOW8TX5y/3w2i58p+PMRE45gUuFjk2BVtOZUg55JQM3d619fdw==", "dependencies": { "@d-fischer/detect-node": "^3.0.1", "@d-fischer/shared-utils": "^3.2.0", @@ -1212,22 +1212,18 @@ } }, "node_modules/@d-fischer/rate-limiter": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@d-fischer/rate-limiter/-/rate-limiter-0.7.2.tgz", - "integrity": "sha512-x6XDquQjyJFtKW6oMjQ8vv8Z2S+i+fHWxgYLSZXObDF4JGFaz13hqQtrDEyAMzVgcO8SmmR5zzrNpAZzb/4u1Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@d-fischer/rate-limiter/-/rate-limiter-1.0.1.tgz", + "integrity": "sha512-Mq+0pAJsx92hP83cjmsrXQZVQJ+/+u1JFT6fjH8pj3yfUrbT3eDBsA+6J63eat+QaC+Mci78HdiBfpsdBkdwog==", "dependencies": { - "@d-fischer/logger": "^4.2.1", - "@d-fischer/promise.allsettled": "^2.0.2", - "@d-fischer/shared-utils": "^3.2.0", - "@types/node": "^12.12.5", - "tslib": "^2.0.3" + "@d-fischer/logger": "^4.2.3", + "@d-fischer/shared-utils": "^3.6.3", + "tslib": "^2.6.2" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@d-fischer/rate-limiter/node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" - }, "node_modules/@d-fischer/shared-utils": { "version": "3.6.3", "resolved": "https://registry.npmjs.org/@d-fischer/shared-utils/-/shared-utils-3.6.3.tgz", @@ -2554,19 +2550,19 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" }, "node_modules/@twurple/api": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/api/-/api-7.0.6.tgz", - "integrity": "sha512-Fe8haADUI+m4juCuNxtkWBX2HkCU6FA2+2Biq2/KRTp50FVRCeOBdDQpkdM4r+T2KE0NaiTpiH2rtaRodkR/gQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/api/-/api-7.1.0.tgz", + "integrity": "sha512-cDVVY+vecMFNEOyp7UobQn4ARydIDf04NZy1YCKIKpJHBuOV/pkTjNGluRZ0nR9/t9hBFfOyHAH4JswRZpZbnw==", "dependencies": { - "@d-fischer/cache-decorators": "^3.0.0", + "@d-fischer/cache-decorators": "^4.0.0", "@d-fischer/cross-fetch": "^5.0.1", "@d-fischer/detect-node": "^3.0.1", "@d-fischer/logger": "^4.2.1", - "@d-fischer/rate-limiter": "^0.7.2", + "@d-fischer/rate-limiter": "^1.0.0", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.1", - "@twurple/api-call": "7.0.6", - "@twurple/common": "7.0.6", + "@twurple/api-call": "7.1.0", + "@twurple/common": "7.1.0", "retry": "^0.13.1", "tslib": "^2.0.3" }, @@ -2574,7 +2570,7 @@ "url": "https://github.com/sponsors/d-fischer" }, "peerDependencies": { - "@twurple/auth": "7.0.6" + "@twurple/auth": "7.1.0" } }, "node_modules/@twurple/api-call": { @@ -2593,23 +2589,32 @@ "url": "https://github.com/sponsors/d-fischer" } }, + "node_modules/@twurple/api/node_modules/@d-fischer/cache-decorators": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@d-fischer/cache-decorators/-/cache-decorators-4.0.1.tgz", + "integrity": "sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==", + "dependencies": { + "@d-fischer/shared-utils": "^3.6.3", + "tslib": "^2.6.2" + } + }, "node_modules/@twurple/api/node_modules/@d-fischer/cross-fetch": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@d-fischer/cross-fetch/-/cross-fetch-5.0.3.tgz", - "integrity": "sha512-PAxxY2MJff3DUZP6uYWAo0gvp7lGry8SjZ07H661RBnJviy91o+NWR5C7E67dMvrSGvfA1kZC0xrwk+v4eTcMA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@d-fischer/cross-fetch/-/cross-fetch-5.0.5.tgz", + "integrity": "sha512-symjDUPInTrkfIsZc2n2mo9hiAJLcTJsZkNICjZajEWnWpJ3s3zn50/FY8xpNUAf5w3eFuQii2wxztTGpvG1Xg==", "dependencies": { "node-fetch": "^2.6.12" } }, "node_modules/@twurple/api/node_modules/@twurple/api-call": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-7.0.6.tgz", - "integrity": "sha512-3E9IAJRyRLji9bjmZzicWv/wP9aGVdIDkzaX4WaK3sTRC9EkZ3nWIxSj0WmJ977O2lDsUkpgxzwmskuDqdaTrQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-7.1.0.tgz", + "integrity": "sha512-aiyV492StnILyFzU/Eqgn+BA8fz125sB/0QJVlCJotMolrZxBkA4NsFEGDOcR3rOJLL7zOKPYMhWI8zY0gfzPA==", "dependencies": { "@d-fischer/cross-fetch": "^5.0.1", "@d-fischer/qs": "^7.0.2", "@d-fischer/shared-utils": "^3.6.1", - "@twurple/common": "7.0.6", + "@twurple/common": "7.1.0", "tslib": "^2.0.3" }, "funding": { @@ -2617,9 +2622,9 @@ } }, "node_modules/@twurple/api/node_modules/@twurple/common": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.0.6.tgz", - "integrity": "sha512-38cufsx5k1ruUNrorXsiyTVBnFCXPFgr+MMkpMoFnh3T5G9drT3RkmE6Ss7oeaK6Glr/ihQNIPL6NzTJkGmXhA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.1.0.tgz", + "integrity": "sha512-kz3J9J116+aOdyhCzRQwaxFC5eAewwQ9Iv2UmPHXYqRfbgKay6TsL27vk+Q2HygBCvx/8OWpX3pdSo3V/VPmoA==", "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", @@ -2657,15 +2662,15 @@ } }, "node_modules/@twurple/auth": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/auth/-/auth-7.0.6.tgz", - "integrity": "sha512-kgjSdLRW9NKk9LD8dySkgjXDxzJQFAiNIBZFbQNgTqSn7A2ZwaxmapIxy7FcHyGV0MdRtimScnRbKK9Nm089Xw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/auth/-/auth-7.1.0.tgz", + "integrity": "sha512-OT7XtoXeYA8yLvCKdIZ76x71D/RfxPZQqufpimy5ZSL4+TpxY1CJNFp8YWstC1KEfyGVwyr7ZoV49u95k0JJmw==", "dependencies": { "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.1", - "@twurple/api-call": "7.0.6", - "@twurple/common": "7.0.6", + "@twurple/api-call": "7.1.0", + "@twurple/common": "7.1.0", "tslib": "^2.0.3" }, "funding": { @@ -2673,22 +2678,22 @@ } }, "node_modules/@twurple/auth/node_modules/@d-fischer/cross-fetch": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@d-fischer/cross-fetch/-/cross-fetch-5.0.3.tgz", - "integrity": "sha512-PAxxY2MJff3DUZP6uYWAo0gvp7lGry8SjZ07H661RBnJviy91o+NWR5C7E67dMvrSGvfA1kZC0xrwk+v4eTcMA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@d-fischer/cross-fetch/-/cross-fetch-5.0.5.tgz", + "integrity": "sha512-symjDUPInTrkfIsZc2n2mo9hiAJLcTJsZkNICjZajEWnWpJ3s3zn50/FY8xpNUAf5w3eFuQii2wxztTGpvG1Xg==", "dependencies": { "node-fetch": "^2.6.12" } }, "node_modules/@twurple/auth/node_modules/@twurple/api-call": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-7.0.6.tgz", - "integrity": "sha512-3E9IAJRyRLji9bjmZzicWv/wP9aGVdIDkzaX4WaK3sTRC9EkZ3nWIxSj0WmJ977O2lDsUkpgxzwmskuDqdaTrQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-7.1.0.tgz", + "integrity": "sha512-aiyV492StnILyFzU/Eqgn+BA8fz125sB/0QJVlCJotMolrZxBkA4NsFEGDOcR3rOJLL7zOKPYMhWI8zY0gfzPA==", "dependencies": { "@d-fischer/cross-fetch": "^5.0.1", "@d-fischer/qs": "^7.0.2", "@d-fischer/shared-utils": "^3.6.1", - "@twurple/common": "7.0.6", + "@twurple/common": "7.1.0", "tslib": "^2.0.3" }, "funding": { @@ -2696,9 +2701,9 @@ } }, "node_modules/@twurple/auth/node_modules/@twurple/common": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.0.6.tgz", - "integrity": "sha512-38cufsx5k1ruUNrorXsiyTVBnFCXPFgr+MMkpMoFnh3T5G9drT3RkmE6Ss7oeaK6Glr/ihQNIPL6NzTJkGmXhA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.1.0.tgz", + "integrity": "sha512-kz3J9J116+aOdyhCzRQwaxFC5eAewwQ9Iv2UmPHXYqRfbgKay6TsL27vk+Q2HygBCvx/8OWpX3pdSo3V/VPmoA==", "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", @@ -2728,31 +2733,40 @@ } }, "node_modules/@twurple/chat": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/chat/-/chat-7.0.6.tgz", - "integrity": "sha512-sHfJ6oYb8+++qX8KGlKOlkkJnXbCHmslRwv5xMr88eze/4x/URcx+nHGTcRpzgk0qtSDdIDfCFcH2t/TWE5lBw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/chat/-/chat-7.1.0.tgz", + "integrity": "sha512-AzLtq+xqbyYpqPZau5jvX3Dov+C7MW1YTunYZZ5TqyQlEb/leUD6LdwdhXhsVxQUfpVD1FhU1NSlNd7VEhv0Rg==", "dependencies": { - "@d-fischer/cache-decorators": "^3.0.0", + "@d-fischer/cache-decorators": "^4.0.0", "@d-fischer/deprecate": "^2.0.2", "@d-fischer/logger": "^4.2.1", - "@d-fischer/rate-limiter": "^0.7.2", + "@d-fischer/rate-limiter": "^1.0.0", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", - "@twurple/common": "7.0.6", - "ircv3": "^0.32.3", + "@twurple/common": "7.1.0", + "ircv3": "^0.33.0", "tslib": "^2.0.3" }, "funding": { "url": "https://github.com/sponsors/d-fischer" }, "peerDependencies": { - "@twurple/auth": "7.0.6" + "@twurple/auth": "7.1.0" + } + }, + "node_modules/@twurple/chat/node_modules/@d-fischer/cache-decorators": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@d-fischer/cache-decorators/-/cache-decorators-4.0.1.tgz", + "integrity": "sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==", + "dependencies": { + "@d-fischer/shared-utils": "^3.6.3", + "tslib": "^2.6.2" } }, "node_modules/@twurple/chat/node_modules/@twurple/common": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.0.6.tgz", - "integrity": "sha512-38cufsx5k1ruUNrorXsiyTVBnFCXPFgr+MMkpMoFnh3T5G9drT3RkmE6Ss7oeaK6Glr/ihQNIPL6NzTJkGmXhA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.1.0.tgz", + "integrity": "sha512-kz3J9J116+aOdyhCzRQwaxFC5eAewwQ9Iv2UmPHXYqRfbgKay6TsL27vk+Q2HygBCvx/8OWpX3pdSo3V/VPmoA==", "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", @@ -2776,16 +2790,16 @@ } }, "node_modules/@twurple/eventsub-base": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/eventsub-base/-/eventsub-base-7.0.6.tgz", - "integrity": "sha512-itUQo5c1mZdXpukVC51ZOoWQo+0pBjydbjSVxZ+BBD95a7WmOAnp4FNKl8caqfSlzQaqA7GvmTJCiPreXin3qw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/eventsub-base/-/eventsub-base-7.1.0.tgz", + "integrity": "sha512-3FNmSwhf09yWYQwhkc+EjmEngbMLbmMPJvJ4m30X9duuhFqvZcd0XnnRvHWSd4qpiolJb0BPerqP2EGXlGjElA==", "dependencies": { "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", - "@twurple/api": "7.0.6", - "@twurple/auth": "7.0.6", - "@twurple/common": "7.0.6", + "@twurple/api": "7.1.0", + "@twurple/auth": "7.1.0", + "@twurple/common": "7.1.0", "tslib": "^2.0.3" }, "funding": { @@ -2793,9 +2807,9 @@ } }, "node_modules/@twurple/eventsub-base/node_modules/@twurple/common": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.0.6.tgz", - "integrity": "sha512-38cufsx5k1ruUNrorXsiyTVBnFCXPFgr+MMkpMoFnh3T5G9drT3RkmE6Ss7oeaK6Glr/ihQNIPL6NzTJkGmXhA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.1.0.tgz", + "integrity": "sha512-kz3J9J116+aOdyhCzRQwaxFC5eAewwQ9Iv2UmPHXYqRfbgKay6TsL27vk+Q2HygBCvx/8OWpX3pdSo3V/VPmoA==", "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", @@ -2806,30 +2820,30 @@ } }, "node_modules/@twurple/eventsub-ws": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/eventsub-ws/-/eventsub-ws-7.0.6.tgz", - "integrity": "sha512-n0JsJ7gTEadvd8saHbXHBO77nmvCBROjVIm3itIFELhLgtn3437wxJ+Ne+yuEKwln8WmZn0JWmVCEHoTWGQehw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/eventsub-ws/-/eventsub-ws-7.1.0.tgz", + "integrity": "sha512-0ZOPAGvStqjBTT2Vjtz6euxgtcb8U4cQ23TOaUssgUYJ6hy34ebmLxt6Ghj/5tJt11sduYAxHRvw+XKTSjGIoA==", "dependencies": { - "@d-fischer/connection": "^8.0.5", + "@d-fischer/connection": "^9.0.0", "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", - "@twurple/auth": "7.0.6", - "@twurple/common": "7.0.6", - "@twurple/eventsub-base": "7.0.6", + "@twurple/auth": "7.1.0", + "@twurple/common": "7.1.0", + "@twurple/eventsub-base": "7.1.0", "tslib": "^2.0.3" }, "funding": { "url": "https://github.com/sponsors/d-fischer" }, "peerDependencies": { - "@twurple/api": "7.0.6" + "@twurple/api": "7.1.0" } }, "node_modules/@twurple/eventsub-ws/node_modules/@twurple/common": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.0.6.tgz", - "integrity": "sha512-38cufsx5k1ruUNrorXsiyTVBnFCXPFgr+MMkpMoFnh3T5G9drT3RkmE6Ss7oeaK6Glr/ihQNIPL6NzTJkGmXhA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.1.0.tgz", + "integrity": "sha512-kz3J9J116+aOdyhCzRQwaxFC5eAewwQ9Iv2UmPHXYqRfbgKay6TsL27vk+Q2HygBCvx/8OWpX3pdSo3V/VPmoA==", "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", @@ -2840,28 +2854,28 @@ } }, "node_modules/@twurple/pubsub": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/pubsub/-/pubsub-7.0.6.tgz", - "integrity": "sha512-RM6t3pFpyHcF5c9RtPP3MC3IkhqYiiag8mHIDMVnhmBRfjTeANuDP/Rex6xzREx/tQlTYQ9vM8K1pbrKrFgPCg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/pubsub/-/pubsub-7.1.0.tgz", + "integrity": "sha512-2YMiktQbHPiPqCNzdlQ2OOMLVHNiy5tjRImkVBHNjw0tqCsbrCUhmFwgKjiIiDQUTTzOcNdFhDNhlcgy5Mz65A==", "dependencies": { - "@d-fischer/connection": "^8.0.5", + "@d-fischer/connection": "^9.0.0", "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", - "@twurple/common": "7.0.6", + "@twurple/common": "7.1.0", "tslib": "^2.0.3" }, "funding": { "url": "https://github.com/sponsors/d-fischer" }, "peerDependencies": { - "@twurple/auth": "7.0.6" + "@twurple/auth": "7.1.0" } }, "node_modules/@twurple/pubsub/node_modules/@twurple/common": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.0.6.tgz", - "integrity": "sha512-38cufsx5k1ruUNrorXsiyTVBnFCXPFgr+MMkpMoFnh3T5G9drT3RkmE6Ss7oeaK6Glr/ihQNIPL6NzTJkGmXhA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@twurple/common/-/common-7.1.0.tgz", + "integrity": "sha512-kz3J9J116+aOdyhCzRQwaxFC5eAewwQ9Iv2UmPHXYqRfbgKay6TsL27vk+Q2HygBCvx/8OWpX3pdSo3V/VPmoA==", "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", @@ -3056,9 +3070,9 @@ "integrity": "sha512-7axfYN8SW9pWg78NgenHasSproWQee5rzyPVLC9HpaQSDgNArsnKJD88EaMfi4Pl48AyciO3agYCFqpHS1gLpg==" }, "node_modules/@types/ws": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", - "integrity": "sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", "dependencies": { "@types/node": "*" } @@ -8569,11 +8583,11 @@ } }, "node_modules/ircv3": { - "version": "0.32.3", - "resolved": "https://registry.npmjs.org/ircv3/-/ircv3-0.32.3.tgz", - "integrity": "sha512-H0ejwbIPzJO73PPGJGrdEDrqRxxK0f+6/7kNZ7yY/ukTDZ8zy7w7mN4wMbmOFrnPYO78ZKnbeqmkBRgcVJ552Q==", + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/ircv3/-/ircv3-0.33.0.tgz", + "integrity": "sha512-7rK1Aial3LBiFycE8w3MHiBBFb41/2GG2Ll/fR2IJj1vx0pLpn1s+78K+z/I4PZTqCCSp/Sb4QgKMh3NMhx0Kg==", "dependencies": { - "@d-fischer/connection": "^8.0.5", + "@d-fischer/connection": "^9.0.0", "@d-fischer/escape-string-regexp": "^5.0.0", "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.5.0", @@ -12386,9 +12400,9 @@ "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==" }, "node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index d6c9ef620..374255137 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,11 @@ "@crowbartools/firebot-custom-scripts-types": "^5.53.2-6", "@nut-tree/nut-js": "^3.1.1", "@seald-io/nedb": "^4.0.4", - "@twurple/api": "^7.0.6", - "@twurple/auth": "^7.0.6", - "@twurple/chat": "^7.0.6", - "@twurple/eventsub-ws": "^7.0.6", - "@twurple/pubsub": "^7.0.6", + "@twurple/api": "^7.1.0", + "@twurple/auth": "^7.1.0", + "@twurple/chat": "^7.1.0", + "@twurple/eventsub-ws": "^7.1.0", + "@twurple/pubsub": "^7.1.0", "@zunderscore/elgato-light-control": "^1.1.2", "angular": "^1.8.0", "angular-animate": "^1.7.8", From 22c6d0d082b7ab2aa3b94a2abef29aa018fdf1ec Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk Date: Sun, 17 Mar 2024 16:11:07 +0100 Subject: [PATCH 090/113] fix: unterminated quote in follow-check --- src/backend/restrictions/builtin/follow-check.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/restrictions/builtin/follow-check.js b/src/backend/restrictions/builtin/follow-check.js index 5d5259f15..2877599da 100644 --- a/src/backend/restrictions/builtin/follow-check.js +++ b/src/backend/restrictions/builtin/follow-check.js @@ -39,7 +39,7 @@ const model = { ng-model="restriction.followAgeSeconds" name="cooldownSeconds" ui-validate="'!restriction.useFollowAge || ($value != null && $value > 0)'" - ui-validate-watch="'restriction.useFollowAge" + ui-validate-watch="'restriction.useFollowAge'" large="true" disabled="!restriction.useFollowAge" /> From 0a1de6b8965ee4f59d59da2fbcf9dcd77e5e7625 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 11:22:40 -0400 Subject: [PATCH 091/113] feat(events/vars): Ad Break Start/End, $adBreakDuration, $isAdBreakScheduled (#2111) --- .../events/builtin/twitch-event-source.js | 32 +++++++++++++++ src/backend/events/twitch-events.js | 41 ++++++++++--------- src/backend/events/twitch-events/ad.ts | 33 +++++++++++++++ .../twitch-api/eventsub/eventsub-client.ts | 22 ++++++++++ .../builtin/twitch/ads/ad-break-duration.ts | 27 ++++++++++++ .../variables/builtin/twitch/ads/index.ts | 7 ++++ .../twitch/ads/is-ad-break-scheduled.ts | 27 ++++++++++++ src/backend/variables/builtin/twitch/index.ts | 2 + 8 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 src/backend/events/twitch-events/ad.ts create mode 100644 src/backend/variables/builtin/twitch/ads/ad-break-duration.ts create mode 100644 src/backend/variables/builtin/twitch/ads/index.ts create mode 100644 src/backend/variables/builtin/twitch/ads/is-ad-break-scheduled.ts diff --git a/src/backend/events/builtin/twitch-event-source.js b/src/backend/events/builtin/twitch-event-source.js index 9326faee0..20c78ec72 100644 --- a/src/backend/events/builtin/twitch-event-source.js +++ b/src/backend/events/builtin/twitch-event-source.js @@ -921,6 +921,38 @@ module.exports = { return `Twitch stream title changed to **${eventData.title}**`; } } + }, + { + id: "ad-break-start", + name: "Ad Break Started", + description: "When an ad break starts on your channel.", + cached: false, + manualMetadata: { + duration: 60, + isAdBreakScheduled: true + }, + activityFeed: { + icon: "fad fa-ad", + getMessage: (eventData) => { + return `**${eventData.duration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break started`; + } + } + }, + { + id: "ad-break-end", + name: "Ad Break Ended", + description: "When an ad break ends on your channel.", + cached: false, + manualMetadata: { + duration: 60, + isAdBreakScheduled: true + }, + activityFeed: { + icon: "fad fa-ad", + getMessage: (eventData) => { + return `**${eventData.duration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break ended`; + } + } } ] }; \ No newline at end of file diff --git a/src/backend/events/twitch-events.js b/src/backend/events/twitch-events.js index 9ae61c886..8261f878d 100644 --- a/src/backend/events/twitch-events.js +++ b/src/backend/events/twitch-events.js @@ -1,22 +1,23 @@ "use strict"; -exports.announcement = require('./twitch-events/announcement'); -exports.charity = require('./twitch-events/charity'); -exports.chatMessage = require('./twitch-events/chat-message'); -exports.chatModeChanged = require('./twitch-events/chat-mode-changed'); -exports.cheer = require('./twitch-events/cheer'); -exports.follow = require('./twitch-events/follow'); -exports.giftSub = require('./twitch-events/gift-sub'); -exports.goal = require('./twitch-events/goal'); -exports.hypeTrain = require('./twitch-events/hype-train'); -exports.poll = require('./twitch-events/poll'); -exports.prediction = require('./twitch-events/prediction'); -exports.raid = require('./twitch-events/raid'); -exports.rewardRedemption = require('./twitch-events/reward-redemption'); -exports.shoutout = require('./twitch-events/shoutout'); -exports.stream = require('./twitch-events/stream'); -exports.sub = require('./twitch-events/sub'); -exports.viewerArrived = require('./twitch-events/viewer-arrived'); -exports.viewerBanned = require('./twitch-events/viewer-banned'); -exports.viewerTimeout = require('./twitch-events/viewer-timeout'); -exports.whisper = require('./twitch-events/whisper'); \ No newline at end of file +exports.ad = require("./twitch-events/ad"); +exports.announcement = require("./twitch-events/announcement"); +exports.charity = require("./twitch-events/charity"); +exports.chatMessage = require("./twitch-events/chat-message"); +exports.chatModeChanged = require("./twitch-events/chat-mode-changed"); +exports.cheer = require("./twitch-events/cheer"); +exports.follow = require("./twitch-events/follow"); +exports.giftSub = require("./twitch-events/gift-sub"); +exports.goal = require("./twitch-events/goal"); +exports.hypeTrain = require("./twitch-events/hype-train"); +exports.poll = require("./twitch-events/poll"); +exports.prediction = require("./twitch-events/prediction"); +exports.raid = require("./twitch-events/raid"); +exports.rewardRedemption = require("./twitch-events/reward-redemption"); +exports.shoutout = require("./twitch-events/shoutout"); +exports.stream = require("./twitch-events/stream"); +exports.sub = require("./twitch-events/sub"); +exports.viewerArrived = require("./twitch-events/viewer-arrived"); +exports.viewerBanned = require("./twitch-events/viewer-banned"); +exports.viewerTimeout = require("./twitch-events/viewer-timeout"); +exports.whisper = require("./twitch-events/whisper"); \ No newline at end of file diff --git a/src/backend/events/twitch-events/ad.ts b/src/backend/events/twitch-events/ad.ts new file mode 100644 index 000000000..9d985bd85 --- /dev/null +++ b/src/backend/events/twitch-events/ad.ts @@ -0,0 +1,33 @@ +import eventManager from "../EventManager"; + +export function triggerAdBreakStart( + username: string, + userId: string, + userDisplayName: string, + adBreakDuration: number, + isAdBreakScheduled: boolean +): void { + eventManager.triggerEvent("twitch", "ad-break-start", { + username, + userId, + userDisplayName, + adBreakDuration, + isAdBreakScheduled + }); +} + +export function triggerAdBreakEnd( + username: string, + userId: string, + userDisplayName: string, + adBreakDuration: number, + isAdBreakScheduled: boolean +): void { + eventManager.triggerEvent("twitch", "ad-break-end", { + username, + userId, + userDisplayName, + adBreakDuration, + isAdBreakScheduled + }); +} \ No newline at end of file diff --git a/src/backend/twitch-api/eventsub/eventsub-client.ts b/src/backend/twitch-api/eventsub/eventsub-client.ts index c01bd6e56..0c835716a 100644 --- a/src/backend/twitch-api/eventsub/eventsub-client.ts +++ b/src/backend/twitch-api/eventsub/eventsub-client.ts @@ -439,6 +439,28 @@ class TwitchEventSubClient { chatRolesManager.removeModeratorFromModeratorsList(event.userId); }); this._subscriptions.push(channelModeratorRemoveSubscription); + + // Ad break start/end + const channelAdBreakBeginSubscription = this._eventSubListener.onChannelAdBreakBegin(streamer.userId, (event) => { + twitchEventsHandler.ad.triggerAdBreakStart( + event.requesterName, + event.requesterId, + event.requesterDisplayName, + event.durationSeconds, + event.isAutomatic + ); + + setTimeout(() => { + twitchEventsHandler.ad.triggerAdBreakEnd( + event.requesterName, + event.requesterId, + event.requesterDisplayName, + event.durationSeconds, + event.isAutomatic + ); + }, event.durationSeconds * 1000); + }); + this._subscriptions.push(channelAdBreakBeginSubscription); } async createClient(): Promise { diff --git a/src/backend/variables/builtin/twitch/ads/ad-break-duration.ts b/src/backend/variables/builtin/twitch/ads/ad-break-duration.ts new file mode 100644 index 000000000..6c7ec22df --- /dev/null +++ b/src/backend/variables/builtin/twitch/ads/ad-break-duration.ts @@ -0,0 +1,27 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { EffectTrigger } from "../../../../../shared/effect-constants"; +import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; + +const triggers = {}; +triggers[EffectTrigger.EVENT] = [ + "twitch:ad-break-start", + "twitch:ad-break-end" +]; +triggers[EffectTrigger.MANUAL] = true; + +const model : ReplaceVariable = { + definition: { + handle: "adBreakDuration", + description: "The duration of the triggered ad break, in seconds", + triggers: triggers, + categories: [VariableCategory.COMMON, VariableCategory.TRIGGER], + possibleDataOutput: [OutputDataType.NUMBER] + }, + evaluator: (trigger) => { + const adBreakDuration = trigger.metadata?.eventData?.adBreakDuration ?? 0; + + return adBreakDuration; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/variables/builtin/twitch/ads/index.ts b/src/backend/variables/builtin/twitch/ads/index.ts new file mode 100644 index 000000000..44f4b2bab --- /dev/null +++ b/src/backend/variables/builtin/twitch/ads/index.ts @@ -0,0 +1,7 @@ +import adBreakDuration from "./ad-break-duration"; +import isAdBreakScheduled from "./is-ad-break-scheduled"; + +export default [ + adBreakDuration, + isAdBreakScheduled +]; \ No newline at end of file diff --git a/src/backend/variables/builtin/twitch/ads/is-ad-break-scheduled.ts b/src/backend/variables/builtin/twitch/ads/is-ad-break-scheduled.ts new file mode 100644 index 000000000..93926639c --- /dev/null +++ b/src/backend/variables/builtin/twitch/ads/is-ad-break-scheduled.ts @@ -0,0 +1,27 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { EffectTrigger } from "../../../../../shared/effect-constants"; +import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; + +const triggers = {}; +triggers[EffectTrigger.EVENT] = [ + "twitch:ad-break-start", + "twitch:ad-break-end" +]; +triggers[EffectTrigger.MANUAL] = true; + +const model : ReplaceVariable = { + definition: { + handle: "isAdBreakScheduled", + description: "Whether or not the triggered ad break was scheduled", + triggers: triggers, + categories: [VariableCategory.COMMON, VariableCategory.TRIGGER], + possibleDataOutput: [OutputDataType.BOOLEAN] + }, + evaluator: (trigger) => { + const isAdBreakScheduled = trigger.metadata?.eventData?.isAdBreakScheduled ?? false; + + return isAdBreakScheduled; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/variables/builtin/twitch/index.ts b/src/backend/variables/builtin/twitch/index.ts index 62d98430a..43c97bb7d 100644 --- a/src/backend/variables/builtin/twitch/index.ts +++ b/src/backend/variables/builtin/twitch/index.ts @@ -1,3 +1,4 @@ +import adVariables from './ads'; import chatVariables from './chat'; import channelGoalVariables from './channel-goal'; import charityVariables from './charity'; @@ -19,6 +20,7 @@ import viewerCount from './viewer-count'; export default [ + ...adVariables, ...chatVariables, ...channelGoalVariables, ...charityVariables, From 747d264a9bc10e841d936f2a15b8cd7a28222aa8 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 11:40:23 -0400 Subject: [PATCH 092/113] chore: future OAuth scopes --- src/backend/auth/twitch-auth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backend/auth/twitch-auth.ts b/src/backend/auth/twitch-auth.ts index cd1298333..66f376001 100644 --- a/src/backend/auth/twitch-auth.ts +++ b/src/backend/auth/twitch-auth.ts @@ -72,9 +72,11 @@ class TwitchAuthProviders { 'moderator:read:chat_settings', 'moderator:read:chatters', 'moderator:read:followers', + 'moderator:read:moderators', 'moderator:read:shield_mode', 'moderator:read:shoutouts', 'moderator:read:unban_requests', + 'moderator:read:vips', 'user:edit:broadcast', 'user:manage:blocked_users', 'user:manage:whispers', @@ -109,6 +111,7 @@ class TwitchAuthProviders { 'moderator:manage:announcements', 'user:manage:whispers', 'user:read:chat', + 'user:read:emotes', 'user:write:chat', 'whispers:edit', 'whispers:read' From 05cde859fd1368077eff55fa8d02df23643b8a52 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 16:48:28 -0400 Subject: [PATCH 093/113] fix(chat): separate raid message object for raid message check (#2468) --- .../chat-listeners/twitch-chat-listeners.js | 5 ++++- .../chat/moderation/raid-message-checker.ts | 22 +++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/backend/chat/chat-listeners/twitch-chat-listeners.js b/src/backend/chat/chat-listeners/twitch-chat-listeners.js index a34039492..22607a9e7 100644 --- a/src/backend/chat/chat-listeners/twitch-chat-listeners.js +++ b/src/backend/chat/chat-listeners/twitch-chat-listeners.js @@ -107,7 +107,10 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { if (firebotChatMessage.isFirstChat) { twitchEventsHandler.chatMessage.triggerFirstTimeChat(firebotChatMessage); } - await raidMessageChecker.sendMessageToCache(firebotChatMessage); + await raidMessageChecker.sendMessageToCache({ + rawText: firebotChatMessage.rawText, + userId: firebotChatMessage.userId + }); }); const whisperHandler = async (_user, messageText, msg, accountType) => { diff --git a/src/backend/chat/moderation/raid-message-checker.ts b/src/backend/chat/moderation/raid-message-checker.ts index 7f87f2de4..d27a5f9cc 100644 --- a/src/backend/chat/moderation/raid-message-checker.ts +++ b/src/backend/chat/moderation/raid-message-checker.ts @@ -1,10 +1,14 @@ -import { FirebotChatMessage } from "../../../types/chat"; import logger from "../../logwrapper"; import twitchApi from "../../twitch-api/api"; +interface RaidMessage { + rawText: string; + userId: string; +} + class RaidMessageChecker { private readonly _chatCacheLimit = 50; - private _messageCache: FirebotChatMessage[] = []; + private _messageCache: RaidMessage[] = []; private _raidMessage = ""; private _checkerEnabled = false; private _settings = { @@ -12,7 +16,7 @@ class RaidMessageChecker { shouldBlock: false }; - private async handleRaider(message: FirebotChatMessage): Promise { + private async handleRaider(message: RaidMessage): Promise { if (this._settings.shouldBan) { await twitchApi.moderation.banUser(message.userId); } @@ -48,19 +52,19 @@ class RaidMessageChecker { } } - async sendMessageToCache(firebotChatMessage: FirebotChatMessage): Promise { + async sendMessageToCache(raidMessage: RaidMessage): Promise { if (this._messageCache.length >= this._chatCacheLimit) { this._messageCache.shift(); } - if (firebotChatMessage.rawText.length > 10) { - firebotChatMessage.rawText = firebotChatMessage.rawText.substr(10); + if (raidMessage.rawText.length > 10) { + raidMessage.rawText = raidMessage.rawText.substr(10); } - this._messageCache.push(firebotChatMessage); + this._messageCache.push(raidMessage); - if (firebotChatMessage && this._checkerEnabled && firebotChatMessage.rawText === this._raidMessage) { - await this.handleRaider(firebotChatMessage); + if (raidMessage && this._checkerEnabled && raidMessage.rawText === this._raidMessage) { + await this.handleRaider(raidMessage); } } From 0b38328e33b24a8dfea6e3d8dcb1b05b21d6a577 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 17:11:59 -0400 Subject: [PATCH 094/113] fix: more accurate ad break end time --- src/backend/twitch-api/eventsub/eventsub-client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/twitch-api/eventsub/eventsub-client.ts b/src/backend/twitch-api/eventsub/eventsub-client.ts index 0c835716a..c645906a4 100644 --- a/src/backend/twitch-api/eventsub/eventsub-client.ts +++ b/src/backend/twitch-api/eventsub/eventsub-client.ts @@ -450,6 +450,9 @@ class TwitchEventSubClient { event.isAutomatic ); + const adBreakEndTime = new Date(event.startDate.getTime()); + adBreakEndTime.setSeconds(event.startDate.getSeconds() + event.durationSeconds); + setTimeout(() => { twitchEventsHandler.ad.triggerAdBreakEnd( event.requesterName, @@ -458,7 +461,7 @@ class TwitchEventSubClient { event.durationSeconds, event.isAutomatic ); - }, event.durationSeconds * 1000); + }, adBreakEndTime.getTime() - (new Date()).getTime()); }); this._subscriptions.push(channelAdBreakBeginSubscription); } From 5afc5413e1940f69c7675feed81ca04ce2622bcc Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 17:29:15 -0400 Subject: [PATCH 095/113] fix: typo with Show/Hide Command menu option --- src/gui/app/controllers/commands.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/app/controllers/commands.controller.js b/src/gui/app/controllers/commands.controller.js index 2246b5c3e..8de689a13 100644 --- a/src/gui/app/controllers/commands.controller.js +++ b/src/gui/app/controllers/commands.controller.js @@ -131,7 +131,7 @@ } }, { - html: ``, + html: ` ${item.hidden ? "Show Command" : "Hide Command"}`, click: () => { $scope.toggleCustomCommandVisibilityState(command); } From 8bae753f7d619b34c0aa0c142cc54e4632d413d4 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 19:50:46 -0400 Subject: [PATCH 096/113] 5.62.0-beta4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d568012f1..170c54c6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebotv5", - "version": "5.62.0-beta3", + "version": "5.62.0-beta4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebotv5", - "version": "5.62.0-beta3", + "version": "5.62.0-beta4", "license": "GPL-3.0", "dependencies": { "@aws-sdk/client-polly": "^3.26.0", diff --git a/package.json b/package.json index 374255137..aafb06650 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebotv5", - "version": "5.62.0-beta3", + "version": "5.62.0-beta4", "description": "Powerful all-in-one bot for Twitch streamers.", "main": "build/main.js", "scripts": { From 50b69b6c0e80548cd8987383639d2db7e5b0b088 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 20:00:06 -0400 Subject: [PATCH 097/113] fix(chat): use Twurple paginated endpoint to get chatters list --- src/backend/twitch-api/resource/chat.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/backend/twitch-api/resource/chat.ts b/src/backend/twitch-api/resource/chat.ts index 45afa1831..3fe4cf191 100644 --- a/src/backend/twitch-api/resource/chat.ts +++ b/src/backend/twitch-api/resource/chat.ts @@ -20,13 +20,7 @@ export class TwitchChatApi { try { const streamerUserId: string = accountAccess.getAccounts().streamer.userId; - let result = await this._streamerClient.chat.getChatters(streamerUserId); - chatters.push(...result.data); - - while (result.cursor) { - result = await this._streamerClient.chat.getChatters(streamerUserId, { after: result.cursor }); - chatters.push(...result.data); - } + chatters.push(...await this._streamerClient.chat.getChattersPaginated(streamerUserId).getAll()); } catch (error) { logger.error("Error getting chatter list", error.message); } From 5085a3b74f5089023d2f1eb7da101ef1f062bef0 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 20:06:14 -0400 Subject: [PATCH 098/113] chore(chat): update sendChatMessage to use new Twurple method --- src/backend/twitch-api/resource/chat.ts | 47 +++++++------------------ 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/src/backend/twitch-api/resource/chat.ts b/src/backend/twitch-api/resource/chat.ts index 3fe4cf191..0decf2961 100644 --- a/src/backend/twitch-api/resource/chat.ts +++ b/src/backend/twitch-api/resource/chat.ts @@ -1,6 +1,6 @@ import logger from '../../logwrapper'; import accountAccess from "../../common/account-access"; -import { ApiClient, HelixChatAnnouncementColor, HelixChatChatter, HelixSendChatAnnouncementParams, HelixUpdateChatSettingsParams } from "@twurple/api"; +import { ApiClient, HelixChatAnnouncementColor, HelixChatChatter, HelixSendChatAnnouncementParams, HelixSentChatMessage, HelixUpdateChatSettingsParams } from "@twurple/api"; export class TwitchChatApi { private _streamerClient: ApiClient; @@ -47,43 +47,20 @@ export class TwitchChatApi { const willSendAsBot: boolean = sendAsBot === true && accountAccess.getAccounts().bot?.userId != null && this._botClient != null; - const senderUserId: string = willSendAsBot === true ? - accountAccess.getAccounts().bot.userId : - accountAccess.getAccounts().streamer.userId; - - // TODO: This next section is a shim until Twurple 7.1.0+ when we get a friendly function call - const client: ApiClient = willSendAsBot === true - ? this._botClient - : this._streamerClient; - - const result = await client.callApi<{ - data: [{ - is_sent: boolean, - drop_reason?: { - message: string - } - }] - }>({ - type: 'helix', - url: 'chat/messages', - method: 'POST', - userId: senderUserId, - canOverrideScopedUserContext: true, - query: { - broadcaster_id: streamerUserId, // eslint-disable-line camelcase - sender_id: senderUserId // eslint-disable-line camelcase - }, - jsonBody: { - message: message, - reply_parent_message_id: replyToMessageId ?? undefined // eslint-disable-line camelcase - } - }); - if (result.data[0].is_sent !== true) { - logger.error(`Twitch dropped chat message. Reason: ${result.data[0].drop_reason.message}`); + let result: HelixSentChatMessage; + + if (willSendAsBot === true) { + result = await this._botClient.chat.sendChatMessage(streamerUserId, message, { replyParentMessageId: replyToMessageId }); + } else { + result = await this._streamerClient.chat.sendChatMessage(streamerUserId, message, { replyParentMessageId: replyToMessageId }); + } + + if (result.isSent !== true) { + logger.error(`Twitch dropped chat message. Reason: ${result.dropReasonMessage}`); } - return result.data[0].is_sent; + return result.isSent; } catch (error) { logger.error(`Unable to send ${sendAsBot === true ? "bot" : "steamer"} chat message`, error); } From 72e6c91f4277fe86965b353ed8c2f6e690b65bdf Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 20:24:43 -0400 Subject: [PATCH 099/113] feat(effects): add Unique mode to Set Chat Mode effect --- .../effects/builtin/twitch/set-chat-mode.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/backend/effects/builtin/twitch/set-chat-mode.ts b/src/backend/effects/builtin/twitch/set-chat-mode.ts index ce47eca62..3c47610e7 100644 --- a/src/backend/effects/builtin/twitch/set-chat-mode.ts +++ b/src/backend/effects/builtin/twitch/set-chat-mode.ts @@ -17,6 +17,9 @@ const model: EffectType<{ setSlowMode: boolean; enableSlowMode?: boolean; slowModeDelay?: number; + + setUniqueChat: boolean; + enableUniqueChat?: boolean; }> = { definition: { id: "firebot:set-chat-mode", @@ -124,6 +127,26 @@ const model: EffectType<{ input-title="Delay (Seconds)" placeholder-text="Optional" /> + + + +
+ + +
`, optionsValidator: (effect) => { @@ -137,6 +160,8 @@ const model: EffectType<{ errors.push("You must specify an emote-only action"); } else if (effect.setSlowMode === true && effect.enableSlowMode == null) { errors.push("You must specify a slow mode action"); + } else if (effect.setUniqueChat === true && effect.enableUniqueChat == null) { + errors.push("You must specify a unique mode action"); } return errors; @@ -166,6 +191,10 @@ const model: EffectType<{ if (effect.setSlowMode === true) { await twitchApi.chat.setSlowMode(effect.enableSlowMode ?? false, effect.slowModeDelay); } + + if (effect.setUniqueChat === true) { + await twitchApi.chat.setUniqueMode(effect.enableUniqueChat ?? false); + } } }; From ffb9f7ae39fae145b562778ebf8278c22b060a45 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 17 Mar 2024 21:32:19 -0400 Subject: [PATCH 100/113] feat(effects): add Snooze Next Ad Break effect --- src/backend/effects/builtin-effect-loader.js | 3 +- .../effects/builtin/twitch/snooze-ad-break.ts | 38 +++++++++++++++++++ src/backend/twitch-api/resource/channels.ts | 22 +++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/backend/effects/builtin/twitch/snooze-ad-break.ts diff --git a/src/backend/effects/builtin-effect-loader.js b/src/backend/effects/builtin-effect-loader.js index aed5e4f6b..a180f723d 100644 --- a/src/backend/effects/builtin-effect-loader.js +++ b/src/backend/effects/builtin-effect-loader.js @@ -70,6 +70,7 @@ exports.loadEffects = () => { 'twitch/raid', 'twitch/set-chat-mode', 'twitch/shoutout', + 'twitch/snooze-ad-break', 'twitch/stream-title', 'twitch/stream-game', @@ -80,7 +81,7 @@ exports.loadEffects = () => { 'twitch/create-prediction', 'twitch/lock-prediction', 'twitch/resolve-prediction' - ].forEach(filename => { + ].forEach((filename) => { const definition = require(`./builtin/${filename}`); effectManager.registerEffect(definition); }); diff --git a/src/backend/effects/builtin/twitch/snooze-ad-break.ts b/src/backend/effects/builtin/twitch/snooze-ad-break.ts new file mode 100644 index 000000000..19250165f --- /dev/null +++ b/src/backend/effects/builtin/twitch/snooze-ad-break.ts @@ -0,0 +1,38 @@ +import { EffectType } from "../../../../types/effects"; +import { EffectCategory } from "../../../../shared/effect-constants"; +import twitchApi from "../../../twitch-api/api"; + +const model: EffectType = { + definition: { + id: "twitch:snooze-ad-break", + name: "Snooze Next Ad Break", + description: "Pushes back the next scheduled mid-roll ad break by 5 minutes", + icon: "fad fa-snooze", + categories: [ + EffectCategory.COMMON, + EffectCategory.MODERATION, + EffectCategory.TWITCH + ], + dependencies: { + twitch: true + } + }, + optionsTemplate: ` + +
+ Note: You must be an affiliate or partner to use this effect. + Also, Twitch limits the number of times you may snooze mid-roll ads. + If you have snoozed ads too many times in a short period, Twitch will deny this. +
+
+ `, + optionsController: () => {}, + optionsValidator: () => { + return []; + }, + onTriggerEvent: async () => { + return await twitchApi.channels.snoozeAdBreak(); + } +}; + +module.exports = model; \ No newline at end of file diff --git a/src/backend/twitch-api/resource/channels.ts b/src/backend/twitch-api/resource/channels.ts index 9f2406c62..7b33f67bc 100644 --- a/src/backend/twitch-api/resource/channels.ts +++ b/src/backend/twitch-api/resource/channels.ts @@ -109,6 +109,28 @@ export class TwitchChannelsApi { } } + /** + * Snoozes the next scheduled mid-roll ad break by 5 minutes. + */ + async snoozeAdBreak(): Promise { + try { + const streamer = accountAccess.getAccounts().streamer; + + const isOnline = await this.getOnlineStatus(streamer.userId); + if (isOnline && streamer.broadcasterType !== "") { + const result = await this._streamerClient.channels.snoozeNextAd(streamer.userId); + logger.debug(`Ads were snoozed. ${result.snoozeCount} snooze${result.snoozeCount !== 1 ? "s" : ""} remaining. Next scheduled ad break: ${result.nextAdDate.toLocaleTimeString()}`); + } else { + logger.warn(`Unable to snooze ads. ${isOnline !== true ? "Stream is offline." : "Streamer must be affiliate or partner."}`); + } + + return true; + } catch (error) { + logger.error("Failed to snooze ads", error.message); + return false; + } + } + /** * Starts a raid * From d459eac1bbfb14260466eee0eeeaed52cf4360d2 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 18 Mar 2024 19:06:18 -0400 Subject: [PATCH 101/113] fix(events): fix ad break Activity Feed data --- src/backend/events/builtin/twitch-event-source.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/events/builtin/twitch-event-source.js b/src/backend/events/builtin/twitch-event-source.js index 20c78ec72..6ca3d3b03 100644 --- a/src/backend/events/builtin/twitch-event-source.js +++ b/src/backend/events/builtin/twitch-event-source.js @@ -928,13 +928,13 @@ module.exports = { description: "When an ad break starts on your channel.", cached: false, manualMetadata: { - duration: 60, + adBreakDuration: 60, isAdBreakScheduled: true }, activityFeed: { icon: "fad fa-ad", getMessage: (eventData) => { - return `**${eventData.duration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break started`; + return `**${eventData.adBreakDuration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break started`; } } }, @@ -944,13 +944,13 @@ module.exports = { description: "When an ad break ends on your channel.", cached: false, manualMetadata: { - duration: 60, + adBreakDuration: 60, isAdBreakScheduled: true }, activityFeed: { icon: "fad fa-ad", getMessage: (eventData) => { - return `**${eventData.duration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break ended`; + return `**${eventData.adBreakDuration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break ended`; } } } From 1f7a1915f33526cb707baab24e6eb367719be7f4 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 19 Mar 2024 22:26:26 -0400 Subject: [PATCH 102/113] feat: Ad Break panel on stream info bar --- .../effects/builtin/twitch/snooze-ad-break.ts | 9 ++- src/backend/events/twitch-events/ad.ts | 11 +++ src/backend/twitch-api/ad-manager.ts | 80 +++++++++++++++++++ .../twitch-api/eventsub/eventsub-client.ts | 1 + src/backend/twitch-api/resource/channels.ts | 23 ++++++ src/backend/twitch-api/stream-info-manager.ts | 10 +++ .../misc/ad-break-indicator.component.js | 70 ++++++++++++++++ .../directives/misc/stream-info.component.js | 5 +- .../settings/categories/general-settings.js | 10 +++ src/gui/app/services/ad-break.service.js | 46 +++++++++++ src/gui/app/services/settings.service.js | 8 ++ 11 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/backend/twitch-api/ad-manager.ts create mode 100644 src/gui/app/directives/misc/ad-break-indicator.component.js create mode 100644 src/gui/app/services/ad-break.service.js diff --git a/src/backend/effects/builtin/twitch/snooze-ad-break.ts b/src/backend/effects/builtin/twitch/snooze-ad-break.ts index 19250165f..5c2e82a4f 100644 --- a/src/backend/effects/builtin/twitch/snooze-ad-break.ts +++ b/src/backend/effects/builtin/twitch/snooze-ad-break.ts @@ -1,6 +1,7 @@ import { EffectType } from "../../../../types/effects"; import { EffectCategory } from "../../../../shared/effect-constants"; import twitchApi from "../../../twitch-api/api"; +import adManager from "../../../twitch-api/ad-manager"; const model: EffectType = { definition: { @@ -31,7 +32,13 @@ const model: EffectType = { return []; }, onTriggerEvent: async () => { - return await twitchApi.channels.snoozeAdBreak(); + const result = await twitchApi.channels.snoozeAdBreak(); + + if (result === true) { + await adManager.runAdCheck(); + } + + return result; } }; diff --git a/src/backend/events/twitch-events/ad.ts b/src/backend/events/twitch-events/ad.ts index 9d985bd85..0f25c10cb 100644 --- a/src/backend/events/twitch-events/ad.ts +++ b/src/backend/events/twitch-events/ad.ts @@ -1,19 +1,28 @@ +import adManager from "../../twitch-api/ad-manager"; import eventManager from "../EventManager"; export function triggerAdBreakStart( username: string, userId: string, userDisplayName: string, + adBreakStart: Date, adBreakDuration: number, isAdBreakScheduled: boolean ): void { + const adBreakEnd = new Date(adBreakStart.getTime()); + adBreakEnd.setSeconds(adBreakStart.getSeconds() + adBreakDuration); + eventManager.triggerEvent("twitch", "ad-break-start", { username, userId, userDisplayName, + adBreakStart, + adBreakEnd, adBreakDuration, isAdBreakScheduled }); + + adManager.triggerAdBreak(adBreakDuration, adBreakEnd); } export function triggerAdBreakEnd( @@ -30,4 +39,6 @@ export function triggerAdBreakEnd( adBreakDuration, isAdBreakScheduled }); + + adManager.triggerAdBreakComplete(); } \ No newline at end of file diff --git a/src/backend/twitch-api/ad-manager.ts b/src/backend/twitch-api/ad-manager.ts new file mode 100644 index 000000000..9c837e38e --- /dev/null +++ b/src/backend/twitch-api/ad-manager.ts @@ -0,0 +1,80 @@ +import logger from "../logwrapper"; +import accountAccess from "../common/account-access"; +import twitchApi from "./api"; +import frontendCommunicator from "../common/frontend-communicator"; + +class AdManager { + private _adCheckIntervalId: NodeJS.Timeout; + private _isAdCheckRunning = false; + private _isAdRunning = false; + + constructor() { + frontendCommunicator.on("ad-manager:refresh-ad-schedule", async () => { + await this.runAdCheck(); + }); + } + + async runAdCheck(): Promise { + if (this._isAdCheckRunning === true) { + return; + } + + if (this._isAdRunning) { + logger.debug("Ad break currently running. Skipping ad timer check."); + return; + } + + const streamer = accountAccess.getAccounts().streamer; + if (streamer.broadcasterType === "") { + logger.debug("Streamer is not affiliate/partner. Skipping ad timer check."); + return; + } + + this._isAdCheckRunning = true; + logger.debug("Starting ad timer check."); + + const adSchedule = await twitchApi.channels.getAdSchedule(); + + if (adSchedule?.nextAdDate != null) { + frontendCommunicator.send("ad-manager:next-ad", { + nextAdBreak: adSchedule.nextAdDate, + duration: adSchedule.duration + }); + } + + logger.debug("Ad timer check complete."); + this._isAdCheckRunning = false; + } + + triggerAdBreak(duration: number, endsAt: Date) { + this._isAdRunning = true; + frontendCommunicator.send("ad-manager:ad-running", { + duration, + endsAt + }); + } + + triggerAdBreakComplete(): void { + this._isAdRunning = false; + this.runAdCheck(); + } + + startAdCheck(): void { + if (this._adCheckIntervalId == null) { + this._adCheckIntervalId = setInterval(async () => { + await this.runAdCheck(); + }, 15 * 1000); + } + } + + stopAdCheck(): void { + if (this._adCheckIntervalId != null) { + clearInterval(this._adCheckIntervalId); + this._adCheckIntervalId = null; + } + } +} + +const adManager = new AdManager(); + +export = adManager; \ No newline at end of file diff --git a/src/backend/twitch-api/eventsub/eventsub-client.ts b/src/backend/twitch-api/eventsub/eventsub-client.ts index c645906a4..28800f895 100644 --- a/src/backend/twitch-api/eventsub/eventsub-client.ts +++ b/src/backend/twitch-api/eventsub/eventsub-client.ts @@ -446,6 +446,7 @@ class TwitchEventSubClient { event.requesterName, event.requesterId, event.requesterDisplayName, + event.startDate, event.durationSeconds, event.isAutomatic ); diff --git a/src/backend/twitch-api/resource/channels.ts b/src/backend/twitch-api/resource/channels.ts index 7b33f67bc..be5cc2176 100644 --- a/src/backend/twitch-api/resource/channels.ts +++ b/src/backend/twitch-api/resource/channels.ts @@ -1,6 +1,7 @@ import logger from '../../logwrapper'; import accountAccess from "../../common/account-access"; import { ApiClient, CommercialLength, HelixChannel, HelixChannelUpdate, HelixUser, HelixUserRelation } from "@twurple/api"; +import { HelixAdSchedule } from '@twurple/api/lib/endpoints/channel/HelixAdSchedule'; export class TwitchChannelsApi { private _streamerClient: ApiClient; @@ -86,6 +87,28 @@ export class TwitchChannelsApi { return this.getChannelInformation(user.id); } + /** + * Retrieves the current ad schedule for the streamer's channel. + */ + async getAdSchedule(): Promise { + try { + let adSchedule: HelixAdSchedule = null; + const streamer = accountAccess.getAccounts().streamer; + + const isOnline = await this.getOnlineStatus(streamer.userId); + if (isOnline && streamer.broadcasterType !== "") { + adSchedule = await this._streamerClient.channels.getAdSchedule(streamer.userId); + } else { + logger.warn(`Unable to get ad schedule. ${isOnline !== true ? "Stream is offline." : "Streamer must be affiliate or partner."}`); + } + + return adSchedule; + } catch (error) { + logger.error("There was an error getting the ad schedule", error.message); + return null; + } + } + /** * Trigger a Twitch ad break. Default length 30 seconds. * diff --git a/src/backend/twitch-api/stream-info-manager.ts b/src/backend/twitch-api/stream-info-manager.ts index 27f54d177..ec45f950c 100644 --- a/src/backend/twitch-api/stream-info-manager.ts +++ b/src/backend/twitch-api/stream-info-manager.ts @@ -9,6 +9,7 @@ import { triggerCategoryChanged, triggerTitleChanged } from "../events/twitch-events/stream"; +import adManager from "./ad-manager"; interface TwitchStreamInfo { isLive?: boolean; @@ -40,6 +41,8 @@ class TwitchStreamInfoManager { if (this._streamInfoPollIntervalId != null) { clearTimeout(this._streamInfoPollIntervalId); } + + adManager.stopAdCheck(); } private async doWebCheckin(): Promise { @@ -71,6 +74,8 @@ class TwitchStreamInfoManager { if (stream == null) { if (this.streamInfo.isLive) { streamInfoChanged = true; + + adManager.stopAdCheck(); } this.streamInfo.isLive = false; } else { @@ -78,6 +83,11 @@ class TwitchStreamInfoManager { this.streamInfo.viewers !== stream.viewers || this.streamInfo.startedAt !== stream.startDate) { streamInfoChanged = true; + + // We just went live, so start the ad check + if (!this.streamInfo.isLive) { + adManager.startAdCheck(); + } } this.streamInfo.isLive = true; this.streamInfo.viewers = stream.viewers; diff --git a/src/gui/app/directives/misc/ad-break-indicator.component.js b/src/gui/app/directives/misc/ad-break-indicator.component.js new file mode 100644 index 000000000..6360687ef --- /dev/null +++ b/src/gui/app/directives/misc/ad-break-indicator.component.js @@ -0,0 +1,70 @@ +"use strict"; + +(function() { + + const moment = require("moment"); + + angular.module("firebotApp") + .component("adBreakIndicator", { + bindings: {}, + template: ` +
+ + {{abs.adRunning ? 'REMAINING' : 'STARTS IN'}} + {{timeLeftDisplay}} + ({{abs.friendlyDuration}} break) +
+ `, + controller: function($scope, adBreakService, $interval) { + const $ctrl = this; + + $scope.abs = adBreakService; + + $scope.timeLeftDisplay = "0:00"; + + function updateTimeLeftDisplay() { + const endsAt = moment(adBreakService.adRunning + ? adBreakService.endsAt + : adBreakService.nextAdBreak + ); + const now = moment(); + + if (now.isAfter(endsAt)) { + $scope.timeLeftDisplay = adBreakService.adRunning + ? "ENDING" + : "SOON"; + return; + } + + const secondsLeft = Math.abs(now.diff(endsAt, "seconds")); + + const allSecs = Math.round(secondsLeft); + + const divisorForMinutes = allSecs % (60 * 60); + const minutes = Math.floor(divisorForMinutes / 60); + + const divisorForSeconds = divisorForMinutes % 60; + const seconds = Math.ceil(divisorForSeconds); + + const minDisplay = minutes.toString().padStart(1, "0"), + secDisplay = seconds.toString().padStart(2, "0"); + + $scope.timeLeftDisplay = `${minDisplay}:${secDisplay}`; + } + + $ctrl.$onInit = function() { + updateTimeLeftDisplay(); + }; + + $interval(() => { + updateTimeLeftDisplay(); + }, 1000); + } + }); +}()); diff --git a/src/gui/app/directives/misc/stream-info.component.js b/src/gui/app/directives/misc/stream-info.component.js index 3d3507b41..8d62a062e 100644 --- a/src/gui/app/directives/misc/stream-info.component.js +++ b/src/gui/app/directives/misc/stream-info.component.js @@ -20,13 +20,16 @@
+ +
`, - controller: function($scope, streamInfoService, settingsService, hypeTrainService, $interval) { + controller: function($scope, streamInfoService, settingsService, hypeTrainService, adBreakService, $interval) { const $ctrl = this; $scope.sis = streamInfoService; $scope.hts = hypeTrainService; + $scope.abs = adBreakService; $scope.settings = settingsService; diff --git a/src/gui/app/directives/settings/categories/general-settings.js b/src/gui/app/directives/settings/categories/general-settings.js index 356831ecd..2a13991d3 100644 --- a/src/gui/app/directives/settings/categories/general-settings.js +++ b/src/gui/app/directives/settings/categories/general-settings.js @@ -134,6 +134,16 @@ />
+
diff --git a/src/gui/app/services/ad-break.service.js b/src/gui/app/services/ad-break.service.js new file mode 100644 index 000000000..38bbaa56d --- /dev/null +++ b/src/gui/app/services/ad-break.service.js @@ -0,0 +1,46 @@ +"use strict"; + +(function() { + angular + .module("firebotApp") + .factory("adBreakService", function(backendCommunicator) { + const service = {}; + + service.showAdBreakTimer = false; + service.adRunning = false; + service.nextAdBreak = new Date().toJSON(); + service.endsAt = new Date().toJSON(); + service.adDuration = 0; + service.friendlyDuration = "0s"; + + service.updateDuration = () => { + if (service.adDuration < 60) { + service.friendlyDuration = `${service.adDuration}s`; + return; + } + + const mins = Math.floor(service.adDuration / 60); + const remainingSecs = service.adDuration % 60; + + service.friendlyDuration = `${mins}m${remainingSecs > 0 ? ` ${remainingSecs}s` : ""}`; + }; + + backendCommunicator.on("ad-manager:next-ad", ({ nextAdBreak, duration }) => { + service.showAdBreakTimer = true; + service.adRunning = false; + service.nextAdBreak = nextAdBreak; + service.adDuration = duration; + service.updateDuration(); + }); + + backendCommunicator.on("ad-manager:ad-running", ({ duration, endsAt }) => { + service.showAdBreakTimer = true; + service.adRunning = true; + service.adDuration = duration; + service.endsAt = endsAt; + service.updateDuration(); + }); + + return service; + }); +}()); \ No newline at end of file diff --git a/src/gui/app/services/settings.service.js b/src/gui/app/services/settings.service.js index ab26b6da8..5dfd90a01 100644 --- a/src/gui/app/services/settings.service.js +++ b/src/gui/app/services/settings.service.js @@ -441,6 +441,14 @@ pushDataToFile("/settings/showHypeTrainIndicator", value === true); }; + service.getShowAdBreakIndicator = function() { + const value = getDataFromFile("/settings/showAdBreakIndicator", false, true); + return value != null ? value : true; + }; + service.setShowAdBreakIndicator = function(value) { + pushDataToFile("/settings/showAdBreakIndicator", value === true); + }; + service.chatHideDeletedMessages = function() { const hide = getDataFromFile('/settings/chatHideDeletedMessages', false, false); return hide != null ? hide : false; From a13b73cbb49454ea3bc0fe1f8e2039b97930d43d Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 20 Mar 2024 12:49:40 -0400 Subject: [PATCH 103/113] feat(events/vars): Scheduled Ad Break Starting Soon, $minutesUntilNextAdBreak --- src/backend/common/settings-access.js | 9 ++++ .../events/builtin/twitch-event-source.js | 41 ++++++++++++++++++- src/backend/twitch-api/ad-manager.ts | 21 ++++++++++ .../builtin/twitch/ads/ad-break-duration.ts | 1 + .../variables/builtin/twitch/ads/index.ts | 4 +- .../twitch/ads/minutes-until-next-ad-break.ts | 28 +++++++++++++ .../settings/categories/trigger-settings.js | 12 ++++++ src/gui/app/services/settings.service.js | 10 +++++ 8 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts diff --git a/src/backend/common/settings-access.js b/src/backend/common/settings-access.js index aceeedd67..832830055 100644 --- a/src/backend/common/settings-access.js +++ b/src/backend/common/settings-access.js @@ -322,4 +322,13 @@ settings.setWebOnlineCheckin = (value) => { pushDataToFile("/settings/webOnlineCheckin", value); }; +settings.getTriggerUpcomingAdBreakMinutes = function() { + const value = getDataFromFile("/settings/triggerUpcomingAdBreakMinutes", false, 0); + return value ?? 0; +}; + +settings.setTriggerUpcomingAdBreakMinutes = function(value) { + pushDataToFile("/settings/triggerUpcomingAdBreakMinutes", value); +}; + exports.settings = settings; diff --git a/src/backend/events/builtin/twitch-event-source.js b/src/backend/events/builtin/twitch-event-source.js index 6ca3d3b03..a0d8a8574 100644 --- a/src/backend/events/builtin/twitch-event-source.js +++ b/src/backend/events/builtin/twitch-event-source.js @@ -922,6 +922,29 @@ module.exports = { } } }, + { + id: "ad-break-upcoming", + name: "Scheduled Ad Break Starting Soon", + description: "When a scheduled ad break will be starting soon on your channel.", + cached: false, + manualMetadata: { + adBreakDuration: 60, + minutesUntilNextAdBreak: 5 + }, + activityFeed: { + icon: "fad fa-ad", + getMessage: (eventData) => { + const mins = Math.floor(eventData.adBreakDuration / 60); + const remainingSecs = eventData.adBreakDuration % 60; + + const friendlyDuration = mins > 0 + ? `${mins}m${remainingSecs > 0 ? ` ${remainingSecs}s` : ""}` + : `${eventData.adBreakDuration}s`; + + return `**${friendlyDuration}** scheduled ad break starting in **${eventData.minutesUntilNextAdBreak}** minutes`; + } + } + }, { id: "ad-break-start", name: "Ad Break Started", @@ -934,7 +957,14 @@ module.exports = { activityFeed: { icon: "fad fa-ad", getMessage: (eventData) => { - return `**${eventData.adBreakDuration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break started`; + const mins = Math.floor(eventData.adBreakDuration / 60); + const remainingSecs = eventData.adBreakDuration % 60; + + const friendlyDuration = mins > 0 + ? `${mins}m${remainingSecs > 0 ? ` ${remainingSecs}s` : ""}` + : `${eventData.adBreakDuration}s`; + + return `**${friendlyDuration}** **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break started`; } } }, @@ -950,7 +980,14 @@ module.exports = { activityFeed: { icon: "fad fa-ad", getMessage: (eventData) => { - return `**${eventData.adBreakDuration}** second **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break ended`; + const mins = Math.floor(eventData.adBreakDuration / 60); + const remainingSecs = eventData.adBreakDuration % 60; + + const friendlyDuration = mins > 0 + ? `${mins}m${remainingSecs > 0 ? ` ${remainingSecs}s` : ""}` + : `${eventData.adBreakDuration}s`; + + return `**${friendlyDuration}** **${eventData.isAdBreakScheduled ? "scheduled" : "manual"}** ad break ended`; } } } diff --git a/src/backend/twitch-api/ad-manager.ts b/src/backend/twitch-api/ad-manager.ts index 9c837e38e..d9c2db139 100644 --- a/src/backend/twitch-api/ad-manager.ts +++ b/src/backend/twitch-api/ad-manager.ts @@ -1,11 +1,16 @@ +import { DateTime } from "luxon"; + import logger from "../logwrapper"; import accountAccess from "../common/account-access"; import twitchApi from "./api"; import frontendCommunicator from "../common/frontend-communicator"; +import { settings } from "../common/settings-access"; +import eventManager from "../events/EventManager"; class AdManager { private _adCheckIntervalId: NodeJS.Timeout; private _isAdCheckRunning = false; + private _upcomingEventTriggered = false; private _isAdRunning = false; constructor() { @@ -40,6 +45,21 @@ class AdManager { nextAdBreak: adSchedule.nextAdDate, duration: adSchedule.duration }); + + const upcomingTriggerMinutes = Number(settings.getTriggerUpcomingAdBreakMinutes()); + const minutesUntilNextAdBreak = Math.abs(DateTime.fromJSDate(adSchedule.nextAdDate).diffNow("minutes").minutes); + + if (upcomingTriggerMinutes > 0 + && this._upcomingEventTriggered !== true + && minutesUntilNextAdBreak <= upcomingTriggerMinutes + ) { + this._upcomingEventTriggered = true; + + eventManager.triggerEvent("twitch", "ad-break-upcoming", { + minutesUntilNextAdBreak: minutesUntilNextAdBreak, + adBreakDuration: adSchedule.duration + }); + } } logger.debug("Ad timer check complete."); @@ -55,6 +75,7 @@ class AdManager { } triggerAdBreakComplete(): void { + this._upcomingEventTriggered = false; this._isAdRunning = false; this.runAdCheck(); } diff --git a/src/backend/variables/builtin/twitch/ads/ad-break-duration.ts b/src/backend/variables/builtin/twitch/ads/ad-break-duration.ts index 6c7ec22df..56024e22b 100644 --- a/src/backend/variables/builtin/twitch/ads/ad-break-duration.ts +++ b/src/backend/variables/builtin/twitch/ads/ad-break-duration.ts @@ -4,6 +4,7 @@ import { OutputDataType, VariableCategory } from "../../../../../shared/variable const triggers = {}; triggers[EffectTrigger.EVENT] = [ + "twitch:ad-break-upcoming", "twitch:ad-break-start", "twitch:ad-break-end" ]; diff --git a/src/backend/variables/builtin/twitch/ads/index.ts b/src/backend/variables/builtin/twitch/ads/index.ts index 44f4b2bab..22f82cfcc 100644 --- a/src/backend/variables/builtin/twitch/ads/index.ts +++ b/src/backend/variables/builtin/twitch/ads/index.ts @@ -1,7 +1,9 @@ import adBreakDuration from "./ad-break-duration"; import isAdBreakScheduled from "./is-ad-break-scheduled"; +import minutesUntilNextAdBreak from "./minutes-until-next-ad-break"; export default [ adBreakDuration, - isAdBreakScheduled + isAdBreakScheduled, + minutesUntilNextAdBreak ]; \ No newline at end of file diff --git a/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts b/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts new file mode 100644 index 000000000..3c8c7c323 --- /dev/null +++ b/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts @@ -0,0 +1,28 @@ +import { DateTime } from "luxon"; +import { ReplaceVariable } from "../../../../../types/variables"; +import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; +import twitchApi from "../../../../twitch-api/api"; + +const model : ReplaceVariable = { + definition: { + handle: "minutesUntilNextAdBreak", + description: "The number of minutes until the next schduled ad break", + categories: [VariableCategory.COMMON, VariableCategory.TRIGGER], + possibleDataOutput: [OutputDataType.NUMBER] + }, + evaluator: async (trigger) => { + let minutesUntilNextAdBreak = trigger.metadata?.eventData?.minutesUntilNextAdBreak ?? 0; + + if (minutesUntilNextAdBreak === 0) { + const adSchedule = await twitchApi.channels.getAdSchedule(); + + if (adSchedule?.nextAdDate != null) { + minutesUntilNextAdBreak = Math.abs(DateTime.fromJSDate(adSchedule.nextAdDate).diffNow("minutes").minutes); + } + } + + return minutesUntilNextAdBreak; + } +}; + +export default model; \ No newline at end of file diff --git a/src/gui/app/directives/settings/categories/trigger-settings.js b/src/gui/app/directives/settings/categories/trigger-settings.js index 11d68b27f..223dcc1a4 100644 --- a/src/gui/app/directives/settings/categories/trigger-settings.js +++ b/src/gui/app/directives/settings/categories/trigger-settings.js @@ -40,6 +40,18 @@ /> + + +
`, controller: function($scope, settingsService) { diff --git a/src/gui/app/services/settings.service.js b/src/gui/app/services/settings.service.js index 5dfd90a01..fe34a981a 100644 --- a/src/gui/app/services/settings.service.js +++ b/src/gui/app/services/settings.service.js @@ -445,10 +445,20 @@ const value = getDataFromFile("/settings/showAdBreakIndicator", false, true); return value != null ? value : true; }; + service.setShowAdBreakIndicator = function(value) { pushDataToFile("/settings/showAdBreakIndicator", value === true); }; + service.getTriggerUpcomingAdBreakMinutes = function() { + const value = getDataFromFile("/settings/triggerUpcomingAdBreakMinutes", false, 0); + return value ?? 0; + }; + + service.setTriggerUpcomingAdBreakMinutes = function(value) { + pushDataToFile("/settings/triggerUpcomingAdBreakMinutes", value); + }; + service.chatHideDeletedMessages = function() { const hide = getDataFromFile('/settings/chatHideDeletedMessages', false, false); return hide != null ? hide : false; From fa94b899b92cc389b5df8127797f0f2c48830db6 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 21 Mar 2024 14:29:04 -0400 Subject: [PATCH 104/113] feat(events/vars): OBS Input events/vars (#2473) --- package-lock.json | 8 +- package.json | 2 +- .../integrations/builtin/obs/constants.ts | 12 ++ .../builtin/obs/events/obs-event-source.ts | 140 +++++++++++++- .../builtin/obs/obs-integration.ts | 48 ++++- .../integrations/builtin/obs/obs-remote.ts | 183 ++++++++++++++++-- .../builtin/obs/variables/input-active.ts | 24 +++ .../obs/variables/input-audio-balance.ts | 24 +++ .../obs/variables/input-audio-monitor-type.ts | 35 ++++ .../obs/variables/input-audio-sync-offset.ts | 24 +++ .../obs/variables/input-audio-tracks.ts | 24 +++ .../builtin/obs/variables/input-kind.ts | 24 +++ .../builtin/obs/variables/input-muted.ts | 24 +++ .../builtin/obs/variables/input-name.ts | 46 +++++ .../builtin/obs/variables/input-settings.ts | 26 +++ .../builtin/obs/variables/input-showing.ts | 24 +++ .../builtin/obs/variables/input-uuid.ts | 46 +++++ .../builtin/obs/variables/input-volume-db.ts | 24 +++ .../obs/variables/input-volume-multiplier.ts | 24 +++ .../builtin/obs/variables/old-input-name.ts | 24 +++ 20 files changed, 760 insertions(+), 26 deletions(-) create mode 100644 src/backend/integrations/builtin/obs/variables/input-active.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-audio-balance.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-audio-monitor-type.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-audio-sync-offset.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-audio-tracks.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-kind.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-muted.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-name.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-settings.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-showing.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-uuid.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-volume-db.ts create mode 100644 src/backend/integrations/builtin/obs/variables/input-volume-multiplier.ts create mode 100644 src/backend/integrations/builtin/obs/variables/old-input-name.ts diff --git a/package-lock.json b/package-lock.json index 170c54c6f..31050335f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,7 +79,7 @@ "node-hue-api": "^4.0.11", "node-json-db": "^1.4.1", "node-xlsx": "^0.20.0", - "obs-websocket-js": "^5.0.3", + "obs-websocket-js": "^5.0.5", "request": "^2.85.0", "roll": "^1.2.0", "sanitize-filename": "^1.6.3", @@ -10168,9 +10168,9 @@ } }, "node_modules/obs-websocket-js": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.3.tgz", - "integrity": "sha512-lEsDKVlSgXQ7p0nLuL8DER9SzOBrpqOo7fUO1m0zVRjdUT8pCSCEPb9Xn0I6XH3xoe51fWGybFYo6bN9cO51Pw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.5.tgz", + "integrity": "sha512-mSMqLXJ4z28jgwy7Ecv8CtpYh/xdbcn524kq0n6wT3kN6xkgWU/Zc6OtiVZo+gyyylC0anjehMLEVF+CDSwccw==", "dependencies": { "@msgpack/msgpack": "^2.7.1", "crypto-js": "^4.1.1", diff --git a/package.json b/package.json index aafb06650..6529da9a5 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "node-hue-api": "^4.0.11", "node-json-db": "^1.4.1", "node-xlsx": "^0.20.0", - "obs-websocket-js": "^5.0.3", + "obs-websocket-js": "^5.0.5", "request": "^2.85.0", "roll": "^1.2.0", "sanitize-filename": "^1.6.3", diff --git a/src/backend/integrations/builtin/obs/constants.ts b/src/backend/integrations/builtin/obs/constants.ts index 44496ecd1..5903ea81e 100644 --- a/src/backend/integrations/builtin/obs/constants.ts +++ b/src/backend/integrations/builtin/obs/constants.ts @@ -14,3 +14,15 @@ export const OBS_REPLAY_BUFFER_SAVED_EVENT_ID = "replay-buffer-saved"; export const OBS_CURRENT_SCENE_COLLECTION_CHANGED_EVENT_ID = "current-scene-collection-changed"; export const OBS_CURRENT_PROFILE_CHANGED_EVENT_ID = "current-profile-changed"; export const OBS_VENDOR_EVENT_EVENT_ID = "vendor-event"; +export const OBS_INPUT_CREATED_EVENT_ID = "input-created"; +export const OBS_INPUT_REMOVED_EVENT_ID = "input-removed"; +export const OBS_INPUT_NAME_CHANGED_EVENT_ID = "input-name-changed"; +export const OBS_INPUT_SETTINGS_CHANGED_EVENT_ID = "input-settings-changed"; +export const OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID = "input-active-state-changed"; +export const OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID = "input-show-state-changed"; +export const OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID = "input-mute-state-changed"; +export const OBS_INPUT_VOLUME_CHANGED_EVENT_ID = "input-volume-changed"; +export const OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID = "input-audio-balance-changed"; +export const OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID = "input-audio-sync-offset-changed"; +export const OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID = "input-audio-tracks-changed"; +export const OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID = "input-audio-monitor-type-changed"; diff --git a/src/backend/integrations/builtin/obs/events/obs-event-source.ts b/src/backend/integrations/builtin/obs/events/obs-event-source.ts index b8ec2654d..2375246f0 100644 --- a/src/backend/integrations/builtin/obs/events/obs-event-source.ts +++ b/src/backend/integrations/builtin/obs/events/obs-event-source.ts @@ -15,7 +15,19 @@ import { OBS_SCENE_TRANSITION_STARTED_EVENT_ID, OBS_STREAM_STARTED_EVENT_ID, OBS_STREAM_STOPPED_EVENT_ID, - OBS_VENDOR_EVENT_EVENT_ID + OBS_VENDOR_EVENT_EVENT_ID, + OBS_INPUT_CREATED_EVENT_ID, + OBS_INPUT_REMOVED_EVENT_ID, + OBS_INPUT_NAME_CHANGED_EVENT_ID, + OBS_INPUT_SETTINGS_CHANGED_EVENT_ID, + OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID, + OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_VOLUME_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID } from "../constants"; export const OBSEventSource: EventSource = { @@ -130,6 +142,132 @@ export const OBSEventSource: EventSource = { vendorName: "Test Vendor", eventType: "Test Event Type" } + }, + { + id: OBS_INPUT_CREATED_EVENT_ID, + name: "OBS Input Created", + description: "When an input has been created in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + inputKind: "" + } + }, + { + id: OBS_INPUT_REMOVED_EVENT_ID, + name: "OBS Input Removed", + description: "When an input has been removed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "" + } + }, + { + id: OBS_INPUT_NAME_CHANGED_EVENT_ID, + name: "OBS Input Name Changed", + description: "When the name of an input has changed in OBS", + manualMetadata: { + oldInputName: "", + inputName: "Microphone", + inputUuid: "" + } + }, + { + id: OBS_INPUT_SETTINGS_CHANGED_EVENT_ID, + name: "OBS Input Settings Changed", + description: "When an input's settings have changed/updated in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "" + } + }, + { + id: OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID, + name: "OBS Input Active State Changed", + description: "When an input's active state has changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + inputActive: true + } + }, + { + id: OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID, + name: "OBS Input Show State Changed", + description: "When an input's show state has changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + inputShowing: true + } + }, + { + id: OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID, + name: "OBS Input Mute State Changed", + description: "When an input's mute state has changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + inputMuted: true + } + }, + { + id: OBS_INPUT_VOLUME_CHANGED_EVENT_ID, + name: "OBS Input Volume Level Changed", + description: "When an input's volume level has changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + inputVolumeMultiplier: 0, + inputVolumeDb: 0 + } + }, + { + id: OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID, + name: "OBS Input Audio Balance Changed", + description: "When an input's audio balance has changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + inputAudioBalance: 0 + } + }, + { + id: OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID, + name: "OBS Input Audio Sync Offset Changed", + description: "When an input's audio sync offset has changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + inputAudioSyncOffset: 0 + } + }, + { + id: OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID, + name: "OBS Input Audio Tracks Changed", + description: "When an input's audio tracks have changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "" + } + }, + { + id: OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, + name: "OBS Input Audio Monitor Type Changed", + description: "When an input's audio monitor type has changed in OBS", + manualMetadata: { + inputName: "Microphone", + inputUuid: "", + monitorType: { + type: "enum", + options: { + "OBS_MONITORING_TYPE_NONE": "None", + "OBS_MONITORING_TYPE_MONITOR_ONLY": "Monitor Only", + "OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT": "Monitor and Output" + }, + value: "OBS_MONITORING_TYPE_NONE" + } + } } ] }; \ No newline at end of file diff --git a/src/backend/integrations/builtin/obs/obs-integration.ts b/src/backend/integrations/builtin/obs/obs-integration.ts index 24ef29e61..91cbb46a9 100644 --- a/src/backend/integrations/builtin/obs/obs-integration.ts +++ b/src/backend/integrations/builtin/obs/obs-integration.ts @@ -1,6 +1,4 @@ -import { initRemote } from "./obs-remote"; import { TypedEmitter } from "tiny-typed-emitter"; -import eventManager from "../../../events/EventManager"; import { Integration, IntegrationController, @@ -8,7 +6,17 @@ import { IntegrationEvents } from "@crowbartools/firebot-custom-scripts-types"; import { EventManager } from "@crowbartools/firebot-custom-scripts-types/types/modules/event-manager"; + import logger from "../../../logwrapper"; +import effectManager from "../../../effects/effectManager"; +import eventManager from "../../../events/EventManager"; +import eventFilterManager from "../../../events/filters/filter-manager"; +import replaceVariableManager from "../../../variables/replace-variable-manager"; +import frontendCommunicator from "../../../common/frontend-communicator"; + +import { initRemote } from "./obs-remote"; +import { setupFrontendListeners } from "./communicator"; + import { ChangeSceneEffectType } from "./effects/change-scene-effect-type"; import { ChangeSceneCollectionEffectType } from "./effects/change-scene-collection"; import { ToggleSourceVisibilityEffectType } from "./effects/toggle-obs-source-visibility"; @@ -26,8 +34,11 @@ import { SetOBSMediaSourceFileEffectType } from "./effects/set-obs-media-source- import { SetOBSColorSourceColorEffectType } from "./effects/set-obs-color-source-color"; import { SendRawOBSWebSocketRequestEffectType } from "./effects/send-raw-obs-websocket-request"; import { TakeOBSSourceScreenshotEffectType } from "./effects/take-obs-source-screenshot"; + import { OBSEventSource } from "./events/obs-event-source"; + import { SceneNameEventFilter } from "./filters/scene-name-filter"; + import { SceneNameVariable } from "./variables/scene-name-variable"; import { SceneCollectionNameVariable } from "./variables/scene-collection-name"; import { IsStreamingVariable } from "./variables/is-streaming"; @@ -43,11 +54,20 @@ import { ProfileNameVariable } from "./variables/profile-name"; import { VendorNameVariable } from "./variables/vendor-name"; import { VendorEventTypeVariable } from "./variables/vendor-event-type"; import { VendorEventDataVariable } from "./variables/vendor-event-data"; -import { setupFrontendListeners } from "./communicator"; -import effectManager from "../../../effects/effectManager"; -import eventFilterManager from "../../../events/filters/filter-manager"; -import replaceVariableManager from "../../../variables/replace-variable-manager"; -import frontendCommunicator from "../../../common/frontend-communicator"; +import { InputNameVariable } from "./variables/input-name"; +import { InputUuidVariable } from "./variables/input-uuid"; +import { InputKindVariable } from "./variables/input-kind"; +import { OldInputNameVariable } from "./variables/old-input-name"; +import { InputSettingsVariable } from "./variables/input-settings"; +import { InputActiveVariable } from "./variables/input-active"; +import { InputShowingVariable } from "./variables/input-showing"; +import { InputMutedVariable } from "./variables/input-muted"; +import { InputVolumeDbVariable } from "./variables/input-volume-db"; +import { InputVolumeMultiplierVariable } from "./variables/input-volume-multiplier"; +import { InputAudioBalanceVariable } from "./variables/input-audio-balance"; +import { InputAudioSyncOffsetVariable } from "./variables/input-audio-sync-offset"; +import { InputAudioTracksVariable } from "./variables/input-audio-tracks"; +import { InputAudioMonitorTypeVariable } from "./variables/input-audio-monitor-type"; type ObsSettings = { websocketSettings: { @@ -151,6 +171,20 @@ class ObsIntegration replaceVariableManager.registerReplaceVariable(VendorNameVariable); replaceVariableManager.registerReplaceVariable(VendorEventTypeVariable); replaceVariableManager.registerReplaceVariable(VendorEventDataVariable); + replaceVariableManager.registerReplaceVariable(InputNameVariable); + replaceVariableManager.registerReplaceVariable(InputUuidVariable); + replaceVariableManager.registerReplaceVariable(InputKindVariable); + replaceVariableManager.registerReplaceVariable(InputSettingsVariable); + replaceVariableManager.registerReplaceVariable(OldInputNameVariable); + replaceVariableManager.registerReplaceVariable(InputActiveVariable); + replaceVariableManager.registerReplaceVariable(InputShowingVariable); + replaceVariableManager.registerReplaceVariable(InputMutedVariable); + replaceVariableManager.registerReplaceVariable(InputVolumeDbVariable); + replaceVariableManager.registerReplaceVariable(InputVolumeMultiplierVariable); + replaceVariableManager.registerReplaceVariable(InputAudioBalanceVariable); + replaceVariableManager.registerReplaceVariable(InputAudioSyncOffsetVariable); + replaceVariableManager.registerReplaceVariable(InputAudioTracksVariable); + replaceVariableManager.registerReplaceVariable(InputAudioMonitorTypeVariable); this.setupConnection(integrationData.userSettings); } diff --git a/src/backend/integrations/builtin/obs/obs-remote.ts b/src/backend/integrations/builtin/obs/obs-remote.ts index 0474bf127..81a9032d7 100644 --- a/src/backend/integrations/builtin/obs/obs-remote.ts +++ b/src/backend/integrations/builtin/obs/obs-remote.ts @@ -16,7 +16,19 @@ import { OBS_SCENE_TRANSITION_STARTED_EVENT_ID, OBS_STREAM_STARTED_EVENT_ID, OBS_STREAM_STOPPED_EVENT_ID, - OBS_VENDOR_EVENT_EVENT_ID + OBS_VENDOR_EVENT_EVENT_ID, + OBS_INPUT_CREATED_EVENT_ID, + OBS_INPUT_REMOVED_EVENT_ID, + OBS_INPUT_NAME_CHANGED_EVENT_ID, + OBS_INPUT_SETTINGS_CHANGED_EVENT_ID, + OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID, + OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_VOLUME_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID } from "./constants"; import logger from "../../../logwrapper"; @@ -180,6 +192,151 @@ function setupRemoteListeners() { } ); }); + + obs.on("InputCreated", ({ inputName, inputUuid, inputKind, inputSettings }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_CREATED_EVENT_ID, + { + inputName, + inputUuid, + inputKind, + inputSettings + } + ); + }); + + obs.on("InputRemoved", ({ inputName, inputUuid }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_REMOVED_EVENT_ID, + { + inputName, + inputUuid + } + ); + }); + + obs.on("InputNameChanged", ({ oldInputName, inputName, inputUuid }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_NAME_CHANGED_EVENT_ID, + { + oldInputName, + inputName, + inputUuid + } + ); + }); + + obs.on("InputSettingsChanged", ({ inputName, inputUuid, inputSettings }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_SETTINGS_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputSettings + } + ); + }); + + obs.on("InputActiveStateChanged", ({ inputName, inputUuid, videoActive }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputActive: videoActive + } + ); + }); + + obs.on("InputShowStateChanged", ({ inputName, inputUuid, videoShowing }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputShowing: videoShowing + } + ); + }); + + obs.on("InputMuteStateChanged", ({ inputName, inputUuid, inputMuted }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputMuted + } + ); + }); + + obs.on("InputVolumeChanged", ({ inputName, inputUuid, inputVolumeMul, inputVolumeDb }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_VOLUME_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputVolumeMultiplier: inputVolumeMul, + inputVolumeDb + } + ); + }); + + obs.on("InputAudioBalanceChanged", ({ inputName, inputUuid, inputAudioBalance }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputAudioBalance + } + ); + }); + + obs.on("InputAudioSyncOffsetChanged", ({ inputName, inputUuid, inputAudioSyncOffset }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputAudioSyncOffset + } + ); + }); + + obs.on("InputAudioTracksChanged", ({ inputName, inputUuid, inputAudioTracks }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + inputAudioTracks + } + ); + }); + + obs.on("InputAudioMonitorTypeChanged", ({ inputName, inputUuid, monitorType }) => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, + { + inputName, + inputUuid, + monitorType + } + ); + }); } let reconnectTimeout: NodeJS.Timeout | null = null; @@ -275,7 +432,7 @@ export async function getSceneList(): Promise { } try { const sceneData = await obs.call("GetSceneList"); - return sceneData.scenes.map((s) => s.sceneName as string); + return sceneData.scenes.map(s => s.sceneName as string); } catch (error) { return []; } @@ -372,7 +529,7 @@ export async function getSourceData(): Promise { sceneName: item.sourceName as string }); - const groupItems = groupItemList.sceneItems.map((gi) => ({ + const groupItems = groupItemList.sceneItems.map(gi => ({ id: gi.sceneItemId as number, name: gi.sourceName as string, groupName: item.sourceName as string @@ -492,7 +649,7 @@ export async function getAllSources(): Promise | null> { if (sourceListData?.inputs == null) { return null; } - const sources: OBSSource[] = sourceListData.inputs.map((i) => ({ + const sources: OBSSource[] = sourceListData.inputs.map(i => ({ name: i.inputName as string, type: i.inputKind as string, typeId: i.inputKind as string, @@ -502,7 +659,7 @@ export async function getAllSources(): Promise | null> { const sceneNameList = await getSceneList(); sources.push( ...sceneNameList.map( - (s) => + s => ({ name: s, filters: [], @@ -518,7 +675,7 @@ export async function getAllSources(): Promise | null> { }); source.filters = ( sourceFiltersData.filters as unknown as Array - ).map((f) => ({ name: f.filterName, enabled: f.filterEnabled })); + ).map(f => ({ name: f.filterName, enabled: f.filterEnabled })); } return sources; } catch (error) { @@ -530,7 +687,7 @@ export async function getAllSources(): Promise | null> { export async function getAllSceneItemsInScene(sceneName: string): Promise> { try { const response = await obs.call("GetSceneItemList", { sceneName }); - return response.sceneItems.map((item) => ({ + return response.sceneItems.map(item => ({ id: item.sceneItemId as number, name: item.sourceName as string })); @@ -552,7 +709,7 @@ export async function getSceneItem(sceneName: string, sceneItemId: number): Prom export async function getSourcesWithFilters(): Promise> { const sources = await getAllSources(); - return sources?.filter((s) => s.filters?.length > 0); + return sources?.filter(s => s.filters?.length > 0); } export async function getFilterEnabledStatus( @@ -643,7 +800,7 @@ export async function setSourceMuted(sourceName: string, muted: boolean) { export async function getTextSources(): Promise> { const sources = await getAllSources(); - return sources?.filter((s) => s.typeId === "text_gdiplus_v2" || s.typeId === "text_ft2_source_v2"); + return sources?.filter(s => s.typeId === "text_gdiplus_v2" || s.typeId === "text_ft2_source_v2"); } export async function setTextSourceSettings(sourceName: string, settings: OBSTextSourceSettings) { @@ -678,7 +835,7 @@ export async function setTextSourceSettings(sourceName: string, settings: OBSTex export async function getBrowserSources(): Promise> { const sources = await getAllSources(); - return sources?.filter((s) => s.typeId === "browser_source"); + return sources?.filter(s => s.typeId === "browser_source"); } export async function setBrowserSourceSettings(sourceName: string, settings: OBSBrowserSourceSettings) { @@ -696,7 +853,7 @@ export async function setBrowserSourceSettings(sourceName: string, settings: OBS export async function getImageSources(): Promise> { const sources = await getAllSources(); - return sources?.filter((s) => s.typeId === "image_source"); + return sources?.filter(s => s.typeId === "image_source"); } export async function setImageSourceSettings(sourceName: string, settings: OBSImageSourceSettings) { @@ -714,7 +871,7 @@ export async function setImageSourceSettings(sourceName: string, settings: OBSIm export async function getMediaSources(): Promise> { const sources = await getAllSources(); - return sources?.filter((s) => s.typeId === "ffmpeg_source"); + return sources?.filter(s => s.typeId === "ffmpeg_source"); } export async function setMediaSourceSettings(sourceName: string, settings: OBSMediaSourceSettings) { @@ -734,7 +891,7 @@ export async function setMediaSourceSettings(sourceName: string, settings: OBSMe export async function getColorSources(): Promise> { const sources = await getAllSources(); - return sources?.filter((s) => s.typeId === "color_source_v3"); + return sources?.filter(s => s.typeId === "color_source_v3"); } export async function setColorSourceSettings(sourceName: string, settings: OBSColorSourceSettings) { diff --git a/src/backend/integrations/builtin/obs/variables/input-active.ts b/src/backend/integrations/builtin/obs/variables/input-active.ts new file mode 100644 index 000000000..36471fac5 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-active.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputActiveVariable: ReplaceVariable = { + definition: { + handle: "obsInputActive", + description: "Returns `true` if the OBS input is active or `false` if it is not.", + possibleDataOutput: ["bool"] + }, + evaluator: async (trigger) => { + const inputActive = trigger.metadata?.eventData?.inputActive; + return inputActive ?? false; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-audio-balance.ts b/src/backend/integrations/builtin/obs/variables/input-audio-balance.ts new file mode 100644 index 000000000..dce56b82a --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-audio-balance.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputAudioBalanceVariable: ReplaceVariable = { + definition: { + handle: "obsInputAudioBalance", + description: "Returns the audio balance value of the OBS input.", + possibleDataOutput: ["number"] + }, + evaluator: async (trigger) => { + const inputAudioBalance = trigger.metadata?.eventData?.inputAudioBalance; + return inputAudioBalance ?? 0; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-audio-monitor-type.ts b/src/backend/integrations/builtin/obs/variables/input-audio-monitor-type.ts new file mode 100644 index 000000000..cfb78b4b2 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-audio-monitor-type.ts @@ -0,0 +1,35 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputAudioMonitorTypeVariable: ReplaceVariable = { + definition: { + handle: "obsInputMonitorType", + description: "Returns the audio monitor type of the OBS input. Values are `None`, `Monitor Only`, or `Monitor and Output`.", + possibleDataOutput: ["object"] + }, + evaluator: async (trigger) => { + const monitorType = trigger.metadata?.eventData?.monitorType; + + switch (monitorType) { + case "OBS_MONITORING_TYPE_MONITOR_ONLY": + return "Monitor Only"; + + case "OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT": + return "Monitor and Output"; + + case "OBS_MONITORING_TYPE_NONE": + default: + return "None"; + } + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-audio-sync-offset.ts b/src/backend/integrations/builtin/obs/variables/input-audio-sync-offset.ts new file mode 100644 index 000000000..b672cc274 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-audio-sync-offset.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputAudioSyncOffsetVariable: ReplaceVariable = { + definition: { + handle: "obsInputAudioSyncOffset", + description: "Returns the audio sync offset (in milliseconds) of the OBS input.", + possibleDataOutput: ["number"] + }, + evaluator: async (trigger) => { + const inputAudioSyncOffset = trigger.metadata?.eventData?.inputAudioSyncOffset; + return inputAudioSyncOffset ?? 0; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-audio-tracks.ts b/src/backend/integrations/builtin/obs/variables/input-audio-tracks.ts new file mode 100644 index 000000000..d68685774 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-audio-tracks.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputAudioTracksVariable: ReplaceVariable = { + definition: { + handle: "obsInputAudioTracks", + description: "Returns the raw OBS audio tracks object of the OBS input.", + possibleDataOutput: ["object"] + }, + evaluator: async (trigger) => { + const inputAudioTracks = trigger.metadata?.eventData?.inputAudioTracks; + return inputAudioTracks ?? {}; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-kind.ts b/src/backend/integrations/builtin/obs/variables/input-kind.ts new file mode 100644 index 000000000..75a447cfa --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-kind.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_CREATED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_CREATED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputKindVariable: ReplaceVariable = { + definition: { + handle: "obsInputKind", + description: "Returns the OBS internal name of the kind of OBS input.", + possibleDataOutput: ["text"] + }, + evaluator: async (trigger) => { + const inputKind = trigger.metadata?.eventData?.inputKind; + return inputKind ?? "Unknown"; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-muted.ts b/src/backend/integrations/builtin/obs/variables/input-muted.ts new file mode 100644 index 000000000..9916ad382 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-muted.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputMutedVariable: ReplaceVariable = { + definition: { + handle: "obsInputMuted", + description: "Returns `true` if the OBS input is muted or `false` if it is not.", + possibleDataOutput: ["bool"] + }, + evaluator: async (trigger) => { + const inputMuted = trigger.metadata?.eventData?.inputMuted; + return inputMuted ?? false; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-name.ts b/src/backend/integrations/builtin/obs/variables/input-name.ts new file mode 100644 index 000000000..d1fc71046 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-name.ts @@ -0,0 +1,46 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_CREATED_EVENT_ID, + OBS_INPUT_REMOVED_EVENT_ID, + OBS_INPUT_NAME_CHANGED_EVENT_ID, + OBS_INPUT_SETTINGS_CHANGED_EVENT_ID, + OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID, + OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_VOLUME_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_CREATED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_REMOVED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_NAME_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_SETTINGS_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_VOLUME_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputNameVariable: ReplaceVariable = { + definition: { + handle: "obsInputName", + description: "Returns the name of the OBS input.", + possibleDataOutput: ["text"] + }, + evaluator: async (trigger) => { + const inputName = trigger.metadata?.eventData?.inputName; + return inputName ?? "Unknown"; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-settings.ts b/src/backend/integrations/builtin/obs/variables/input-settings.ts new file mode 100644 index 000000000..735ec45ec --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-settings.ts @@ -0,0 +1,26 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_CREATED_EVENT_ID, + OBS_INPUT_SETTINGS_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_CREATED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_SETTINGS_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputSettingsVariable: ReplaceVariable = { + definition: { + handle: "obsInputSettings", + description: "Returns the raw OBS settings object of the OBS input.", + possibleDataOutput: ["object"] + }, + evaluator: async (trigger) => { + const inputSettings = trigger.metadata?.eventData?.inputSettings; + return inputSettings ?? {}; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-showing.ts b/src/backend/integrations/builtin/obs/variables/input-showing.ts new file mode 100644 index 000000000..49ed32691 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-showing.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputShowingVariable: ReplaceVariable = { + definition: { + handle: "obsInputShowing", + description: "Returns `true` if the OBS input is currently showing or `false` if it is not.", + possibleDataOutput: ["bool"] + }, + evaluator: async (trigger) => { + const inputShowing = trigger.metadata?.eventData?.inputShowing; + return inputShowing ?? false; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-uuid.ts b/src/backend/integrations/builtin/obs/variables/input-uuid.ts new file mode 100644 index 000000000..6459df4a0 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-uuid.ts @@ -0,0 +1,46 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_CREATED_EVENT_ID, + OBS_INPUT_REMOVED_EVENT_ID, + OBS_INPUT_NAME_CHANGED_EVENT_ID, + OBS_INPUT_SETTINGS_CHANGED_EVENT_ID, + OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID, + OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID, + OBS_INPUT_VOLUME_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, + OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_CREATED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_REMOVED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_NAME_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_SETTINGS_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_ACTIVE_STATE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_SHOW_STATE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_MUTE_STATE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_VOLUME_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID}`, + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputUuidVariable: ReplaceVariable = { + definition: { + handle: "obsInputUuid", + description: "Returns the UUID of the OBS input.", + possibleDataOutput: ["text"] + }, + evaluator: async (trigger) => { + const inputUuid = trigger.metadata?.eventData?.inputUuid; + return inputUuid ?? "Unknown"; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-volume-db.ts b/src/backend/integrations/builtin/obs/variables/input-volume-db.ts new file mode 100644 index 000000000..dd5661e4d --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-volume-db.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_VOLUME_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_VOLUME_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputVolumeDbVariable: ReplaceVariable = { + definition: { + handle: "obsInputVolumeDb", + description: "Returns the volume level in dB of the OBS input.", + possibleDataOutput: ["number"] + }, + evaluator: async (trigger) => { + const inputVolumeDb = trigger.metadata?.eventData?.inputVolumeDb; + return inputVolumeDb ?? 0; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/input-volume-multiplier.ts b/src/backend/integrations/builtin/obs/variables/input-volume-multiplier.ts new file mode 100644 index 000000000..3de5ce1dd --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/input-volume-multiplier.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_VOLUME_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_VOLUME_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const InputVolumeMultiplierVariable: ReplaceVariable = { + definition: { + handle: "obsInputVolumeMultiplier", + description: "Returns the volume level multiplier of the OBS input.", + possibleDataOutput: ["number"] + }, + evaluator: async (trigger) => { + const inputVolumeMultiplier = trigger.metadata?.eventData?.inputVolumeMultiplier; + return inputVolumeMultiplier ?? 0; + } +}; diff --git a/src/backend/integrations/builtin/obs/variables/old-input-name.ts b/src/backend/integrations/builtin/obs/variables/old-input-name.ts new file mode 100644 index 000000000..32bc93571 --- /dev/null +++ b/src/backend/integrations/builtin/obs/variables/old-input-name.ts @@ -0,0 +1,24 @@ +import { ReplaceVariable } from "../../../../../types/variables"; +import { TriggerType } from "../../../../common/EffectType"; +import { + OBS_EVENT_SOURCE_ID, + OBS_INPUT_NAME_CHANGED_EVENT_ID +} from "../constants"; + +const triggers = {}; +triggers[TriggerType.EVENT] = [ + `${OBS_EVENT_SOURCE_ID}:${OBS_INPUT_NAME_CHANGED_EVENT_ID}` +]; +triggers[TriggerType.MANUAL] = true; + +export const OldInputNameVariable: ReplaceVariable = { + definition: { + handle: "obsOldInputName", + description: "Returns the previous name of the OBS input.", + possibleDataOutput: ["text"] + }, + evaluator: async (trigger) => { + const oldInputName = trigger.metadata?.eventData?.oldInputName; + return oldInputName ?? "Unknown"; + } +}; From 6076f3dafd1d04b3275b54f141b699beed63e4dd Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 21 Mar 2024 14:47:14 -0400 Subject: [PATCH 105/113] fix: hide ad break timer when no upcoming scheduled ads --- src/backend/twitch-api/ad-manager.ts | 2 ++ src/gui/app/services/ad-break.service.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/backend/twitch-api/ad-manager.ts b/src/backend/twitch-api/ad-manager.ts index d9c2db139..b7eac9a55 100644 --- a/src/backend/twitch-api/ad-manager.ts +++ b/src/backend/twitch-api/ad-manager.ts @@ -60,6 +60,8 @@ class AdManager { adBreakDuration: adSchedule.duration }); } + } else { + frontendCommunicator.send("ad-manager:hide-ad-break-timer"); } logger.debug("Ad timer check complete."); diff --git a/src/gui/app/services/ad-break.service.js b/src/gui/app/services/ad-break.service.js index 38bbaa56d..32e73adba 100644 --- a/src/gui/app/services/ad-break.service.js +++ b/src/gui/app/services/ad-break.service.js @@ -41,6 +41,10 @@ service.updateDuration(); }); + backendCommunicator.on("ad-manager:hide-ad-break-timer", () => { + service.showAdBreakTimer = false; + }); + return service; }); }()); \ No newline at end of file From fc2d1c9562aeb8b8b45b2176f2c7a1bbf9d0b471 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 21 Mar 2024 21:55:40 -0400 Subject: [PATCH 106/113] fix(vars): round off minutes until next ad break --- .../variables/builtin/twitch/ads/minutes-until-next-ad-break.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts b/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts index 3c8c7c323..87fdaaadb 100644 --- a/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts +++ b/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts @@ -21,7 +21,7 @@ const model : ReplaceVariable = { } } - return minutesUntilNextAdBreak; + return Math.round(minutesUntilNextAdBreak); } }; From 43816e1504b1a9d276f01becea09fa71a1acefe0 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Fri, 22 Mar 2024 09:27:47 -0500 Subject: [PATCH 107/113] fix: update shorthand validation regex for magic variables --- src/gui/app/directives/controls/effectList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/app/directives/controls/effectList.js b/src/gui/app/directives/controls/effectList.js index dca23fe4f..dc3729189 100644 --- a/src/gui/app/directives/controls/effectList.js +++ b/src/gui/app/directives/controls/effectList.js @@ -569,7 +569,7 @@ } function stringCanBeShorthand(str) { - return /^[a-zA-Z]{3,}$/.test(str); + return /^([a-z][a-z\d._-]+)([\s\S]*)$/i.test(str); } function checkEffectListForMagicVariables(effects, ignoreEffectId) { From 682bf2051802ebe780e0110c02d378b11072dfbe Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 22 Mar 2024 11:15:35 -0400 Subject: [PATCH 108/113] fix: internal web server now forced to listen on both IPv4 and IPv6 --- src/server/http-server-manager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/http-server-manager.js b/src/server/http-server-manager.js index 690bac125..de10435fc 100644 --- a/src/server/http-server-manager.js +++ b/src/server/http-server-manager.js @@ -197,7 +197,7 @@ class HttpServerManager extends EventEmitter { }); try { - this.overlayServer = this.defaultHttpServer.listen(port, "0.0.0.0", () => { + this.overlayServer = this.defaultHttpServer.listen(port, ["0.0.0.0", "::"], () => { this.isDefaultServerStarted = true; this.serverInstances.push({ @@ -246,7 +246,7 @@ class HttpServerManager extends EventEmitter { } let newHttpServer = http.createServer(instance); - newHttpServer = newHttpServer.listen(port, "0.0.0.0"); + newHttpServer = newHttpServer.listen(port, ["0.0.0.0", "::"]); this.serverInstances.push({ name: name, From e000feac9b3089d60c576b14def4d3331351a344 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 22 Mar 2024 11:25:12 -0400 Subject: [PATCH 109/113] fix(events): more precision to upcoming ad break event --- src/backend/twitch-api/ad-manager.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/backend/twitch-api/ad-manager.ts b/src/backend/twitch-api/ad-manager.ts index b7eac9a55..ba1591fbd 100644 --- a/src/backend/twitch-api/ad-manager.ts +++ b/src/backend/twitch-api/ad-manager.ts @@ -47,18 +47,32 @@ class AdManager { }); const upcomingTriggerMinutes = Number(settings.getTriggerUpcomingAdBreakMinutes()); - const minutesUntilNextAdBreak = Math.abs(DateTime.fromJSDate(adSchedule.nextAdDate).diffNow("minutes").minutes); + let minutesUntilNextAdBreak = Math.abs(DateTime.fromJSDate(adSchedule.nextAdDate).diffNow("minutes").minutes); if (upcomingTriggerMinutes > 0 && this._upcomingEventTriggered !== true - && minutesUntilNextAdBreak <= upcomingTriggerMinutes + && minutesUntilNextAdBreak <= (upcomingTriggerMinutes + 1) ) { this._upcomingEventTriggered = true; - eventManager.triggerEvent("twitch", "ad-break-upcoming", { - minutesUntilNextAdBreak: minutesUntilNextAdBreak, - adBreakDuration: adSchedule.duration - }); + /** + * Adding some precision to the upcoming ad break event + * If we're past the threshold already, trigger immediately + * Otherwise, get as close to the threshold as possible + */ + let timeout = 1; + if (minutesUntilNextAdBreak > upcomingTriggerMinutes) { + const diff = minutesUntilNextAdBreak - upcomingTriggerMinutes; + timeout = diff * 60 * 1000; + minutesUntilNextAdBreak -= diff; + } + + setTimeout(() => { + eventManager.triggerEvent("twitch", "ad-break-upcoming", { + minutesUntilNextAdBreak: minutesUntilNextAdBreak, + adBreakDuration: adSchedule.duration + }); + }, timeout); } } else { frontendCommunicator.send("ad-manager:hide-ad-break-timer"); From 8fcc37f66384f8358b448805bedb5e3396e6e1df Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 22 Mar 2024 11:55:43 -0400 Subject: [PATCH 110/113] chore: change to secondsUntilNextAdBreak, cache next ad break time --- .../events/builtin/twitch-event-source.js | 4 ++-- src/backend/twitch-api/ad-manager.ts | 18 +++++++++++++----- .../variables/builtin/twitch/ads/index.ts | 4 ++-- ...break.ts => seconds-until-next-ad-break.ts} | 15 +++++++++------ 4 files changed, 26 insertions(+), 15 deletions(-) rename src/backend/variables/builtin/twitch/ads/{minutes-until-next-ad-break.ts => seconds-until-next-ad-break.ts} (55%) diff --git a/src/backend/events/builtin/twitch-event-source.js b/src/backend/events/builtin/twitch-event-source.js index a0d8a8574..676f9296b 100644 --- a/src/backend/events/builtin/twitch-event-source.js +++ b/src/backend/events/builtin/twitch-event-source.js @@ -929,7 +929,7 @@ module.exports = { cached: false, manualMetadata: { adBreakDuration: 60, - minutesUntilNextAdBreak: 5 + secondsUntilNextAdBreak: 300 }, activityFeed: { icon: "fad fa-ad", @@ -941,7 +941,7 @@ module.exports = { ? `${mins}m${remainingSecs > 0 ? ` ${remainingSecs}s` : ""}` : `${eventData.adBreakDuration}s`; - return `**${friendlyDuration}** scheduled ad break starting in **${eventData.minutesUntilNextAdBreak}** minutes`; + return `**${friendlyDuration}** scheduled ad break starting in about **${Math.round(eventData.secondsUntilNextAdBreak / 60)}** minutes`; } } }, diff --git a/src/backend/twitch-api/ad-manager.ts b/src/backend/twitch-api/ad-manager.ts index ba1591fbd..df47ad207 100644 --- a/src/backend/twitch-api/ad-manager.ts +++ b/src/backend/twitch-api/ad-manager.ts @@ -9,6 +9,7 @@ import eventManager from "../events/EventManager"; class AdManager { private _adCheckIntervalId: NodeJS.Timeout; + private _nextAdBreak: DateTime = null; private _isAdCheckRunning = false; private _upcomingEventTriggered = false; private _isAdRunning = false; @@ -19,6 +20,12 @@ class AdManager { }); } + get secondsUntilNextAdBreak(): number { + return this._nextAdBreak != null + ? Math.round(Math.abs(this._nextAdBreak.diffNow("seconds").seconds)) + : 0; + } + async runAdCheck(): Promise { if (this._isAdCheckRunning === true) { return; @@ -41,13 +48,15 @@ class AdManager { const adSchedule = await twitchApi.channels.getAdSchedule(); if (adSchedule?.nextAdDate != null) { + this._nextAdBreak = DateTime.fromJSDate(adSchedule.nextAdDate); + frontendCommunicator.send("ad-manager:next-ad", { nextAdBreak: adSchedule.nextAdDate, duration: adSchedule.duration }); const upcomingTriggerMinutes = Number(settings.getTriggerUpcomingAdBreakMinutes()); - let minutesUntilNextAdBreak = Math.abs(DateTime.fromJSDate(adSchedule.nextAdDate).diffNow("minutes").minutes); + const minutesUntilNextAdBreak = this.secondsUntilNextAdBreak / 60; if (upcomingTriggerMinutes > 0 && this._upcomingEventTriggered !== true @@ -62,19 +71,18 @@ class AdManager { */ let timeout = 1; if (minutesUntilNextAdBreak > upcomingTriggerMinutes) { - const diff = minutesUntilNextAdBreak - upcomingTriggerMinutes; - timeout = diff * 60 * 1000; - minutesUntilNextAdBreak -= diff; + timeout = this.secondsUntilNextAdBreak * 1000; } setTimeout(() => { eventManager.triggerEvent("twitch", "ad-break-upcoming", { - minutesUntilNextAdBreak: minutesUntilNextAdBreak, + secondsUntilNextAdBreak: this.secondsUntilNextAdBreak, adBreakDuration: adSchedule.duration }); }, timeout); } } else { + this._nextAdBreak = null; frontendCommunicator.send("ad-manager:hide-ad-break-timer"); } diff --git a/src/backend/variables/builtin/twitch/ads/index.ts b/src/backend/variables/builtin/twitch/ads/index.ts index 22f82cfcc..ede8b4d0e 100644 --- a/src/backend/variables/builtin/twitch/ads/index.ts +++ b/src/backend/variables/builtin/twitch/ads/index.ts @@ -1,9 +1,9 @@ import adBreakDuration from "./ad-break-duration"; import isAdBreakScheduled from "./is-ad-break-scheduled"; -import minutesUntilNextAdBreak from "./minutes-until-next-ad-break"; +import secondsUntilNextAdBreak from "./seconds-until-next-ad-break"; export default [ adBreakDuration, isAdBreakScheduled, - minutesUntilNextAdBreak + secondsUntilNextAdBreak ]; \ No newline at end of file diff --git a/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts b/src/backend/variables/builtin/twitch/ads/seconds-until-next-ad-break.ts similarity index 55% rename from src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts rename to src/backend/variables/builtin/twitch/ads/seconds-until-next-ad-break.ts index 87fdaaadb..6eb4c20b4 100644 --- a/src/backend/variables/builtin/twitch/ads/minutes-until-next-ad-break.ts +++ b/src/backend/variables/builtin/twitch/ads/seconds-until-next-ad-break.ts @@ -2,26 +2,29 @@ import { DateTime } from "luxon"; import { ReplaceVariable } from "../../../../../types/variables"; import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; import twitchApi from "../../../../twitch-api/api"; +import adManager from "../../../../twitch-api/ad-manager"; const model : ReplaceVariable = { definition: { - handle: "minutesUntilNextAdBreak", - description: "The number of minutes until the next schduled ad break", + handle: "secondsUntilNextAdBreak", + description: "The number of seconds until the next schduled ad break", categories: [VariableCategory.COMMON, VariableCategory.TRIGGER], possibleDataOutput: [OutputDataType.NUMBER] }, evaluator: async (trigger) => { - let minutesUntilNextAdBreak = trigger.metadata?.eventData?.minutesUntilNextAdBreak ?? 0; + let secondsUntilNextAdBreak = trigger.metadata?.eventData?.secondsUntilNextAdBreak + ?? adManager.secondsUntilNextAdBreak + ?? 0; - if (minutesUntilNextAdBreak === 0) { + if (secondsUntilNextAdBreak === 0) { const adSchedule = await twitchApi.channels.getAdSchedule(); if (adSchedule?.nextAdDate != null) { - minutesUntilNextAdBreak = Math.abs(DateTime.fromJSDate(adSchedule.nextAdDate).diffNow("minutes").minutes); + secondsUntilNextAdBreak = Math.abs(DateTime.fromJSDate(adSchedule.nextAdDate).diffNow("seconds").seconds); } } - return Math.round(minutesUntilNextAdBreak); + return secondsUntilNextAdBreak; } }; From db740b35ba5a92e6e9f30a5ab036a068c3c45eb4 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 22 Mar 2024 15:10:49 -0400 Subject: [PATCH 111/113] fix(events): minor display text change --- src/backend/events/builtin/twitch-event-source.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/backend/events/builtin/twitch-event-source.js b/src/backend/events/builtin/twitch-event-source.js index 676f9296b..d4da0b7c2 100644 --- a/src/backend/events/builtin/twitch-event-source.js +++ b/src/backend/events/builtin/twitch-event-source.js @@ -941,7 +941,9 @@ module.exports = { ? `${mins}m${remainingSecs > 0 ? ` ${remainingSecs}s` : ""}` : `${eventData.adBreakDuration}s`; - return `**${friendlyDuration}** scheduled ad break starting in about **${Math.round(eventData.secondsUntilNextAdBreak / 60)}** minutes`; + const minutesUntilNextAdBreak = Math.round(eventData.secondsUntilNextAdBreak / 60); + + return `**${friendlyDuration}** scheduled ad break starting in about **${minutesUntilNextAdBreak}** minute${minutesUntilNextAdBreak !== 1 ? "s" : ""}`; } } }, From ba0d8303c67869e174093e23053e9bfad015c89c Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 22 Mar 2024 17:12:14 -0400 Subject: [PATCH 112/113] fix(events): fix upcoming ad break event timing --- src/backend/twitch-api/ad-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/twitch-api/ad-manager.ts b/src/backend/twitch-api/ad-manager.ts index df47ad207..ac67c4389 100644 --- a/src/backend/twitch-api/ad-manager.ts +++ b/src/backend/twitch-api/ad-manager.ts @@ -71,7 +71,7 @@ class AdManager { */ let timeout = 1; if (minutesUntilNextAdBreak > upcomingTriggerMinutes) { - timeout = this.secondsUntilNextAdBreak * 1000; + timeout = (this.secondsUntilNextAdBreak - (upcomingTriggerMinutes * 60)) * 1000; } setTimeout(() => { From 08dc0e24d3f3eec476fe16cb4e710470b7672e8b Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 22 Mar 2024 21:39:17 -0400 Subject: [PATCH 113/113] 5.62.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 31050335f..fd35be933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebotv5", - "version": "5.62.0-beta4", + "version": "5.62.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebotv5", - "version": "5.62.0-beta4", + "version": "5.62.0", "license": "GPL-3.0", "dependencies": { "@aws-sdk/client-polly": "^3.26.0", diff --git a/package.json b/package.json index 6529da9a5..cb909c8de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebotv5", - "version": "5.62.0-beta4", + "version": "5.62.0", "description": "Powerful all-in-one bot for Twitch streamers.", "main": "build/main.js", "scripts": {