mirror of
https://github.com/opelly27/streetmerchant.git
synced 2026-05-20 05:17:35 +00:00
feat: retry logic for nvidia session token and adding to cart (#347)
This commit is contained in:
@@ -13,6 +13,8 @@ IN_STOCK_WAIT_TIME=""
|
||||
LOG_LEVEL=""
|
||||
LOW_BANDWIDTH=""
|
||||
MICROCENTER_LOCATION=""
|
||||
NVIDIA_ADD_TO_CART_ATTEMPTS=""
|
||||
NVIDIA_SESSION_TTL=""
|
||||
OPEN_BROWSER=""
|
||||
PAGE_TIMEOUT=""
|
||||
PHONE_NUMBER=""
|
||||
|
||||
@@ -80,6 +80,8 @@ Here is a list of variables that you can use to customize your newly copied `.en
|
||||
| `LOG_LEVEL` | [Logging levels](https://github.com/winstonjs/winston#logging-levels) | Debugging related, default: `info` |
|
||||
| `LOW_BANDWIDTH` | Blocks images/fonts to reduce traffic | Disables ad blocker, default: `false` |
|
||||
| `MICROCENTER_LOCATION` | Specific MicroCenter location to search | Default : `web` |
|
||||
| `NVIDIA_ADD_TO_CART_ATTEMPTS` | The maximum number of times the `nvidia-api` add to cart feature will be attempted before failing | Default: `10` |
|
||||
| `NVIDIA_SESSION_TTL` | The time in seconds to keep the cart active while using `nvidia-api` | Default: `60000` |
|
||||
| `OPEN_BROWSER` | Toggle for whether or not the browser should open when item is found | Default: `true` |
|
||||
| `PAGE_TIMEOUT` | Navigation Timeout in milliseconds | `0` for infinite, default: `30000` |
|
||||
| `PHONE_NUMBER` | 10 digit phone number | E.g.: `1234567890`, email configuration required |
|
||||
|
||||
@@ -113,6 +113,11 @@ const notifications = {
|
||||
}
|
||||
};
|
||||
|
||||
const nvidia = {
|
||||
addToCardAttempts: envOrNumber(process.env.NVIDIA_ADD_TO_CART_ATTEMPTS, 10),
|
||||
sessionTtl: envOrNumber(process.env.NVIDIA_SESSION_TTL, 60000)
|
||||
};
|
||||
|
||||
const page = {
|
||||
height: 1080,
|
||||
inStockWaitTime: envOrNumber(process.env.IN_STOCK_WAIT_TIME),
|
||||
@@ -135,6 +140,7 @@ export const Config = {
|
||||
browser,
|
||||
logLevel,
|
||||
notifications,
|
||||
nvidia,
|
||||
page,
|
||||
store
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import {NvidiaRegionInfo, regionInfos} from '../nvidia-api';
|
||||
import {usingPage, usingResponse} from '../../../util';
|
||||
import {Browser} from 'puppeteer';
|
||||
import {Config} from '../../../config';
|
||||
import {Logger} from '../../../logger';
|
||||
import open from 'open';
|
||||
|
||||
interface NvidiaSessionTokenJSON {
|
||||
session_token: string;
|
||||
}
|
||||
|
||||
interface NvidiaAddToCardJSON {
|
||||
location: string;
|
||||
}
|
||||
|
||||
export class NvidiaCart {
|
||||
protected readonly browser: Browser;
|
||||
protected isKeepAlive = false;
|
||||
protected sessionToken: string | null = null;
|
||||
|
||||
public constructor(browser: Browser) {
|
||||
this.browser = browser;
|
||||
}
|
||||
|
||||
public keepAlive() {
|
||||
if (this.isKeepAlive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = async () => {
|
||||
if (!this.isKeepAlive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshSessionToken();
|
||||
|
||||
setTimeout(callback, Config.nvidia.sessionTtl);
|
||||
};
|
||||
|
||||
this.isKeepAlive = true;
|
||||
|
||||
void callback();
|
||||
}
|
||||
|
||||
public get fallbackCartUrl(): string {
|
||||
return `https://www.nvidia.com/${this.regionInfo.siteLocale}/geforce/`;
|
||||
}
|
||||
|
||||
public get regionInfo(): NvidiaRegionInfo {
|
||||
const country = Config.store.country;
|
||||
const regionInfo = regionInfos.get(country);
|
||||
if (!regionInfo) {
|
||||
throw new Error(`Unknown country ${country}`);
|
||||
}
|
||||
|
||||
return regionInfo;
|
||||
}
|
||||
|
||||
public get sessionUrl(): string {
|
||||
return `https://store.nvidia.com/store/nvidia/SessionToken?format=json&locale=${this.regionInfo.drLocale}`;
|
||||
}
|
||||
|
||||
public async addToCard(productId: number, name: string): Promise<string> {
|
||||
let cartUrl: string | undefined;
|
||||
Logger.info(`🚀🚀🚀 [nvidia] ${name}, starting auto add to cart 🚀🚀🚀`);
|
||||
try {
|
||||
Logger.info(`🚀🚀🚀 [nvidia] ${name}, adding to cart 🚀🚀🚀`);
|
||||
let lastError: Error | string | undefined;
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
for (let i = 0; i < Config.nvidia.addToCardAttempts; i++) {
|
||||
try {
|
||||
cartUrl = await this.addToCartAndGetLocationRedirect(productId);
|
||||
|
||||
break;
|
||||
} catch (error) {
|
||||
Logger.error(`✖ [nvidia] ${name} could not automatically add to cart, attempt ${i + 1} of ${Config.nvidia.addToCardAttempts}`, error);
|
||||
Logger.debug(error);
|
||||
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
|
||||
if (!cartUrl) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
Logger.info(`🚀🚀🚀 [nvidia] ${name}, opening checkout page 🚀🚀🚀`);
|
||||
Logger.info(cartUrl);
|
||||
|
||||
await open(cartUrl);
|
||||
} catch (error) {
|
||||
Logger.error(`✖ [nvidia] ${name} could not automatically add to cart, opening page`);
|
||||
Logger.debug(error);
|
||||
|
||||
cartUrl = this.fallbackCartUrl;
|
||||
|
||||
await open(cartUrl);
|
||||
}
|
||||
|
||||
return cartUrl;
|
||||
}
|
||||
|
||||
public async getSessionToken(): Promise<string> {
|
||||
if (!this.sessionToken) {
|
||||
await this.refreshSessionToken();
|
||||
}
|
||||
|
||||
if (!this.sessionToken) {
|
||||
throw new Error('Failed to create the session_token');
|
||||
}
|
||||
|
||||
return this.sessionToken;
|
||||
}
|
||||
|
||||
public async refreshSessionToken(): Promise<void> {
|
||||
Logger.debug('ℹ [nvidia] refreshing session token');
|
||||
try {
|
||||
const result = await usingResponse(this.browser, this.sessionUrl, async response => {
|
||||
return response?.json() as NvidiaSessionTokenJSON | undefined;
|
||||
});
|
||||
if (typeof result !== 'object' || result === null || !('session_token' in result)) {
|
||||
throw new Error('malformed response');
|
||||
}
|
||||
|
||||
this.sessionToken = result.session_token;
|
||||
Logger.debug(`ℹ [nvidia] session_token=${result.session_token}`);
|
||||
} catch (error) {
|
||||
const message: string = typeof error === 'object' ? error.message : error;
|
||||
Logger.error(`✖ [nvidia] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async addToCartAndGetLocationRedirect(productId: number): Promise<string> {
|
||||
const url = 'https://api-prod.nvidia.com/direct-sales-shop/DR/add-to-cart';
|
||||
const sessionToken = await this.getSessionToken();
|
||||
|
||||
Logger.info(`ℹ [nvidia] session_token=${sessionToken}`);
|
||||
|
||||
const locationData = await usingPage(this.browser, async page => {
|
||||
page.removeAllListeners('request');
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
|
||||
page.on('request', interceptedRequest => {
|
||||
void interceptedRequest.continue({
|
||||
headers: {
|
||||
...interceptedRequest.headers(),
|
||||
'content-type': 'application/json',
|
||||
nvidia_shop_id: sessionToken
|
||||
},
|
||||
method: 'POST',
|
||||
postData: JSON.stringify({
|
||||
products: [
|
||||
{productId, quantity: 1}
|
||||
]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const response = await page.goto(url, {waitUntil: 'networkidle0'});
|
||||
|
||||
if (response === null) {
|
||||
throw new Error('NvidiaAddToCartUnavailable');
|
||||
}
|
||||
|
||||
return response.json() as Promise<NvidiaAddToCardJSON>;
|
||||
});
|
||||
|
||||
return locationData.location;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import {Browser, Page, Response} from 'puppeteer';
|
||||
import {NvidiaRegionInfo, regionInfos} from '../nvidia-api';
|
||||
import {Browser} from 'puppeteer';
|
||||
import {Config} from '../../../config';
|
||||
import {Link} from '../store';
|
||||
import {Logger} from '../../../logger';
|
||||
import open from 'open';
|
||||
import {NvidiaCart} from './nvidia-cart';
|
||||
import {timestampUrlParameter} from '../../timestamp-url-parameter';
|
||||
|
||||
function getRegionInfo(): NvidiaRegionInfo {
|
||||
@@ -25,94 +24,23 @@ function nvidiaStockUrl(id: number, drLocale: string, currency: string): string
|
||||
timestampUrlParameter().slice(1);
|
||||
}
|
||||
|
||||
interface NvidiaSessionTokenJSON {
|
||||
session_token: string;
|
||||
}
|
||||
let cart: NvidiaCart;
|
||||
|
||||
interface NvidiaAddToCardJSON {
|
||||
location: string;
|
||||
}
|
||||
|
||||
function nvidiaSessionUrl(drLocale: string): string {
|
||||
return `https://store.nvidia.com/store/nvidia/SessionToken?format=json&locale=${drLocale}` +
|
||||
timestampUrlParameter();
|
||||
}
|
||||
|
||||
async function addToCartAndGetLocationRedirect(page: Page, sessionToken: string, productId: number): Promise<string> {
|
||||
const url = 'https://api-prod.nvidia.com/direct-sales-shop/DR/add-to-cart';
|
||||
|
||||
page.removeAllListeners('request');
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
|
||||
page.on('request', interceptedRequest => {
|
||||
void interceptedRequest.continue({
|
||||
headers: {
|
||||
...interceptedRequest.headers(),
|
||||
'content-type': 'application/json',
|
||||
nvidia_shop_id: sessionToken
|
||||
},
|
||||
method: 'POST',
|
||||
postData: JSON.stringify({
|
||||
products: [
|
||||
{productId, quantity: 1}
|
||||
]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const response = await page.goto(url, {waitUntil: 'networkidle0'});
|
||||
if (response === null) {
|
||||
throw new Error('NvidiaAddToCartUnavailable');
|
||||
}
|
||||
|
||||
const locationData = await response.json() as NvidiaAddToCardJSON;
|
||||
|
||||
return locationData.location;
|
||||
}
|
||||
|
||||
function fallbackCartUrl(nvidiaLocale: string): string {
|
||||
return `https://www.nvidia.com/${nvidiaLocale}/shop/geforce?${timestampUrlParameter()}`;
|
||||
}
|
||||
|
||||
export function generateOpenCartAction(id: number, drLocale: string, cardName: string) {
|
||||
export function generateSetupAction() {
|
||||
return async (browser: Browser) => {
|
||||
const page = await browser.newPage();
|
||||
cart = new NvidiaCart(browser);
|
||||
|
||||
Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, starting auto add to cart 🚀🚀🚀`);
|
||||
|
||||
let response: Response | null;
|
||||
let cartUrl: string;
|
||||
try {
|
||||
Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, getting access token 🚀🚀🚀`);
|
||||
|
||||
response = await page.goto(nvidiaSessionUrl(drLocale), {waitUntil: 'networkidle0'});
|
||||
if (response === null) {
|
||||
throw new Error('NvidiaAccessTokenUnavailable');
|
||||
}
|
||||
|
||||
const data = await response.json() as NvidiaSessionTokenJSON;
|
||||
const sessionToken = data.session_token;
|
||||
|
||||
Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, adding to cart 🚀🚀🚀`);
|
||||
|
||||
cartUrl = await addToCartAndGetLocationRedirect(page, sessionToken, id);
|
||||
|
||||
Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, opening checkout page 🚀🚀🚀`);
|
||||
Logger.info(cartUrl);
|
||||
|
||||
await open(cartUrl);
|
||||
} catch (error) {
|
||||
Logger.debug(error);
|
||||
Logger.error(`✖ [nvidia] ${cardName} could not automatically add to cart, opening page`, error);
|
||||
|
||||
cartUrl = fallbackCartUrl(drLocale);
|
||||
await open(cartUrl);
|
||||
if (Config.browser.open) {
|
||||
cart.keepAlive();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
await page.close();
|
||||
export function generateOpenCartAction(id: number, cardName: string) {
|
||||
return async () => {
|
||||
const url = await cart.addToCard(id, cardName);
|
||||
|
||||
return cartUrl;
|
||||
return url;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,7 +53,7 @@ export function generateLinks(): Link[] {
|
||||
links.push({
|
||||
brand: 'test:brand',
|
||||
model: 'test:model',
|
||||
openCartAction: generateOpenCartAction(fe2060SuperId, drLocale, 'TEST CARD debug'),
|
||||
openCartAction: generateOpenCartAction(fe2060SuperId, 'TEST CARD debug'),
|
||||
series: 'test:series',
|
||||
url: nvidiaStockUrl(fe2060SuperId, drLocale, currency)
|
||||
});
|
||||
@@ -135,7 +63,7 @@ export function generateLinks(): Link[] {
|
||||
links.push({
|
||||
brand: 'nvidia',
|
||||
model: 'founders edition',
|
||||
openCartAction: generateOpenCartAction(fe3080Id, drLocale, 'nvidia founders edition 3080'),
|
||||
openCartAction: generateOpenCartAction(fe3080Id, 'nvidia founders edition 3080'),
|
||||
series: '3080',
|
||||
url: nvidiaStockUrl(fe3080Id, drLocale, currency)
|
||||
});
|
||||
@@ -145,7 +73,7 @@ export function generateLinks(): Link[] {
|
||||
links.push({
|
||||
brand: 'nvidia',
|
||||
model: 'founders edition',
|
||||
openCartAction: generateOpenCartAction(fe3090Id, drLocale, 'nvidia founders edition 3090'),
|
||||
openCartAction: generateOpenCartAction(fe3090Id, 'nvidia founders edition 3090'),
|
||||
series: '3090',
|
||||
url: nvidiaStockUrl(fe3090Id, drLocale, currency)
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {generateLinks, generateSetupAction} from './helpers/nvidia';
|
||||
import {Store} from './store';
|
||||
import {generateLinks} from './helpers/nvidia';
|
||||
|
||||
// Region/country set by config file, silently ignores null / missing values and defaults to usa
|
||||
|
||||
@@ -9,29 +9,30 @@ export interface NvidiaRegionInfo {
|
||||
fe3080Id: number | null;
|
||||
fe3090Id: number | null;
|
||||
fe2060SuperId: number | null;
|
||||
siteLocale: string;
|
||||
}
|
||||
|
||||
export const regionInfos = new Map<string, NvidiaRegionInfo>([
|
||||
['austria', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5440853700, fe3090Id: 5444941400}],
|
||||
['belgium', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600}],
|
||||
['canada', {currency: 'CAD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600}],
|
||||
['czechia', {currency: 'CZK', drLocale: 'en_gb', fe2060SuperId: 5394902800, fe3080Id: 5438793800, fe3090Id: 5438793600}],
|
||||
['denmark', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500}],
|
||||
['finland', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500}],
|
||||
['france', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394903200, fe3080Id: 5438795200, fe3090Id: 5438761500}],
|
||||
['germany', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5438792300, fe3090Id: 5438761400}],
|
||||
['great_britain', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700}],
|
||||
['ireland', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700}],
|
||||
['italy', {currency: 'EUR', drLocale: 'it_it', fe2060SuperId: 5394903400, fe3080Id: 5438796200, fe3090Id: 5438796100}],
|
||||
['luxembourg', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600}],
|
||||
['netherlands', {currency: 'EUR', drLocale: 'nl_nl', fe2060SuperId: 5394903500, fe3080Id: 5438796700, fe3090Id: 5438796600}],
|
||||
['norway', {currency: 'EUR', drLocale: 'nb_no', fe2060SuperId: 5394903600, fe3080Id: 5438797200, fe3090Id: 5438797100}],
|
||||
['poland', {currency: 'PLN', drLocale: 'pl_pl', fe2060SuperId: 5394903700, fe3080Id: 5438797700, fe3090Id: 5438797600}],
|
||||
['portugal', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: null, fe3080Id: 5438794300, fe3090Id: null}],
|
||||
['russia', {currency: 'RUB', drLocale: 'ru_ru', fe2060SuperId: null, fe3080Id: null, fe3090Id: null}],
|
||||
['spain', {currency: 'EUR', drLocale: 'es_es', fe2060SuperId: 5394903000, fe3080Id: 5438794800, fe3090Id: 5438794700}],
|
||||
['sweden', {currency: 'SEK', drLocale: 'sv_se', fe2060SuperId: 5394903900, fe3080Id: 5438798100, fe3090Id: 5438761600}],
|
||||
['usa', {currency: 'USD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600}]
|
||||
['austria', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5440853700, fe3090Id: 5444941400, siteLocale: 'de-at'}],
|
||||
['belgium', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600, siteLocale: 'fr-be'}],
|
||||
['canada', {currency: 'CAD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600, siteLocale: 'en-us'}],
|
||||
['czechia', {currency: 'CZK', drLocale: 'en_gb', fe2060SuperId: 5394902800, fe3080Id: 5438793800, fe3090Id: 5438793600, siteLocale: 'cs-cz'}],
|
||||
['denmark', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500, siteLocale: 'da-dk'}],
|
||||
['finland', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500, siteLocale: 'da-dk'}],
|
||||
['france', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394903200, fe3080Id: 5438795200, fe3090Id: 5438761500, siteLocale: 'fr-fr'}],
|
||||
['germany', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5438792300, fe3090Id: 5438761400, siteLocale: 'de-de'}],
|
||||
['great_britain', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700, siteLocale: 'en-gb'}],
|
||||
['ireland', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700, siteLocale: 'en-gb'}],
|
||||
['italy', {currency: 'EUR', drLocale: 'it_it', fe2060SuperId: 5394903400, fe3080Id: 5438796200, fe3090Id: 5438796100, siteLocale: 'it-it'}],
|
||||
['luxembourg', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600, siteLocale: 'fr-be'}],
|
||||
['netherlands', {currency: 'EUR', drLocale: 'nl_nl', fe2060SuperId: 5394903500, fe3080Id: 5438796700, fe3090Id: 5438796600, siteLocale: 'nl-nl'}],
|
||||
['norway', {currency: 'EUR', drLocale: 'nb_no', fe2060SuperId: 5394903600, fe3080Id: 5438797200, fe3090Id: 5438797100, siteLocale: 'nb-no'}],
|
||||
['poland', {currency: 'PLN', drLocale: 'pl_pl', fe2060SuperId: 5394903700, fe3080Id: 5438797700, fe3090Id: 5438797600, siteLocale: 'pl-pl'}],
|
||||
['portugal', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: null, fe3080Id: 5438794300, fe3090Id: null, siteLocale: 'en-gb'}],
|
||||
['russia', {currency: 'RUB', drLocale: 'ru_ru', fe2060SuperId: null, fe3080Id: null, fe3090Id: null, siteLocale: 'ru-ru'}],
|
||||
['spain', {currency: 'EUR', drLocale: 'es_es', fe2060SuperId: 5394903000, fe3080Id: 5438794800, fe3090Id: 5438794700, siteLocale: 'es-es'}],
|
||||
['sweden', {currency: 'SEK', drLocale: 'sv_se', fe2060SuperId: 5394903900, fe3080Id: 5438798100, fe3090Id: 5438761600, siteLocale: 'sv-se'}],
|
||||
['usa', {currency: 'USD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600, siteLocale: 'en-us'}]
|
||||
]);
|
||||
|
||||
export const NvidiaApi: Store = {
|
||||
@@ -42,5 +43,6 @@ export const NvidiaApi: Store = {
|
||||
}
|
||||
},
|
||||
links: generateLinks(),
|
||||
name: 'nvidia-api'
|
||||
name: 'nvidia-api',
|
||||
setupAction: generateSetupAction()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user