diff --git a/package-lock.json b/package-lock.json index dafa185d3..04eca041c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "electron-window-state": "^4.1.1", "eventsource": "^1.0.7", "express": "^4.17.1", - "expressionish": "github:SReject/expressionish#87101258cc46c1cee83a0e7d4c3ca58689ccba27", + "expressionish": "github:SReject/expressionish#6b8893e182dcf94a9704687196e95c5175e6ff1e", "extra-life-ts": "^0.4.0", "fflate": "^0.8.1", "form-data": "^4.0.0", @@ -6655,8 +6655,8 @@ }, "node_modules/expressionish": { "version": "0.0.3", - "resolved": "git+ssh://git@github.com/SReject/expressionish.git#87101258cc46c1cee83a0e7d4c3ca58689ccba27", - "integrity": "sha512-BcyHhD+FJ+ov2Ek9SccVMxwvCAhvIhtLzOh6Sc149JgJNYKiVJrbNMDQrTodh8bEPnzH+mmedhZlpwIYiTGUFQ==", + "resolved": "git+ssh://git@github.com/SReject/expressionish.git#6b8893e182dcf94a9704687196e95c5175e6ff1e", + "integrity": "sha512-CKqPTZ1hqTXkctiTl+vxLYIxJ+RA1fX1OlpGfSyXIW8XzVIQZ275tIpZ+e6Hl0xtiOrwBw6+UQZf6jZR5EgWwg==", "license": "ISC" }, "node_modules/extend": { diff --git a/package.json b/package.json index 6b9dbbb72..187262a3b 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "electron-window-state": "^4.1.1", "eventsource": "^1.0.7", "express": "^4.17.1", - "expressionish": "github:SReject/expressionish#87101258cc46c1cee83a0e7d4c3ca58689ccba27", + "expressionish": "github:SReject/expressionish#6b8893e182dcf94a9704687196e95c5175e6ff1e", "extra-life-ts": "^0.4.0", "fflate": "^0.8.1", "form-data": "^4.0.0", diff --git a/src/backend/auth/firebot-device-auth-provider.ts b/src/backend/auth/firebot-device-auth-provider.ts index bed21a097..b1f3b9c75 100644 --- a/src/backend/auth/firebot-device-auth-provider.ts +++ b/src/backend/auth/firebot-device-auth-provider.ts @@ -58,6 +58,7 @@ class FirebotDeviceAuthProvider { }); this.streamerProvider.onRefresh((userId, token) => this.onRefresh("streamer", userId, token)); + this.streamerProvider.onRefreshFailure(() => accountAccess.setAccountTokenIssue("streamer")); } else { this.streamerProvider = null; } @@ -82,6 +83,7 @@ class FirebotDeviceAuthProvider { }); this.botProvider.onRefresh((userId, token) => this.onRefresh("bot", userId, token)); + this.botProvider.onRefreshFailure(() => accountAccess.setAccountTokenIssue("bot")); } else { this.botProvider = null; } diff --git a/src/backend/common/account-access.js b/src/backend/common/account-access.js index 5afc1bc83..4c8ecfa3a 100644 --- a/src/backend/common/account-access.js +++ b/src/backend/common/account-access.js @@ -215,16 +215,27 @@ frontendCommunicator.on("getAccounts", () => { return cache; }); -frontendCommunicator.on("logoutAccount", accountType => { +frontendCommunicator.on("logoutAccount", (accountType) => { logger.debug("got logout request for", accountType); removeAccount(accountType); }); +function setAccountTokenIssue(accountType) { + if (accountType === "streamer") { + streamerTokenIssue = true; + } else if (accountType === "bot") { + botTokenIssue = true; + } else { + throw new Error("invalid account type"); + } +} + exports.events = accountEvents; exports.updateAccountCache = loadAccountData; exports.updateAccount = updateAccount; exports.updateStreamerAccountSettings = updateStreamerAccountSettings; exports.getAccounts = () => cache; +exports.setAccountTokenIssue = setAccountTokenIssue; exports.streamerTokenIssue = () => streamerTokenIssue; exports.botTokenIssue = () => botTokenIssue; exports.refreshTwitchData = refreshTwitchData; \ No newline at end of file diff --git a/src/backend/common/custom-variable-manager.js b/src/backend/common/custom-variable-manager.js index 5f5a0c4e1..5ff8b938a 100644 --- a/src/backend/common/custom-variable-manager.js +++ b/src/backend/common/custom-variable-manager.js @@ -145,12 +145,12 @@ exports.getCustomVariable = (name, propertyPath, defaultData = null) => { return defaultData; } - if (propertyPath == null || propertyPath === "null") { + if (propertyPath == null || propertyPath === "null" || propertyPath === '') { return data; } try { - const pathNodes = propertyPath.split("."); + const pathNodes = `${propertyPath}`.split("."); for (let i = 0; i < pathNodes.length; i++) { if (data == null) { break; diff --git a/src/backend/common/handlers/js-sandbox/sandbox-eval.ts b/src/backend/common/handlers/js-sandbox/sandbox-eval.ts new file mode 100644 index 000000000..fa9ca1f24 --- /dev/null +++ b/src/backend/common/handlers/js-sandbox/sandbox-eval.ts @@ -0,0 +1,230 @@ +import { join } from 'node:path'; +import { BrowserWindow, MessageChannelMain, session } from 'electron'; + +import type { Trigger } from '../../../../types/triggers'; + +const logger = require('../../../logwrapper'); +const preloadPath = join(__dirname, 'sandbox-preload.js'); +const htmlPath = join(__dirname, './sandbox.html'); + +const charList = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const handlers = new Map unknown>(); + +interface Sandbox { + finished: boolean, + tunnel: Electron.MessagePortMain, + timeout?: ReturnType, + window?: Electron.BrowserWindow, + + resolve?: (...args: unknown[]) => void, + reject?: (...args: unknown[]) => void +} + +export const evalSandboxedJs = async (code: string, args: unknown[], metadata: Trigger["metadata"]) => { + if (code instanceof String) { + code = `${code}`; + } + if (typeof code !== 'string' || code === '') { + return; + } + + return new Promise((resolve, reject) => { + let { port1: portToBackend, port2: portToSandbox } = new MessageChannelMain(); + + const sandbox : Sandbox = { + finished: false, + tunnel: portToSandbox + }; + + // Frees all resources related to the sandbox + const cleanup = () => { + sandbox.finished = true; + + if (sandbox.timeout) { + clearTimeout(sandbox.timeout); + sandbox.timeout = null; + } + + try { + sandbox.window.webContents.removeAllListeners(); + } catch (err) {} + try { + sandbox.window.removeAllListeners(); + sandbox.window.destroy(); + } catch (err) {} + sandbox.window = null; + + try { + sandbox.tunnel.close(); + sandbox.tunnel.removeAllListeners(); + } catch (err) {} + sandbox.tunnel = null; + portToSandbox = null; + + try { + portToBackend.close(); + portToBackend.removeAllListeners(); + } catch (err) {} + portToBackend = null; + }; + + // Called when the sandbox successfully returns a result + sandbox.resolve = (result: unknown) => { + if (!sandbox.finished) { + cleanup(); + resolve(result); + } + }; + + // Called when the sandbox ends with an error + sandbox.reject = (reason?: string | Error) => { + if (!sandbox.finished) { + cleanup(); + reason = typeof reason === 'string' ? new Error(reason) : reason == null ? new Error('unknown error') : reason; + reject(reason); + } + }; + + // Listen for messages from sandbox + portToSandbox.on('message', async (event) => { + if (sandbox.finished) { + cleanup(); + return; + } + const { id, action, method, parameters, status, result } = event.data; + + // Sandbox returned a result for the evaluation + if ((id === 0 || id === '0') && action === 'result') { + if (status === 'ok') { + sandbox.resolve(result); + } else if (status === 'error') { + sandbox.reject(result); + } + + // Sandbox is leveraging Firebot.* apis + } else if (action === 'method') { + + const base = { id, action: "result" }; + if (method === 'metadata') { + sandbox.tunnel.postMessage({ ...base, status: "ok", result: metadata || {} }); + + } else if (handlers.has(method)) { + try { + const result = await handlers.get(method)(...parameters); + if (sandbox.finished) { + return; + } + sandbox.tunnel.postMessage({ ...base, status: "ok", result }); + } catch (err) { + sandbox.tunnel.postMessage({ ...base, status: "error", result: err.message }); + } + } else { + sandbox.tunnel.postMessage({ ...base, status: "error", result: "unknown method"}); + } + } + }); + + // Start listening for messages from sandbox + portToSandbox.start(); + + // Generate a unique session id for the sandbox; this equates to each sandbox getting its own session data(LocalStorage, cache, etc) + let sandboxSessionId = '', index = 10; + while (index) { + sandboxSessionId += charList[Math.floor(62 * Math.random())]; + index -= 1; + } + sandboxSessionId = `firebot-sandbox-${Date.now()}-${sandboxSessionId}`; + + // Create a new, hidden, browser window + sandbox.window = new BrowserWindow({ + show: false, + title: 'Firebot - $JS Eval Sandbox', + webPreferences: { + preload: preloadPath, + + // Sandbox the context + sandbox: true, + nodeIntegration: false, + contextIsolation: true, + + // Use a unique session for each sandbox. + // Creates a unique local/SessionStorage instance, cache, etc for the sandbox + session: session.fromPartition(sandboxSessionId, { cache: false }), + + // Loosen web-request restrictions + webSecurity: false, + + // Tighten restrictions + // No autoplay without user interaction and since the window is + // never shown to the user there will never be a user gesture thus + // no playing of audio or video. + autoplayPolicy: 'user-gesture-required', + + // Disable abusable and/or irrelevent features + disableDialogs: true, + webgl: false, + images: false, + enableWebSQL: false + } + }); + + // Prevent sandbox js from opening windows + sandbox.window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + + // Prevent sandbox js from navigating away from sandbox page + sandbox.window.webContents.on('will-navigate', event => event.preventDefault()); + + // Prevent sandbox from altering page title + sandbox.window.on('page-title-updated', event => event.preventDefault()); + + // Cleanup the sandbox if it becomes unresponsive + sandbox.window.on('unresponsive', () => sandbox.reject('sandbox unresponsive')); + + // Cleanup the sandbox if the window closes + sandbox.window.on('closed', () => sandbox.reject('sandbox closed')); + + // Reroute console.* from the sandbox to the logger + sandbox.window.webContents.on('console-message', (event, level, message) => { + if (level === 2 && /^%cElectron /i.test(message)) { + return; + } + switch (level) { + case 1: + logger.info(`($evalJS Sandbox) ${message}`); + break; + + case 2: + logger.warn(`($evalJS Sandbox) ${message}`); + break; + + case 3: + logger.error(`($evalJS Sandbox) ${message}`); + break; + + default: + logger.verbose(`($evalJS Sandbox) ${message}`); + } + }); + + // Wait for the contents of the sandbox window to be ready + sandbox.window.on('ready-to-show', () => { + + // Give evaluation 15s to resolve + sandbox.timeout = setTimeout(() => sandbox.reject('eval timed out'), 15000); + + // send the message port the sandbox should use to the preload script + sandbox.window.webContents.postMessage('firebot-port', null, [portToBackend]); + + // tell sandbox the code to evaluate + sandbox.tunnel.postMessage({ + id: 0, + action: 'method', + method: 'evaluate', + parameters: [code, ...args] + }); + }); + + // load the sandbox html + sandbox.window.loadFile(htmlPath); + }); +}; \ No newline at end of file diff --git a/src/backend/variables/builtin/utility/js/sandbox-preload.js b/src/backend/common/handlers/js-sandbox/sandbox-preload.js similarity index 100% rename from src/backend/variables/builtin/utility/js/sandbox-preload.js rename to src/backend/common/handlers/js-sandbox/sandbox-preload.js diff --git a/src/backend/variables/builtin/utility/js/sandbox.html b/src/backend/common/handlers/js-sandbox/sandbox.html similarity index 95% rename from src/backend/variables/builtin/utility/js/sandbox.html rename to src/backend/common/handlers/js-sandbox/sandbox.html index cdf8cdd24..a6166893b 100644 --- a/src/backend/variables/builtin/utility/js/sandbox.html +++ b/src/backend/common/handlers/js-sandbox/sandbox.html @@ -44,7 +44,6 @@ if ((typeof id !== 'string' && typeof id !== 'number') || typeof action !== 'string') { return; } - console.log(`Message from Firebot: ${JSON.stringify(event.data)}`); // Result of calling a Firebot.* api method if ( @@ -114,7 +113,8 @@ const metadata = await Firebot.metadata(); // Wrap the code to evaluate - const evaluate = new Function('Firebot', 'metadata', 'parameters', `return (async ()=>{"use strict";${parameters[0]}})()`); + const AsyncFunction = (async function () {}).constructor; + const evaluate = new AsyncFunction('Firebot', 'metadata', 'parameters', parameters[0]); // Attempt to call the evaluator function const result = await evaluate(Firebot, metadata, parameters.slice(1)); diff --git a/src/backend/effects/builtin-effect-loader.js b/src/backend/effects/builtin-effect-loader.js index 987b4823b..aed5e4f6b 100644 --- a/src/backend/effects/builtin-effect-loader.js +++ b/src/backend/effects/builtin-effect-loader.js @@ -24,6 +24,7 @@ exports.loadEffects = () => { 'delete-chat-message', 'dice', 'effect-group', + 'eval-js', 'file-writer', 'html', 'http-request', diff --git a/src/backend/effects/builtin/eval-js.ts b/src/backend/effects/builtin/eval-js.ts new file mode 100644 index 000000000..7655be17f --- /dev/null +++ b/src/backend/effects/builtin/eval-js.ts @@ -0,0 +1,106 @@ +"use strict"; + +import { EffectType } from "../../../types/effects"; +import { EffectCategory } from "../../../shared/effect-constants"; +import logger from "../../logwrapper"; +import { evalSandboxedJs } from "../../common/handlers/js-sandbox/sandbox-eval"; + +const model: EffectType<{ + code: string; + parameters: string[]; +}> = { + definition: { + id: "firebot:eval-js", + name: "Evaluate JavaScript", + description: "Evaluate a JavaScript expression", + icon: "fab fa-js", + categories: [EffectCategory.ADVANCED], + dependencies: [], + outputs: [ + { + label: "Code Result", + defaultName: "jsResult", + description: "The result of the JavaScript code. Note you must use 'return' for a result to be captured." + } + ] + }, + optionsTemplate: ` + +
+
+
+ + + + + + +
+ Things to note: +
    +
  • JavaScript is evaluated in a sandboxed browser environment
  • +
  • You must use return to have a result captured as the output
  • +
  • Parameters can be accessed via parameters[n]
  • +
  • Trigger metadata can be accessed via metadata.*
  • +
+
+
+ `, + optionsController: ($scope) => { + $scope.editorSettings = { + mode: 'javascript', + theme: 'blackboard', + lineNumbers: true, + autoRefresh: true, + showGutter: true + }; + + $scope.parameterSettings = { + sortable: true, + showIndex: true, + indexZeroBased: true, + indexTemplate: "parameters[{index}]", + trigger: $scope.trigger, + triggerMeta: $scope.triggerMeta + }; + + $scope.codemirrorLoaded = function(_editor) { + // Editor part + _editor.refresh(); + const cmResize = require("cm-resize"); + cmResize(_editor, { + minHeight: 200, + resizableWidth: false, + resizableHeight: true + }); + }; + }, + optionsValidator: (effect) => { + const errors = []; + if (effect.code == null) { + errors.push("Please enter some JavaScript code."); + } + return errors; + }, + onTriggerEvent: async ({ effect, trigger }) => { + try { + const result = await evalSandboxedJs(effect.code, effect.parameters ?? [], trigger.metadata); + return { + success: true, + outputs: { + jsResult: result + } + }; + } catch (err) { + logger.error("Error evaluating JavaScript", err); + return false; + } + } +}; + +module.exports = model; diff --git a/src/backend/twitch-api/eventsub/eventsub-client.ts b/src/backend/twitch-api/eventsub/eventsub-client.ts index 202516bd7..b3cbebbe3 100644 --- a/src/backend/twitch-api/eventsub/eventsub-client.ts +++ b/src/backend/twitch-api/eventsub/eventsub-client.ts @@ -11,410 +11,451 @@ import twitchStreamInfoPoll from "../stream-info-manager"; class TwitchEventSubClient { private _eventSubListener: EventSubWsListener; private _subscriptions: Array = []; + private _subscriptionCheckTimer: NodeJS.Timeout; - async createClient(): Promise { - const streamer = accountAccess.getAccounts().streamer; + private startSubTimer(): void { + logger.debug("Starting EventSub subscription check timer"); - await this.disconnectEventSub(); + this._subscriptionCheckTimer = setInterval(async () => { + logger.debug("Running EventSub subscription check"); - logger.info("Connecting to Twitch EventSub..."); - - try { - this._eventSubListener = new EventSubWsListener({ - apiClient: TwitchApi.streamerClient - }); + const activeSubs = (await TwitchApi.streamerClient.eventSub.getSubscriptions()).data; - this._eventSubListener.start(); + for (const sub of this._subscriptions) { + const subInfo = activeSubs.find(s => s.id === sub._twitchId); - // Stream online - const onlineSubscription = this._eventSubListener.onStreamOnline(streamer.userId, (event) => { - twitchEventsHandler.stream.triggerStreamOnline( - event.broadcasterId, - event.broadcasterName, - event.broadcasterDisplayName - ); - }); - this._subscriptions.push(onlineSubscription); - - // Stream offline - const offlineSubscription = this._eventSubListener.onStreamOffline(streamer.userId, (event) => { - twitchEventsHandler.stream.triggerStreamOffline( - event.broadcasterId, - event.broadcasterName, - event.broadcasterDisplayName - ); - }); - this._subscriptions.push(offlineSubscription); - - // Follows - const followSubscription = this._eventSubListener.onChannelFollow(streamer.userId, streamer.userId, (event) => { - twitchEventsHandler.follow.triggerFollow( - event.userId, - event.userName, - event.userDisplayName - ); - }); - this._subscriptions.push(followSubscription); - - // Cheers - const bitsSubscription = this._eventSubListener.onChannelCheer(streamer.userId, async (event) => { - const totalBits = (await TwitchApi.bits.getChannelBitsLeaderboard(1, "all", new Date(), event.userId))[0]?.amount ?? 0; - - twitchEventsHandler.cheer.triggerCheer( - event.userDisplayName ?? "An Anonymous Cheerer", - event.userId, - event.isAnonymous, - event.bits, - totalBits, - event.message ?? "" - ); - }); - this._subscriptions.push(bitsSubscription); - - // Channel custom reward - const customRewardRedemptionSubscription = this._eventSubListener.onChannelRedemptionAdd(streamer.userId, async (event) => { - const reward = await TwitchApi.channelRewards.getCustomChannelReward(event.rewardId); - let imageUrl = ""; - - if (reward && reward.defaultImage) { - const images = reward.defaultImage; - if (images.url4x) { - imageUrl = images.url4x; - } else if (images.url2x) { - imageUrl = images.url2x; - } else if (images.url1x) { - imageUrl = images.url1x; - } + if (subInfo?.status !== "enabled") { + logger.warn(`EventSub subscription for ${sub.id} is no longer valid. Attempting to resubscribe...`); + sub.start(subInfo); } + } - twitchEventsHandler.rewardRedemption.handleRewardRedemption( - event.id, - event.status, - !reward.shouldRedemptionsSkipRequestQueue, - event.input, - event.userId, - event.userName, - event.userDisplayName, - event.rewardId, - event.rewardTitle, - event.rewardPrompt, - event.rewardCost, - imageUrl - ); - }); - this._subscriptions.push(customRewardRedemptionSubscription); - - // Raid - const raidSubscription = this._eventSubListener.onChannelRaidTo(streamer.userId, (event) => { - twitchEventsHandler.raid.triggerRaid( - event.raidingBroadcasterName, - event.raidingBroadcasterId, - event.raidingBroadcasterDisplayName, - event.viewers - ); - }); - this._subscriptions.push(raidSubscription); - - // Shoutout sent to another channel - const shoutoutSentSubscription = this._eventSubListener.onChannelShoutoutCreate(streamer.userId, streamer.userId, (event) => { - twitchEventsHandler.shoutout.triggerShoutoutSent( - event.shoutedOutBroadcasterDisplayName, - event.moderatorDisplayName, - event.viewerCount - ); - }); - this._subscriptions.push(shoutoutSentSubscription); - - // Shoutout received from another channel - const shoutoutReceivedSubscription = this._eventSubListener.onChannelShoutoutReceive(streamer.userId, streamer.userId, (event) => { - twitchEventsHandler.shoutout.triggerShoutoutReceived( - event.shoutingOutBroadcasterDisplayName, - event.viewerCount - ); - }); - this._subscriptions.push(shoutoutReceivedSubscription); - - // Hype Train start - const hypeTrainBeginSubscription = this._eventSubListener.onChannelHypeTrainBegin(streamer.userId, (event) => { - twitchEventsHandler.hypeTrain.triggerHypeTrainStart( - event.total, - event.progress, - event.goal, - event.level, - event.startDate, - event.expiryDate, - event.lastContribution, - event.topContributors - ); - }); - this._subscriptions.push(hypeTrainBeginSubscription); - - // Hype Train progress - const hypeTrainProgressSubscription = this._eventSubListener.onChannelHypeTrainProgress(streamer.userId, (event) => { - twitchEventsHandler.hypeTrain.triggerHypeTrainProgress( - event.total, - event.progress, - event.goal, - event.level, - event.startDate, - event.expiryDate, - event.lastContribution, - event.topContributors - ); - }); - this._subscriptions.push(hypeTrainProgressSubscription); + logger.debug("EventSub subscription check complete"); + }, 5 * 60 * 1000); // Every 5 minutes + } - // Hype Train end - const hypeTrainEndSubscription = this._eventSubListener.onChannelHypeTrainEnd(streamer.userId, (event) => { - twitchEventsHandler.hypeTrain.triggerHypeTrainEnd( - event.total, - event.level, - event.startDate, - event.endDate, - event.cooldownEndDate, - event.topContributors - ); - }); - this._subscriptions.push(hypeTrainEndSubscription); + private stopSubTimer(): void { + logger.debug("Stopping EventSub subscription check timer"); - // Channel goal begin - const channelGoalBeginSubscription = this._eventSubListener.onChannelGoalBegin(streamer.userId, (event) => { - twitchEventsHandler.goal.triggerChannelGoalBegin( - event.description, - event.type, - event.startDate, - event.currentAmount, - event.targetAmount - ); - }); - this._subscriptions.push(channelGoalBeginSubscription); + clearInterval(this._subscriptionCheckTimer); + this._subscriptionCheckTimer = undefined; + } - // Channel goal progress - const channelGoalProgressSubscription = this._eventSubListener.onChannelGoalProgress(streamer.userId, (event) => { - twitchEventsHandler.goal.triggerChannelGoalProgress( - event.description, - event.type, - event.startDate, - event.currentAmount, - event.targetAmount - ); - }); - this._subscriptions.push(channelGoalProgressSubscription); + private createSubscriptions(): void { + const streamer = accountAccess.getAccounts().streamer; - // Channel goal end - const channelGoalEndSubscription = this._eventSubListener.onChannelGoalEnd(streamer.userId, (event) => { - twitchEventsHandler.goal.triggerChannelGoalEnd( - event.description, - event.type, - event.startDate, - event.endDate, - event.currentAmount, - event.targetAmount, - event.isAchieved - ); - }); - this._subscriptions.push(channelGoalEndSubscription); + // Stream online + const onlineSubscription = this._eventSubListener.onStreamOnline(streamer.userId, (event) => { + twitchEventsHandler.stream.triggerStreamOnline( + event.broadcasterId, + event.broadcasterName, + event.broadcasterDisplayName + ); + }); + this._subscriptions.push(onlineSubscription); + + // Stream offline + const offlineSubscription = this._eventSubListener.onStreamOffline(streamer.userId, (event) => { + twitchEventsHandler.stream.triggerStreamOffline( + event.broadcasterId, + event.broadcasterName, + event.broadcasterDisplayName + ); + }); + this._subscriptions.push(offlineSubscription); + + // Follows + const followSubscription = this._eventSubListener.onChannelFollow(streamer.userId, streamer.userId, (event) => { + twitchEventsHandler.follow.triggerFollow( + event.userId, + event.userName, + event.userDisplayName + ); + }); + this._subscriptions.push(followSubscription); + + // Cheers + const bitsSubscription = this._eventSubListener.onChannelCheer(streamer.userId, async (event) => { + const totalBits = (await TwitchApi.bits.getChannelBitsLeaderboard(1, "all", new Date(), event.userId))[0]?.amount ?? 0; + + twitchEventsHandler.cheer.triggerCheer( + event.userDisplayName ?? "An Anonymous Cheerer", + event.userId, + event.isAnonymous, + event.bits, + totalBits, + event.message ?? "" + ); + }); + this._subscriptions.push(bitsSubscription); + + // Channel custom reward + const customRewardRedemptionSubscription = this._eventSubListener.onChannelRedemptionAdd(streamer.userId, async (event) => { + const reward = await TwitchApi.channelRewards.getCustomChannelReward(event.rewardId); + let imageUrl = ""; + + if (reward && reward.defaultImage) { + const images = reward.defaultImage; + if (images.url4x) { + imageUrl = images.url4x; + } else if (images.url2x) { + imageUrl = images.url2x; + } else if (images.url1x) { + imageUrl = images.url1x; + } + } - // Channel poll begin - const pollBeginSubscription = this._eventSubListener.onChannelPollBegin(streamer.userId, (event) => { - twitchEventsHandler.poll.triggerChannelPollBegin( + twitchEventsHandler.rewardRedemption.handleRewardRedemption( + event.id, + event.status, + !reward.shouldRedemptionsSkipRequestQueue, + event.input, + event.userId, + event.userName, + event.userDisplayName, + event.rewardId, + event.rewardTitle, + event.rewardPrompt, + event.rewardCost, + imageUrl + ); + }); + this._subscriptions.push(customRewardRedemptionSubscription); + + // Raid + const raidSubscription = this._eventSubListener.onChannelRaidTo(streamer.userId, (event) => { + twitchEventsHandler.raid.triggerRaid( + event.raidingBroadcasterName, + event.raidingBroadcasterId, + event.raidingBroadcasterDisplayName, + event.viewers + ); + }); + this._subscriptions.push(raidSubscription); + + // Shoutout sent to another channel + const shoutoutSentSubscription = this._eventSubListener.onChannelShoutoutCreate(streamer.userId, streamer.userId, (event) => { + twitchEventsHandler.shoutout.triggerShoutoutSent( + event.shoutedOutBroadcasterDisplayName, + event.moderatorDisplayName, + event.viewerCount + ); + }); + this._subscriptions.push(shoutoutSentSubscription); + + // Shoutout received from another channel + const shoutoutReceivedSubscription = this._eventSubListener.onChannelShoutoutReceive(streamer.userId, streamer.userId, (event) => { + twitchEventsHandler.shoutout.triggerShoutoutReceived( + event.shoutingOutBroadcasterDisplayName, + event.viewerCount + ); + }); + this._subscriptions.push(shoutoutReceivedSubscription); + + // Hype Train start + const hypeTrainBeginSubscription = this._eventSubListener.onChannelHypeTrainBegin(streamer.userId, (event) => { + twitchEventsHandler.hypeTrain.triggerHypeTrainStart( + event.total, + event.progress, + event.goal, + event.level, + event.startDate, + event.expiryDate, + event.lastContribution, + event.topContributors + ); + }); + this._subscriptions.push(hypeTrainBeginSubscription); + + // Hype Train progress + const hypeTrainProgressSubscription = this._eventSubListener.onChannelHypeTrainProgress(streamer.userId, (event) => { + twitchEventsHandler.hypeTrain.triggerHypeTrainProgress( + event.total, + event.progress, + event.goal, + event.level, + event.startDate, + event.expiryDate, + event.lastContribution, + event.topContributors + ); + }); + this._subscriptions.push(hypeTrainProgressSubscription); + + // Hype Train end + const hypeTrainEndSubscription = this._eventSubListener.onChannelHypeTrainEnd(streamer.userId, (event) => { + twitchEventsHandler.hypeTrain.triggerHypeTrainEnd( + event.total, + event.level, + event.startDate, + event.endDate, + event.cooldownEndDate, + event.topContributors + ); + }); + this._subscriptions.push(hypeTrainEndSubscription); + + // Channel goal begin + const channelGoalBeginSubscription = this._eventSubListener.onChannelGoalBegin(streamer.userId, (event) => { + twitchEventsHandler.goal.triggerChannelGoalBegin( + event.description, + event.type, + event.startDate, + event.currentAmount, + event.targetAmount + ); + }); + this._subscriptions.push(channelGoalBeginSubscription); + + // Channel goal progress + const channelGoalProgressSubscription = this._eventSubListener.onChannelGoalProgress(streamer.userId, (event) => { + twitchEventsHandler.goal.triggerChannelGoalProgress( + event.description, + event.type, + event.startDate, + event.currentAmount, + event.targetAmount + ); + }); + this._subscriptions.push(channelGoalProgressSubscription); + + // Channel goal end + const channelGoalEndSubscription = this._eventSubListener.onChannelGoalEnd(streamer.userId, (event) => { + twitchEventsHandler.goal.triggerChannelGoalEnd( + event.description, + event.type, + event.startDate, + event.endDate, + event.currentAmount, + event.targetAmount, + event.isAchieved + ); + }); + this._subscriptions.push(channelGoalEndSubscription); + + // Channel poll begin + const pollBeginSubscription = this._eventSubListener.onChannelPollBegin(streamer.userId, (event) => { + twitchEventsHandler.poll.triggerChannelPollBegin( + event.title, + event.choices, + event.startDate, + event.endDate, + event.isChannelPointsVotingEnabled, + event.channelPointsPerVote + ); + }); + this._subscriptions.push(pollBeginSubscription); + + // Channel poll progress + const pollProgressSubscription = this._eventSubListener.onChannelPollProgress(streamer.userId, (event) => { + twitchEventsHandler.poll.triggerChannelPollProgress( + event.title, + event.choices, + event.startDate, + event.endDate, + event.isChannelPointsVotingEnabled, + event.channelPointsPerVote + ); + }); + this._subscriptions.push(pollProgressSubscription); + + // Channel poll end + const pollEndSubscription = this._eventSubListener.onChannelPollEnd(streamer.userId, (event) => { + if (event.status !== "archived") { + twitchEventsHandler.poll.triggerChannelPollEnd( event.title, event.choices, event.startDate, event.endDate, event.isChannelPointsVotingEnabled, - event.channelPointsPerVote + event.channelPointsPerVote, + event.status ); - }); - this._subscriptions.push(pollBeginSubscription); - - // Channel poll progress - const pollProgressSubscription = this._eventSubListener.onChannelPollProgress(streamer.userId, (event) => { - twitchEventsHandler.poll.triggerChannelPollProgress( - event.title, - event.choices, - event.startDate, - event.endDate, - event.isChannelPointsVotingEnabled, - event.channelPointsPerVote + } + }); + this._subscriptions.push(pollEndSubscription); + + // Channel prediction begin + const predictionBeginSubscription = this._eventSubListener.onChannelPredictionBegin(streamer.userId, (event) => { + twitchEventsHandler.prediction.triggerChannelPredictionBegin( + event.title, + event.outcomes, + event.startDate, + event.lockDate + ); + }); + this._subscriptions.push(predictionBeginSubscription); + + // Channel prediction progress + const predictionProgressSubscription = this._eventSubListener.onChannelPredictionProgress(streamer.userId, (event) => { + twitchEventsHandler.prediction.triggerChannelPredictionProgress( + event.title, + event.outcomes, + event.startDate, + event.lockDate + ); + }); + this._subscriptions.push(predictionProgressSubscription); + + // Channel prediction lock + const predictionLockSubscription = this._eventSubListener.onChannelPredictionLock(streamer.userId, (event) => { + twitchEventsHandler.prediction.triggerChannelPredictionLock( + event.title, + event.outcomes, + event.startDate, + event.lockDate + ); + }); + this._subscriptions.push(predictionLockSubscription); + + // Channel prediction end + const predictionEndSubscription = this._eventSubListener.onChannelPredictionEnd(streamer.userId, (event) => { + twitchEventsHandler.prediction.triggerChannelPredictionEnd( + event.title, + event.outcomes, + event.winningOutcome, + event.startDate, + event.endDate, + event.status + ); + }); + this._subscriptions.push(predictionEndSubscription); + + // Ban + const banSubscription = this._eventSubListener.onChannelBan(streamer.userId, (event) => { + if (event.endDate) { + const timeoutDuration = (event.endDate.getTime() - event.startDate.getTime()) / 1000; + twitchEventsHandler.viewerTimeout.triggerTimeout( + event.userDisplayName, + timeoutDuration, + event.moderatorName, + event.reason ); - }); - this._subscriptions.push(pollProgressSubscription); - - // Channel poll end - const pollEndSubscription = this._eventSubListener.onChannelPollEnd(streamer.userId, (event) => { - if (event.status !== "archived") { - twitchEventsHandler.poll.triggerChannelPollEnd( - event.title, - event.choices, - event.startDate, - event.endDate, - event.isChannelPointsVotingEnabled, - event.channelPointsPerVote, - event.status - ); - } - }); - this._subscriptions.push(pollEndSubscription); - - // Channel prediction begin - const predictionBeginSubscription = this._eventSubListener.onChannelPredictionBegin(streamer.userId, (event) => { - twitchEventsHandler.prediction.triggerChannelPredictionBegin( - event.title, - event.outcomes, - event.startDate, - event.lockDate + } else { + twitchEventsHandler.viewerBanned.triggerBanned( + event.userDisplayName, + event.moderatorName, + event.reason ); - }); - this._subscriptions.push(predictionBeginSubscription); + } - // Channel prediction progress - const predictionProgressSubscription = this._eventSubListener.onChannelPredictionProgress(streamer.userId, (event) => { - twitchEventsHandler.prediction.triggerChannelPredictionProgress( - event.title, - event.outcomes, - event.startDate, - event.lockDate - ); + frontendCommunicator.send("twitch:chat:user:delete-messages", event.userName); + }); + this._subscriptions.push(banSubscription); + + // Unban + const unbanSubscription = this._eventSubListener.onChannelUnban(streamer.userId, (event) => { + twitchEventsHandler.viewerBanned.triggerUnbanned( + event.userName, + event.moderatorName + ); + }); + this._subscriptions.push(unbanSubscription); + + // Charity Campaign Start + const charityCampaignStartSubscription = this._eventSubListener.onChannelCharityCampaignStart(streamer.userId, (event) => { + twitchEventsHandler.charity.triggerCharityCampaignStart( + event.charityName, + event.charityDescription, + event.charityLogo, + event.charityWebsite, + event.currentAmount.localizedValue, + event.currentAmount.currency, + event.targetAmount.localizedValue, + event.targetAmount.currency + ); + }); + this._subscriptions.push(charityCampaignStartSubscription); + + // Charity Donation + const charityDonationSubscription = this._eventSubListener.onChannelCharityDonation(streamer.userId, (event) => { + twitchEventsHandler.charity.triggerCharityDonation( + event.donorDisplayName, + event.charityName, + event.charityDescription, + event.charityLogo, + event.charityWebsite, + event.amount.localizedValue, + event.amount.currency + ); + }); + this._subscriptions.push(charityDonationSubscription); + + // Charity Campaign Progress + const charityCampaignProgressSubscription = this._eventSubListener.onChannelCharityCampaignProgress(streamer.userId, (event) => { + twitchEventsHandler.charity.triggerCharityCampaignProgress( + event.charityName, + event.charityDescription, + event.charityLogo, + event.charityWebsite, + event.currentAmount.localizedValue, + event.currentAmount.currency, + event.targetAmount.localizedValue, + event.targetAmount.currency + ); + }); + this._subscriptions.push(charityCampaignProgressSubscription); + + // Charity Campaign End + const charityCampaignEndSubscription = this._eventSubListener.onChannelCharityCampaignStop(streamer.userId, (event) => { + twitchEventsHandler.charity.triggerCharityCampaignEnd( + event.charityName, + event.charityDescription, + event.charityLogo, + event.charityWebsite, + event.currentAmount.localizedValue, + event.currentAmount.currency, + event.targetAmount.localizedValue, + event.targetAmount.currency + ); + }); + this._subscriptions.push(charityCampaignEndSubscription); + + const channelUpdateSubscription = this._eventSubListener.onChannelUpdate(streamer.userId, (event) => { + twitchStreamInfoPoll.updateStreamInfo({ + categoryId: event.categoryId, + categoryName: event.categoryName, + title: event.streamTitle, + language: event.streamLanguage }); - this._subscriptions.push(predictionProgressSubscription); + }); + this._subscriptions.push(channelUpdateSubscription); + } - // Channel prediction lock - const predictionLockSubscription = this._eventSubListener.onChannelPredictionLock(streamer.userId, (event) => { - twitchEventsHandler.prediction.triggerChannelPredictionLock( - event.title, - event.outcomes, - event.startDate, - event.lockDate - ); - }); - this._subscriptions.push(predictionLockSubscription); + async createClient(): Promise { + await this.disconnectEventSub(); - // Channel prediction end - const predictionEndSubscription = this._eventSubListener.onChannelPredictionEnd(streamer.userId, (event) => { - twitchEventsHandler.prediction.triggerChannelPredictionEnd( - event.title, - event.outcomes, - event.winningOutcome, - event.startDate, - event.endDate, - event.status - ); - }); - this._subscriptions.push(predictionEndSubscription); - - // Ban - const banSubscription = this._eventSubListener.onChannelBan(streamer.userId, (event) => { - if (event.endDate) { - const timeoutDuration = (event.endDate.getTime() - event.startDate.getTime()) / 1000; - twitchEventsHandler.viewerTimeout.triggerTimeout( - event.userDisplayName, - timeoutDuration, - event.moderatorName, - event.reason - ); - } else { - twitchEventsHandler.viewerBanned.triggerBanned( - event.userDisplayName, - event.moderatorName, - event.reason - ); - } + logger.info("Connecting to Twitch EventSub..."); - frontendCommunicator.send("twitch:chat:user:delete-messages", event.userName); + try { + this._eventSubListener = new EventSubWsListener({ + apiClient: TwitchApi.streamerClient }); - this._subscriptions.push(banSubscription); - // Unban - const unbanSubscription = this._eventSubListener.onChannelUnban(streamer.userId, (event) => { - twitchEventsHandler.viewerBanned.triggerUnbanned( - event.userName, - event.moderatorName - ); - }); - this._subscriptions.push(unbanSubscription); - - // Charity Campaign Start - const charityCampaignStartSubscription = this._eventSubListener.onChannelCharityCampaignStart(streamer.userId, (event) => { - twitchEventsHandler.charity.triggerCharityCampaignStart( - event.charityName, - event.charityDescription, - event.charityLogo, - event.charityWebsite, - event.currentAmount.localizedValue, - event.currentAmount.currency, - event.targetAmount.localizedValue, - event.targetAmount.currency - ); - }); - this._subscriptions.push(charityCampaignStartSubscription); - - // Charity Donation - const charityDonationSubscription = this._eventSubListener.onChannelCharityDonation(streamer.userId, (event) => { - twitchEventsHandler.charity.triggerCharityDonation( - event.donorDisplayName, - event.charityName, - event.charityDescription, - event.charityLogo, - event.charityWebsite, - event.amount.localizedValue, - event.amount.currency - ); - }); - this._subscriptions.push(charityDonationSubscription); - - // Charity Campaign Progress - const charityCampaignProgressSubscription = this._eventSubListener.onChannelCharityCampaignProgress(streamer.userId, (event) => { - twitchEventsHandler.charity.triggerCharityCampaignProgress( - event.charityName, - event.charityDescription, - event.charityLogo, - event.charityWebsite, - event.currentAmount.localizedValue, - event.currentAmount.currency, - event.targetAmount.localizedValue, - event.targetAmount.currency - ); - }); - this._subscriptions.push(charityCampaignProgressSubscription); - - // Charity Campaign End - const charityCampaignEndSubscription = this._eventSubListener.onChannelCharityCampaignStop(streamer.userId, (event) => { - twitchEventsHandler.charity.triggerCharityCampaignEnd( - event.charityName, - event.charityDescription, - event.charityLogo, - event.charityWebsite, - event.currentAmount.localizedValue, - event.currentAmount.currency, - event.targetAmount.localizedValue, - event.targetAmount.currency - ); - }); - this._subscriptions.push(charityCampaignEndSubscription); - - const channelUpdateSubscription = this._eventSubListener.onChannelUpdate(streamer.userId, (event) => { - twitchStreamInfoPoll.updateStreamInfo({ - categoryId: event.categoryId, - categoryName: event.categoryName, - title: event.streamTitle, - language: event.streamLanguage - }); - }); - this._subscriptions.push(channelUpdateSubscription); + this._eventSubListener.start(); + + this.createSubscriptions(); + this.startSubTimer(); + + logger.info("Connected to the Twitch EventSub!"); + + // Finally, clear out any subcriptions that are no longer active + await TwitchApi.streamerClient.eventSub.deleteBrokenSubscriptions(); } catch (error) { logger.error("Failed to connect to Twitch EventSub", error); return; } - - logger.info("Connected to the Twitch EventSub!"); } async removeSubscriptions(): Promise { + for (const sub of this._subscriptions) { + await TwitchApi.streamerClient.eventSub.deleteSubscription(sub.id); + } this._subscriptions = []; } async disconnectEventSub(): Promise { + this.stopSubTimer(); await this.removeSubscriptions(); try { if (this._eventSubListener) { diff --git a/src/backend/variables/builtin/text/regex-exec.ts b/src/backend/variables/builtin/text/regex-exec.ts index 61436a867..bbbc1683f 100644 --- a/src/backend/variables/builtin/text/regex-exec.ts +++ b/src/backend/variables/builtin/text/regex-exec.ts @@ -19,7 +19,7 @@ const model : ReplaceVariable = { trigger: Trigger, stringToEvaluate: unknown, expression: unknown, - flags: unknown + flags: unknown = "g" ) : Array => { try { const regex = RegExp(`${expression}`, `${flags}`); @@ -34,4 +34,3 @@ const model : ReplaceVariable = { }; export default model; - diff --git a/src/backend/variables/builtin/text/regex-matches.ts b/src/backend/variables/builtin/text/regex-matches.ts index 21da2776a..764c3f8cf 100644 --- a/src/backend/variables/builtin/text/regex-matches.ts +++ b/src/backend/variables/builtin/text/regex-matches.ts @@ -19,14 +19,10 @@ const model : ReplaceVariable = { trigger: Trigger, stringToEvaluate: unknown, expression: unknown, - flags: unknown + flags: unknown = "g" ) : string[] => { - if (flags == null) { - flags = 'g'; - } else { - if (!`${flags}`.includes('g')) { - flags = `${flags}g`; - } + if (!`${flags}`.includes('g')) { + flags = `${flags}g`; } try { diff --git a/src/backend/variables/builtin/text/regex-test.ts b/src/backend/variables/builtin/text/regex-test.ts index 21b257c8c..6212ecec5 100644 --- a/src/backend/variables/builtin/text/regex-test.ts +++ b/src/backend/variables/builtin/text/regex-test.ts @@ -19,7 +19,7 @@ const model : ReplaceVariable = { trigger: Trigger, stringToEvaluate: unknown, expression: unknown, - flags: unknown + flags: unknown = "g" ) : boolean => { try { const regex = RegExp(`${expression}`, `${flags}`); @@ -31,4 +31,3 @@ const model : ReplaceVariable = { }; export default model; - diff --git a/src/backend/variables/builtin/utility/eval-js.ts b/src/backend/variables/builtin/utility/eval-js.ts new file mode 100644 index 000000000..4df28674f --- /dev/null +++ b/src/backend/variables/builtin/utility/eval-js.ts @@ -0,0 +1,41 @@ +import { ReplaceVariable, Trigger } from "../../../../types/variables"; +import { OutputDataType, VariableCategory } from "../../../../shared/variable-constants"; +import logger from '../../../logwrapper'; +import { evalSandboxedJs } from '../../../common/handlers/js-sandbox/sandbox-eval'; + +const model : ReplaceVariable = { + definition: { + handle: "evalJs", + usage: "evalJs[`` code ``, ...parameters]", + description: 'Evaluates the given js in a sandboxed browser instance.

Parameters can be accessed via parameters[N] within the js.
Event metadata can be accessed via metadata.*

You must use return to return a result from the evaluation.', + examples: [ + { + usage: 'evalJs[``return parameters[0]``, test]', + description: 'Returns the first parameter passed to $evalJS: "test"' + }, + { + usage: 'evalJs[``return metadata.username``]', + description: 'Returns the username from the event\'s metadata' + }, + { + usage: 'evalJs[``return await Firebot.sum[1,2,3,4]``]', + description: 'Calls the sum firebot api and returns the result' + } + ], + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.ALL] + }, + evaluator: async (trigger: Trigger, code: string, ...args: unknown[]) => { + try { + return await evalSandboxedJs(code, args, trigger.metadata); + + } catch (err) { + err.javascript = code; + err.parameters = args; + logger.error(err); + return '[$evalJs Error]'; + } + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/variables/builtin/utility/index.ts b/src/backend/variables/builtin/utility/index.ts index 95b645fcf..af7bf5536 100644 --- a/src/backend/variables/builtin/utility/index.ts +++ b/src/backend/variables/builtin/utility/index.ts @@ -3,7 +3,7 @@ import apiReadRaw from './api-read-raw'; import audioDuration from './audio-duration'; import convertFromJSON from './convert-from-json'; import convertToJSON from './convert-to-json'; -import evalJS from './js/sandbox-eval'; +import evalJS from './eval-js'; import evalVars from './eval-vars'; import fileExists from './file-exists'; import fileLineCount from './file-line-count'; diff --git a/src/backend/variables/builtin/utility/js/sandbox-eval.ts b/src/backend/variables/builtin/utility/js/sandbox-eval.ts deleted file mode 100644 index 6ebd3e80f..000000000 --- a/src/backend/variables/builtin/utility/js/sandbox-eval.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { join } from 'node:path'; -import { BrowserWindow, MessageChannelMain, session } from 'electron'; - -import { ReplaceVariable, Trigger } from "../../../../../types/variables"; -import { OutputDataType, VariableCategory } from "../../../../../shared/variable-constants"; - -const logger = require('../../../../logwrapper.js'); -const preloadPath = join(__dirname, 'sandbox-preload.js'); -const htmlPath = join(__dirname, './sandbox.html'); - -const charList = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; -const handlers = new Map unknown>(); - - -interface Sandbox { - finished: boolean, - tunnel: Electron.MessagePortMain, - timeout?: ReturnType, - window?: Electron.BrowserWindow, - - resolve?: (...args: unknown[]) => void, - reject?: (...args: unknown[]) => void -} - -const model : ReplaceVariable = { - definition: { - handle: "evalJs", - usage: "evalJs[`` code ``, ...parameters]", - description: 'Evaluates the given js in a sandboxed browser instance.

Parameters can be accessed via parameters[N] within the js.
Event metadata can be accessed via metadata.*

You must use return to return a result from the evaluation.', - examples: [ - { - usage: 'evalJs[``return parameters[0]``, test]', - description: 'Returns the first parameter passed to $evalJS: "test"' - }, - { - usage: 'evalJs[``return metadata.username``]', - description: 'Returns the username from the event\'s metadata' - }, - { - usage: 'evalJs[``return await Firebot.sum[1,2,3,4]``]', - description: 'Calls the sum firebot api and returns the result' - } - ], - categories: [VariableCategory.ADVANCED], - possibleDataOutput: [OutputDataType.ALL] - }, - evaluator: (trigger: Trigger, code: string, ...args: unknown[]) => { - if (code instanceof String) { - code = `${code}`; - } - if (typeof code !== 'string' || code === '') { - return; - } - - return new Promise((resolve, reject) => { - let { port1: portToBackend, port2: portToSandbox } = new MessageChannelMain(); - - const sandbox : Sandbox = { - finished: false, - tunnel: portToSandbox - }; - - // Frees all resources related to the sandbox - const cleanup = () => { - sandbox.finished = true; - - if (sandbox.timeout) { - clearTimeout(sandbox.timeout); - sandbox.timeout = null; - } - - try { - sandbox.window.webContents.removeAllListeners(); - } catch (err) {} - try { - sandbox.window.removeAllListeners(); - sandbox.window.destroy(); - } catch (err) {} - sandbox.window = null; - - try { - sandbox.tunnel.close(); - sandbox.tunnel.removeAllListeners(); - } catch (err) {} - sandbox.tunnel = null; - portToSandbox = null; - - try { - portToBackend.close(); - portToBackend.removeAllListeners(); - } catch (err) {} - portToBackend = null; - }; - - // Called when the sandbox successfully returns a result - sandbox.resolve = (result: unknown) => { - if (!sandbox.finished) { - cleanup(); - resolve(result); - } - }; - - // Called when the sandbox ends with an error - sandbox.reject = (reason?: string | Error) => { - if (!sandbox.finished) { - cleanup(); - reason = typeof reason === 'string' ? new Error(reason) : reason == null ? new Error('unknown error') : reason; - reject(reason); - } - }; - - // Listen for messages from sandbox - portToSandbox.on('message', async (event) => { - if (sandbox.finished) { - cleanup(); - return; - } - const { id, action, method, parameters, status, result } = event.data; - - // Sandbox returned a result for the evaluation - if ((id === 0 || id === '0') && action === 'result') { - if (status === 'ok') { - sandbox.resolve(result); - } else if (status === 'error') { - sandbox.reject(result); - } - - // Sandbox is leveraging Firebot.* apis - } else if (action === 'method') { - - const base = { id, action: "result" }; - if (method === 'metadata') { - sandbox.tunnel.postMessage({ ...base, status: "ok", result: trigger.metadata || {} }); - - } else if (handlers.has(method)) { - try { - const result = await handlers.get(method)(...parameters); - if (sandbox.finished) { - return; - } - sandbox.tunnel.postMessage({ ...base, status: "ok", result }); - } catch (err) { - sandbox.tunnel.postMessage({ ...base, status: "error", result: err.message }); - } - } else { - sandbox.tunnel.postMessage({ ...base, status: "error", result: "unknown method"}); - } - } - }); - - // Start listening for messages from sandbox - portToSandbox.start(); - - // Generate a unique session id for the sandbox; this equates to each sandbox getting its own session data(LocalStorage, cache, etc) - let sandboxSessionId = '', index = 10; - while (index) { - sandboxSessionId += charList[Math.floor(62 * Math.random())]; - index -= 1; - } - sandboxSessionId = `firebot-sandbox-${Date.now()}-${sandboxSessionId}`; - - // Create a new, hidden, browser window - sandbox.window = new BrowserWindow({ - show: false, - title: 'Firebot - $JS Eval Sandbox', - webPreferences: { - preload: preloadPath, - - // Sandbox the context - sandbox: true, - nodeIntegration: false, - contextIsolation: true, - - // Unique session for each sandbox - session: session.fromPartition(sandboxSessionId, { cache: false }), - - // Loosen web restrictions - still under discussion - webSecurity: false, - - // Tighten web restrictions - // No autoplay without user interaction and since the window is - // never shown there will never be a user action thus: no autoplay. - // Also, since window is hidden, audio/video will never play. - autoplayPolicy: 'user-gesture-required', - - // Disable abusable and/or irrelevent features - disableDialogs: true, - webgl: false, - images: false, - enableWebSQL: false - } - }); - - // Prevent sandbox js from opening windows - sandbox.window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - - // Prevent sandbox js from navigating away from sandbox page - sandbox.window.webContents.on('will-navigate', event => event.preventDefault()); - - // Prevent sandbox from altering page title - sandbox.window.on('page-title-updated', event => event.preventDefault()); - - // Cleanup the sandbox if it becomes unresponsive - sandbox.window.on('unresponsive', () => sandbox.reject('sandbox unresponsive')); - - // Cleanup the sandbox if the window closes - sandbox.window.on('closed', () => sandbox.reject('sandbox closed')); - - sandbox.window.webContents.on('console-message', (event, level, message) => { - if (level === 2 && /^%cElectron /i.test(message)) { - return; - } - switch (level) { - case 1: - logger.info(`($evalJS Sandbox) ${message}`); - break; - - case 2: - logger.warn(`($evalJS Sandbox) ${message}`); - break; - - case 3: - logger.error(`($evalJS Sandbox) ${message}`); - break; - - default: - logger.verbose(`($evalJS Sandbox) ${message}`); - } - }); - - // Wait for the contents of the sandbox window to be ready - sandbox.window.on('ready-to-show', () => { - - // Give evaluation 30s to resolve - sandbox.timeout = setTimeout(() => sandbox.reject('eval timed out'), 15000); - - // send the message port the sandbox should use to the preload script - sandbox.window.webContents.postMessage('firebot-port', null, [portToBackend]); - - // tell sandbox the code to evaluate - sandbox.tunnel.postMessage({ - id: 0, - action: 'method', - method: 'evaluate', - parameters: [code, ...args] - }); - }); - - // load the sandbox html - sandbox.window.loadFile(htmlPath); - }); - } -}; - -export default model; \ No newline at end of file diff --git a/src/gui/app/directives/controls/editable-list.js b/src/gui/app/directives/controls/editable-list.js index 453c0b58d..0c7f8896c 100644 --- a/src/gui/app/directives/controls/editable-list.js +++ b/src/gui/app/directives/controls/editable-list.js @@ -15,10 +15,13 @@ const deepmerge = require("deepmerge");
- - - -
{{item}}
+
+ + + {{ $ctrl.settings.indexTemplate.replace("{index}", $ctrl.settings.indexZeroBased ? $index : $index + 1) }} +
{{item}}
+
@@ -51,6 +54,9 @@ const deepmerge = require("deepmerge"); const defaultSettings = { sortable: false, + showIndex: false, + indexZeroBased: false, + indexTemplate: "{index}.", addLabel: "Add", editLabel: "Edit", validationText: "Text cannot be empty", diff --git a/src/gui/scss/core/_bootstrap-overrides.scss b/src/gui/scss/core/_bootstrap-overrides.scss index f87f331fe..53526a972 100644 --- a/src/gui/scss/core/_bootstrap-overrides.scss +++ b/src/gui/scss/core/_bootstrap-overrides.scss @@ -654,4 +654,9 @@ a { .ui-select-container { border-radius: 8px; +} + +code { + color: #daf4fc; + background-color: #334557; } \ No newline at end of file