1
0

🚨 Fix most of the linting error and warnings

This commit is contained in:
2022-01-06 18:15:56 +01:00
parent 0a18826978
commit 931cc57b1b
18 changed files with 829 additions and 695 deletions

View File

@@ -1,22 +1,28 @@
import { hasVip, say } from "..";
import { chatClient } from "../.."; import { chatClient } from "../..";
import { say } from "..";
async function addVip( async function addVip(
channel: string, channel: string,
username: string, username: string,
message?: string message?: string
): Promise<boolean> { ): Promise<boolean> {
try { username = username.toLowerCase();
await chatClient.addVip(channel, username); if (!(await hasVip(channel, username))) {
} catch (e) { return false;
return false; }
}
if (message) { try {
await say(channel, message); await chatClient.addVip(channel, username);
} } catch (e) {
return false;
}
return true; if (message) {
await say(channel, message);
}
return true;
} }
export { addVip }; export { addVip };

View File

@@ -2,23 +2,54 @@ import { chatClient } from "../..";
type CacheType = Record<string, Array<string>>; type CacheType = Record<string, Array<string>>;
const cache: CacheType = {}; const cache: CacheType = {};
const cacheKeepTime = 2.5e3;
interface ChannelFetching {
channel: string;
promise: Promise<unknown>;
}
const channelsFetching: Array<ChannelFetching> = [];
async function fetchVips(channel: string): Promise<void> {
const alreadyChecking = channelsFetching.find((c) => c.channel === channel);
if (alreadyChecking) {
await alreadyChecking.promise;
} else {
const promise = new Promise<void>((res) => {
chatClient.getVips(channel).then((vips) => {
cache[channel] = vips.map((u) => u.toLowerCase());
res();
});
});
channelsFetching.push({ channel, promise });
// eslint-disable-next-line no-magic-numbers
const addedIdx = channelsFetching.length - 1;
await promise;
channelsFetching.splice(addedIdx);
setTimeout(() => {
delete cache[channel];
}, cacheKeepTime);
}
}
async function hasVip(channel: string, username: string): Promise<boolean> { async function hasVip(channel: string, username: string): Promise<boolean> {
if (!username) { username = username.toLowerCase();
return false;
}
if (!cache[channel]) { if (!cache[channel]) {
cache[channel] = await chatClient.getVips(channel); await fetchVips(channel);
}
setTimeout(() => { const vips = cache[channel];
delete cache[channel];
}, 2500);
}
const vips = cache[channel]; return vips.includes(username);
return vips.includes(username);
} }
export { hasVip }; export { hasVip };

View File

@@ -1,7 +1,18 @@
import { say, sayError, sayInfo, saySuccess, sayWarn } from "./say";
import { addVip } from "./addVip"; import { addVip } from "./addVip";
import { hasVip } from "./hasVip"; import { hasVip } from "./hasVip";
import { removeVip } from "./removeVip"; import { removeVip } from "./removeVip";
import { say } from "./say";
import { timeout } from "./timeout"; import { timeout } from "./timeout";
export { say, timeout, addVip, removeVip, hasVip }; export {
addVip,
hasVip,
removeVip,
say,
sayError,
sayInfo,
saySuccess,
sayWarn,
timeout,
};

View File

@@ -1,22 +1,29 @@
import { hasVip, say } from "..";
import { chatClient } from "../.."; import { chatClient } from "../..";
import { say } from "..";
async function removeVip( async function removeVip(
channel: string, channel: string,
username: string, username: string,
message?: string message?: string
): Promise<boolean> { ): Promise<boolean> {
try { username = username.toLowerCase();
await chatClient.removeVip(channel, username);
} catch (e) {
return false;
}
if (message) { if (await hasVip(channel, username)) {
await say(channel, message); return false;
} }
return true; try {
await chatClient.removeVip(channel, username);
} catch (e) {
return false;
}
if (message) {
await say(channel, message);
}
return true;
} }
export { removeVip }; export { removeVip };

View File

@@ -1,19 +1,36 @@
import { chatClient } from "../.."; import { chatClient } from "../..";
const maxMessageLength = 500;
async function say(channel: string, message: string): Promise<void> { async function say(channel: string, message: string): Promise<void> {
// message = `MrDestructoid ${message}`; const maxMessageLength = 500;
// message = `MrDestructoid ${message}`;
if (message.length > 500) { if (message.length > maxMessageLength) {
const suffix = "..."; const startIndex = 0;
message = `${message.substring( const suffix = "...";
0,
maxMessageLength - suffix.length
)}${suffix}`;
}
await chatClient.say(channel, message); message = `${message.substring(
startIndex,
maxMessageLength - suffix.length
)}${suffix}`;
}
await chatClient.say(channel, message);
} }
export { say }; async function sayError(channel: string, message: string): Promise<void> {
await say(channel, `[ERROR] ${message}`);
}
async function sayWarn(channel: string, message: string): Promise<void> {
await say(channel, `[WARN] ${message}`);
}
async function sayInfo(channel: string, message: string) {
await say(channel, `[INFO] ${message}`);
}
async function saySuccess(channel: string, message: string): Promise<void> {
await say(channel, `[SUCCESS] ${message}`);
}
export { say, sayError, sayWarn, sayInfo, saySuccess };

View File

@@ -1,21 +1,22 @@
import { chatClient } from "../.."; import { chatClient } from "../..";
// timeouts a user in a channel const defaultTime = 60;
async function timeout( async function timeout(
channel: string, channel: string,
username: string, username: string,
time?: number, time?: number,
reason?: string reason?: string
): Promise<void> { ): Promise<void> {
if (!time) { if (!time) {
time = 60; time = defaultTime;
} }
if (!reason) { if (!reason) {
reason = ""; reason = "";
} }
await chatClient.timeout(channel, username, time, reason); await chatClient.timeout(channel, username, time, reason);
} }
export { timeout }; export { timeout };

View File

@@ -1,42 +1,42 @@
import { LOG_PREFIX, say } from ".."; import { sayError, saySuccess } from "../clientActions";
import { LOG_PREFIX } from "..";
import { TwitchPrivateMessage } from "@twurple/chat/lib/commands/TwitchPrivateMessage"; import { TwitchPrivateMessage } from "@twurple/chat/lib/commands/TwitchPrivateMessage";
import { createReward as createChannelPointsReward } from "../../helpers/twitch"; import { createReward as createChannelPointsReward } from "../../helpers/twitch";
async function createReward( async function createReward(
channel: string, channel: string,
user: string, _user: string,
message: string, message: string,
msg: TwitchPrivateMessage msg: TwitchPrivateMessage
): Promise<void> { ): Promise<void> {
const args = message.split(" "); const args = message.split(" ");
const title = args.shift(); const title = args.shift();
const cost = Math.max(1, parseInt(args.shift() ?? "0"));
if (!title || !cost) { if (!title) {
await say( await sayError(channel, "Debes indicar el título de la recompensa");
channel, return;
"No se ha especificado el nombre de la recompensa o costo" }
);
return;
}
try { const minRewardPrice = 1;
await createChannelPointsReward(msg.channelId as string, { const cost = Math.max(minRewardPrice, parseInt(args.shift() ?? "0"));
title,
cost
});
say( try {
channel, await createChannelPointsReward(msg.channelId as string, {
`✅ Creada recompensa de canal "${title}" con un costo de ${cost}` title,
); cost,
} catch (e) { });
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`); saySuccess(
} channel,
} `Creada recompensa de canal "${title}" con un costo de ${cost}`
);
} catch (e) {
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
} }
export { createReward }; export { createReward };

View File

@@ -12,107 +12,115 @@ import { start } from "../helpers/miniDb";
let chatClient: ChatClient; let chatClient: ChatClient;
export { chatClient, connect, handleClientAction, say, LOG_PREFIX };
const LOG_PREFIX = "[ChatClient] "; const LOG_PREFIX = "[ChatClient] ";
async function connect(channels: Array<any>): Promise<void> { function onConnect(): Promise<void> {
const authProvider = await getAuthProvider(); console.log(`${LOG_PREFIX}Connected`);
if (chatClient && (chatClient.isConnecting || chatClient.isConnected)) { start();
return;
}
chatClient = new ChatClient({ return Promise.resolve();
authProvider,
channels
});
chatClient.onConnect(onConnect);
chatClient.onDisconnect((e: any) => {
console.log(`${LOG_PREFIX}Disconnected ${e.message}`);
});
chatClient.onNoPermission((channel, message) => {
console.log(`${LOG_PREFIX}No permission on ${channel}: ${message}`);
});
chatClient.onMessage(onMessage);
await chatClient.connect();
}
async function onConnect(): Promise<void> {
console.log(`${LOG_PREFIX}Connected`);
start();
}
async function handleClientAction(action: Action): Promise<void> {
const [channel, username] = await Promise.all([
getUsernameFromId(parseInt(action.channelId)),
getUsernameFromId(parseInt(action.userId))
]);
if (!channel || !username) {
console.log(`${[LOG_PREFIX]}ChannelId or userId could not be solved`);
return;
}
switch (action.type) {
case ActionType.Say:
say(channel, action.data.message);
break;
case ActionType.Timeout:
try {
await timeout(channel, username, action.data.time, action.data.reason);
} catch (e) {
// user cannot be timed out
}
break;
case ActionType.Broadcast:
broadcast(action.data.message);
break;
case ActionType.AddVip:
await addVip(channel, username, `Otorgado VIP a @${username}`);
break;
case ActionType.RemoveVip:
await removeVip(channel, username, `Eliminado VIP de @${username}`);
break;
default:
console.log(`${[LOG_PREFIX]}Couldn't handle action:`, action);
}
} }
const commandPrefix = "!"; const commandPrefix = "!";
async function onMessage( async function onMessage(
channel: string, channel: string,
user: string, user: string,
message: string, message: string,
msg: TwitchPrivateMessage msg: TwitchPrivateMessage
): Promise<void> { ): Promise<void> {
if (msg.userInfo.isBroadcaster && message.startsWith(commandPrefix)) { if (msg.userInfo.isBroadcaster && message.startsWith(commandPrefix)) {
message = message.substring(commandPrefix.length); message = message.substring(commandPrefix.length);
const args = message.split(" "); const args = message.split(" ");
const commandName = args.shift(); const commandName = args.shift();
switch (commandName) { switch (commandName) {
case ChatCommands.Commands: case ChatCommands.Commands:
await say( await say(
channel, channel,
`Comandos disponibles: "${Object.values(ChatCommands).join('", "')}"` `Comandos disponibles: "${Object.values(ChatCommands).join('", "')}"`
); );
break; break;
case ChatCommands.CreateReward: case ChatCommands.CreateReward:
await createReward(channel, user, args.join(" "), msg); await createReward(channel, user, args.join(" "), msg);
break; break;
default: default:
console.log( console.log(
`${LOG_PREFIX}Command ${commandPrefix}${commandName} not handled` `${LOG_PREFIX}Command ${commandPrefix}${commandName} not handled`
); );
} }
} }
} }
async function connect(channels: Array<string>): Promise<void> {
const authProvider = await getAuthProvider();
if (chatClient && (chatClient.isConnecting || chatClient.isConnected)) {
return;
}
chatClient = new ChatClient({
authProvider,
channels,
webSocket: true,
});
chatClient.onConnect(onConnect);
chatClient.onDisconnect((manually, reason) => {
if (manually) {
console.log(`${LOG_PREFIX}Disconnected manually`);
return;
}
console.log(`${LOG_PREFIX}Disconnected ${reason}`);
});
chatClient.onNoPermission((channel, message) => {
console.log(`${LOG_PREFIX}No permission on ${channel}: ${message}`);
});
chatClient.onMessage(onMessage);
await chatClient.connect();
}
async function handleClientAction(action: Action): Promise<void> {
const [channel, username] = await Promise.all([
getUsernameFromId(parseInt(action.channelId)),
getUsernameFromId(parseInt(action.userId)),
]);
if (!channel || !username) {
console.log(`${[LOG_PREFIX]}ChannelId or userId could not be solved`);
return;
}
switch (action.type) {
case ActionType.Say:
say(channel, action.data.message);
break;
case ActionType.Timeout:
try {
await timeout(channel, username, action.data.time, action.data.reason);
} catch (e) {
// user cannot be timed out
}
break;
case ActionType.Broadcast:
broadcast(action.data.message);
break;
case ActionType.AddVip:
await addVip(channel, username, `Otorgado VIP a @${username}`);
break;
case ActionType.RemoveVip:
await removeVip(channel, username, `Eliminado VIP de @${username}`);
break;
default:
console.log(`${[LOG_PREFIX]}Couldn't handle action:`, action);
}
}
export { chatClient, connect, handleClientAction, say, LOG_PREFIX };

View File

@@ -1,3 +1,4 @@
import { Action } from "../../interfaces/actions/Action";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { handleClientAction } from "../chatClient"; import { handleClientAction } from "../chatClient";
import { resolve } from "path"; import { resolve } from "path";
@@ -12,104 +13,124 @@ const FILES_BASE = resolve(process.cwd(), "./storage");
const SCHEDULED_FILE = resolve(FILES_BASE, "./scheduled.json"); const SCHEDULED_FILE = resolve(FILES_BASE, "./scheduled.json");
const VIP_USERS_FILE = resolve(FILES_BASE, "./vips.json"); const VIP_USERS_FILE = resolve(FILES_BASE, "./vips.json");
const scheduledActions: Array<any> = []; const defaultScheduledTimestamp = 0;
let scheduledActions: Array<Action> = [];
const vipUsers: Record<string, Array<string>> = {}; const vipUsers: Record<string, Array<string>> = {};
let checkingScheduled = false; let checkingScheduled = false;
let scheduledActionsInterval: NodeJS.Timeout; let scheduledActionsInterval: NodeJS.Timeout;
let saveScheduledActionsTimeout: NodeJS.Timeout | null; let saveScheduledActionsTimeout: NodeJS.Timeout | null;
// *Check this, not working function save(): void {
async function start(): Promise<void> { if (saveScheduledActionsTimeout) {
if (scheduledActionsInterval) { clearTimeout(saveScheduledActionsTimeout);
return; saveScheduledActionsTimeout = null;
} console.log(`${LOG_PREFIX}Removed save timeout.`);
}
let savedActions = []; saveScheduledActionsTimeout = setTimeout(async () => {
try { await Promise.all([
savedActions = JSON.parse((await fs.readFile(SCHEDULED_FILE)).toString()); fs.writeFile(SCHEDULED_FILE, JSON.stringify(scheduledActions)),
} catch (e) { fs.writeFile(VIP_USERS_FILE, JSON.stringify(vipUsers)),
// probably file does not exist ]);
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
scheduledActions.push.apply(scheduledActions, savedActions); console.log(`${LOG_PREFIX}Saved actions.`);
scheduledActions.sort((a, b) => a.scheduledAt - b.scheduledAt); saveScheduledActionsTimeout = null;
}, SAVE_TIMEOUT);
setTimeout(checkScheduledActions, FIRST_CHECK_TIMEOUT);
scheduledActionsInterval = setInterval(checkScheduledActions, CHECK_INTERVAL);
try {
const savedVipUsers = JSON.parse(
await (await fs.readFile(VIP_USERS_FILE)).toString()
);
for (const key of Object.keys(savedVipUsers)) {
vipUsers[key] = savedVipUsers[key];
}
} catch (e) {
// probably file does not exist
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
} }
async function checkScheduledActions(): Promise<void> { async function checkScheduledActions(): Promise<void> {
if (checkingScheduled) { if (checkingScheduled) {
return; return;
} }
checkingScheduled = true; checkingScheduled = true;
let hasToSave = false; let hasToSave = false;
for ( for (
let i = 0; let i = 0;
i < scheduledActions.length && i < scheduledActions.length &&
scheduledActions[i].scheduledAt <= Date.now(); (scheduledActions[i].scheduledAt ?? defaultScheduledTimestamp) <=
i++ Date.now();
) { i++
hasToSave = true; ) {
hasToSave = true;
const action = scheduledActions.splice(i, 1)[0]; const deleteCount = 1;
await handleClientAction(action); const deletedActions = scheduledActions.splice(i, deleteCount);
console.log(`${LOG_PREFIX}Executed: ${JSON.stringify(action)}`); const action = deletedActions.shift();
}
if (hasToSave) { if (action) {
save(); await handleClientAction(action);
} }
checkingScheduled = false; console.log(`${LOG_PREFIX}Executed: ${JSON.stringify(action)}`);
}
if (hasToSave) {
save();
}
checkingScheduled = false;
}
async function start(): Promise<void> {
if (scheduledActionsInterval) {
return;
}
let savedActions = [];
try {
savedActions = JSON.parse((await fs.readFile(SCHEDULED_FILE)).toString());
} catch (e) {
// probably file does not exist
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
scheduledActions = [...scheduledActions, ...savedActions];
scheduledActions.sort((a, b) => {
if (typeof a.scheduledAt === "undefined") {
a.scheduledAt = defaultScheduledTimestamp;
}
if (typeof b.scheduledAt === "undefined") {
b.scheduledAt = defaultScheduledTimestamp;
}
return a.scheduledAt - b.scheduledAt;
});
setTimeout(checkScheduledActions, FIRST_CHECK_TIMEOUT);
// eslint-disable-next-line require-atomic-updates
scheduledActionsInterval = setInterval(checkScheduledActions, CHECK_INTERVAL);
try {
const savedVipUsers = JSON.parse(
await (await fs.readFile(VIP_USERS_FILE)).toString()
);
for (const key of Object.keys(savedVipUsers)) {
vipUsers[key] = savedVipUsers[key];
}
} catch (e) {
// probably file does not exist
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
} }
async function createSaveDirectory() { async function createSaveDirectory() {
try { try {
await fs.stat(FILES_BASE); await fs.stat(FILES_BASE);
} catch (e) { } catch (e) {
await fs.mkdir(FILES_BASE); await fs.mkdir(FILES_BASE);
} }
}
function save(): void {
if (saveScheduledActionsTimeout) {
clearTimeout(saveScheduledActionsTimeout);
saveScheduledActionsTimeout = null;
console.log(`${LOG_PREFIX}Removed save timeout.`);
}
saveScheduledActionsTimeout = setTimeout(async () => {
await Promise.all([
fs.writeFile(SCHEDULED_FILE, JSON.stringify(scheduledActions)),
fs.writeFile(VIP_USERS_FILE, JSON.stringify(vipUsers))
]);
console.log(`${LOG_PREFIX}Saved actions.`);
saveScheduledActionsTimeout = null;
}, SAVE_TIMEOUT);
} }
createSaveDirectory(); createSaveDirectory();

View File

@@ -5,45 +5,51 @@ import { resolve } from "path";
const TOKENS_FILE = "tokens.json"; const TOKENS_FILE = "tokens.json";
const LOG_PREFIX = "[TokenData] "; const LOG_PREFIX = "[TokenData] ";
export { getTokenData, saveTokenData };
function getTokenDataFilePath(): string { function getTokenDataFilePath(): string {
return resolve(process.cwd(), TOKENS_FILE); return resolve(process.cwd(), TOKENS_FILE);
}
async function getTokenData(): Promise<AccessToken> {
const tokenDataFilePath = getTokenDataFilePath();
let buffer: Buffer;
try {
buffer = await fs.readFile(tokenDataFilePath);
} catch (e) {
console.error(
`${LOG_PREFIX}${TOKENS_FILE} not found on ${tokenDataFilePath}.`
);
process.exit(1);
}
const tokenData = await JSON.parse(buffer.toString());
checkTokenData(tokenData);
return tokenData;
}
async function saveTokenData(tokenData: AccessToken): Promise<void> {
const tokenDataFilePath = getTokenDataFilePath();
const jsonTokenData = JSON.stringify(tokenData);
await fs.writeFile(tokenDataFilePath, jsonTokenData);
console.log(`${LOG_PREFIX}Token data saved`);
} }
function checkTokenData(tokenData: AccessToken): void { function checkTokenData(tokenData: AccessToken): void {
if (!tokenData.accessToken || !tokenData.refreshToken) { if (!tokenData.accessToken || !tokenData.refreshToken) {
console.error( console.error(
`${LOG_PREFIX}Missing refresh_token or access_token in ${TOKENS_FILE}.` `${LOG_PREFIX}Missing refreshToken or accessToken in ${TOKENS_FILE}.`
); );
process.exit(1);
} const exitCode = 1;
process.exit(exitCode);
}
} }
async function getTokenData(): Promise<AccessToken> {
const tokenDataFilePath = getTokenDataFilePath();
let buffer: Buffer;
try {
buffer = await fs.readFile(tokenDataFilePath);
} catch (e) {
console.error(
`${LOG_PREFIX}${TOKENS_FILE} not found on ${tokenDataFilePath}.`
);
const exitCode = 1;
process.exit(exitCode);
}
const tokenData = await JSON.parse(buffer.toString());
checkTokenData(tokenData);
return tokenData;
}
async function saveTokenData(tokenData: AccessToken): Promise<void> {
const tokenDataFilePath = getTokenDataFilePath();
const jsonTokenData = JSON.stringify(tokenData);
await fs.writeFile(tokenDataFilePath, jsonTokenData);
console.log(`${LOG_PREFIX}Token data saved`);
}
export { getTokenData, saveTokenData };

View File

@@ -1,8 +1,8 @@
import { AccessToken, RefreshingAuthProvider } from "@twurple/auth"; import { AccessToken, RefreshingAuthProvider } from "@twurple/auth";
import { import {
ApiClient, ApiClient,
HelixCreateCustomRewardData, HelixCreateCustomRewardData,
UserIdResolvable UserIdResolvable,
} from "@twurple/api"; } from "@twurple/api";
import { getTokenData, saveTokenData } from "./tokenData"; import { getTokenData, saveTokenData } from "./tokenData";
@@ -13,115 +13,118 @@ const LOG_PREFIX = "[Twitch] ";
let refreshAuthProvider: RefreshingAuthProvider; let refreshAuthProvider: RefreshingAuthProvider;
function getClientCredentials(): ClientCredentials { function getClientCredentials(): ClientCredentials {
if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) { if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) {
console.error( console.error(
`${LOG_PREFIX}Missing environment parameters TWITCH_CLIENT_ID or TWITCH_CLIENT_SECRET` `${LOG_PREFIX}Missing environment parameters TWITCH_CLIENT_ID or TWITCH_CLIENT_SECRET`
); );
process.exit(1);
}
return { const exitCode = 1;
clientId: process.env.TWITCH_CLIENT_ID,
clientSecret: process.env.TWITCH_CLIENT_SECRET
};
}
async function getAuthProvider(): Promise<RefreshingAuthProvider> { process.exit(exitCode);
if (refreshAuthProvider) { }
return refreshAuthProvider;
}
let tokenData = await getTokenData(); return {
clientId: process.env.TWITCH_CLIENT_ID,
const credentials = getClientCredentials(); clientSecret: process.env.TWITCH_CLIENT_SECRET,
};
refreshAuthProvider = new RefreshingAuthProvider(
{
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
onRefresh
},
tokenData
);
return refreshAuthProvider;
} }
async function onRefresh(refreshData: AccessToken): Promise<void> { async function onRefresh(refreshData: AccessToken): Promise<void> {
console.log(`${LOG_PREFIX}Tokens refreshed`); console.log(`${LOG_PREFIX}Tokens refreshed`);
await saveTokenData(refreshData); await saveTokenData(refreshData);
}
async function getAuthProvider(): Promise<RefreshingAuthProvider> {
if (refreshAuthProvider) {
return refreshAuthProvider;
}
const tokenData = await getTokenData();
const credentials = getClientCredentials();
// eslint-disable-next-line require-atomic-updates
refreshAuthProvider = new RefreshingAuthProvider(
{
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
onRefresh,
},
tokenData
);
return refreshAuthProvider;
} }
async function getApiClient(): Promise<ApiClient> { async function getApiClient(): Promise<ApiClient> {
const authProvider = await getAuthProvider(); const authProvider = await getAuthProvider();
return await new ApiClient({ authProvider }); return new ApiClient({ authProvider });
} }
async function getUsernameFromId(userId: number): Promise<string | null> { async function getUsernameFromId(userId: number): Promise<string | null> {
const apiClient = await getApiClient(); const apiClient = await getApiClient();
const user = await apiClient.users.getUserById(userId); const user = await apiClient.users.getUserById(userId);
if (!user) { if (!user) {
return null; return null;
} }
return user.displayName; return user.displayName;
} }
async function createReward( async function createReward(
userId: UserIdResolvable, userId: UserIdResolvable,
data: HelixCreateCustomRewardData data: HelixCreateCustomRewardData
) { ) {
const apiClient = await getApiClient(); const apiClient = await getApiClient();
await apiClient.channelPoints.createCustomReward(userId, data); await apiClient.channelPoints.createCustomReward(userId, data);
} }
async function completeRewards( async function completeRewards(
channel: UserIdResolvable, channel: UserIdResolvable,
rewardId: string, rewardId: string,
redemptionIds: Array<string> | string redemptionIds: Array<string> | string
) { ) {
if (!Array.isArray(redemptionIds)) { if (!Array.isArray(redemptionIds)) {
redemptionIds = [redemptionIds]; redemptionIds = [redemptionIds];
} }
const apiClient = await getApiClient(); const apiClient = await getApiClient();
await apiClient.channelPoints.updateRedemptionStatusByIds( await apiClient.channelPoints.updateRedemptionStatusByIds(
channel, channel,
rewardId, rewardId,
redemptionIds, redemptionIds,
"FULFILLED" "FULFILLED"
); );
} }
async function cancelRewards( async function cancelRewards(
channel: UserIdResolvable, channel: UserIdResolvable,
rewardId: string, rewardId: string,
redemptionIds: Array<string> | string redemptionIds: Array<string> | string
) { ) {
if (!Array.isArray(redemptionIds)) { if (!Array.isArray(redemptionIds)) {
redemptionIds = [redemptionIds]; redemptionIds = [redemptionIds];
} }
const apiClient = await getApiClient(); const apiClient = await getApiClient();
await apiClient.channelPoints.updateRedemptionStatusByIds( await apiClient.channelPoints.updateRedemptionStatusByIds(
channel, channel,
rewardId, rewardId,
redemptionIds, redemptionIds,
"CANCELED" "CANCELED"
); );
} }
export { export {
getAuthProvider, getAuthProvider,
getApiClient, getApiClient,
getUsernameFromId, getUsernameFromId,
completeRewards, completeRewards,
cancelRewards, cancelRewards,
createReward createReward,
}; };

View File

@@ -1,9 +1,8 @@
import { AddressInfo, Socket } from "net";
import { IncomingMessage, Server } from "http"; import { IncomingMessage, Server } from "http";
import { save, scheduledActions } from "./miniDb"; import { save, scheduledActions } from "./miniDb";
import { Action } from "../../interfaces/actions/Action"; import { Action } from "../../interfaces/actions/Action";
import { AddressInfo } from "net";
import { Socket } from "net";
import WebSocket from "ws"; import WebSocket from "ws";
import express from "express"; import express from "express";
import { handleClientAction } from "../chatClient"; import { handleClientAction } from "../chatClient";
@@ -17,98 +16,103 @@ const app = express();
const sockets: Array<WebSocket> = []; const sockets: Array<WebSocket> = [];
const wsServer = new WebSocket.Server({ const wsServer = new WebSocket.Server({
noServer: true noServer: true,
}); });
let server: Server; let server: Server;
export { listen, broadcast }; function broadcast(msg: string, socket?: WebSocket) {
const filteredSockets = socket
? sockets.filter((s) => s !== socket)
: sockets;
filteredSockets.forEach((s) => s.send(msg));
}
async function onMessage(this: WebSocket, msg: string) {
const data = JSON.parse(msg);
if (!data.actions) {
broadcast(msg, this);
return;
}
const actions: Array<Action> = data.actions;
for (const action of actions) {
if (!action.scheduledAt) {
await handleClientAction(action);
} else {
scheduledActions.push(action);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
scheduledActions.sort((a: any, b: any) => a.scheduledAt - b.scheduledAt);
save();
}
}
console.log(
`${LOG_PREFIX_WS}Received message with ${data.actions.length} actions:`,
data
);
}
function onClose(this: WebSocket) {
const socketIdx = sockets.indexOf(this);
const deleteCount = 1;
sockets.splice(socketIdx, deleteCount);
console.log(`${LOG_PREFIX_WS}Connection closed`);
}
function onListening() {
console.log(
`${LOG_PREFIX_HTTP}Listening on port ${
(server.address() as AddressInfo).port
}`
);
}
function onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer) {
wsServer.handleUpgrade(req, socket, head, (socket) => {
wsServer.emit("connection", socket, req);
});
}
function onConnection(socket: WebSocket, req: IncomingMessage) {
console.log(
`${LOG_PREFIX_WS}${req.socket.remoteAddress} New connection established`
);
sockets.push(socket);
socket.send(
JSON.stringify({
env: isDevelopment ? "dev" : "prod",
})
);
socket.on("message", onMessage);
socket.on("close", onClose);
}
wsServer.on("connection", onConnection); wsServer.on("connection", onConnection);
app.use(express.static(join(process.cwd(), "client"))); app.use(express.static(join(process.cwd(), "client")));
function listen() { function listen() {
if (server) { if (server) {
console.log(`${LOG_PREFIX_HTTP}Server is already running`); console.log(`${LOG_PREFIX_HTTP}Server is already running`);
return; return;
} }
server = app.listen(!isDevelopment ? 8080 : 8081, "0.0.0.0"); let port = 8080;
server.on("listening", onListening); if (isDevelopment) {
server.on("upgrade", onUpgrade); port++;
}
server = app.listen(port, "0.0.0.0");
server.on("listening", onListening);
server.on("upgrade", onUpgrade);
} }
function onListening() { export { listen, broadcast };
console.log(
`${LOG_PREFIX_HTTP}Listening on port ${
(server.address() as AddressInfo).port
}`
);
}
function onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer) {
wsServer.handleUpgrade(req, socket, head, socket => {
wsServer.emit("connection", socket, req);
});
}
function onConnection(socket: WebSocket, req: IncomingMessage) {
console.log(
`${LOG_PREFIX_WS}${req.socket.remoteAddress} New connection established`
);
sockets.push(socket);
socket.send(
JSON.stringify({
env: isDevelopment ? "dev" : "prod"
})
);
socket.on("message", onMessage);
socket.on("close", onClose);
}
// broadcast a message to all clients
function broadcast(msg: string, socket?: any) {
const filteredSockets = socket ? sockets.filter(s => s !== socket) : sockets;
filteredSockets.forEach(s => s.send(msg));
}
async function onMessage(msg: string) {
// @ts-ignore
const socket = this as WebSocket;
const data = JSON.parse(msg);
if (!data.actions || data.actions.length === 0) {
broadcast(msg, socket);
return;
}
const actions: Array<Action> = data.actions;
for (const action of actions) {
if (!action.scheduledAt) {
await handleClientAction(action);
} else {
scheduledActions.push(action);
scheduledActions.sort((a: any, b: any) => a.scheduledAt - b.scheduledAt);
save();
}
}
console.log(
`${LOG_PREFIX_WS}Received message with ${data.actions.length} actions:`,
data
);
}
function onClose() {
// @ts-ignore
const socket: WebSocket = this as WebSocket;
const socketIdx = sockets.indexOf(socket);
sockets.splice(socketIdx, 1);
console.log(`${LOG_PREFIX_WS}Connection closed`);
}

View File

@@ -4,39 +4,40 @@ import { getUsernameFromId } from "../../helpers/twitch";
import { timeout } from "../../chatClient/clientActions"; import { timeout } from "../../chatClient/clientActions";
async function highlightMessage( async function highlightMessage(
msg: RedemptionMessage msg: RedemptionMessage
): Promise<RedemptionMessage | undefined> { ): Promise<RedemptionMessage | undefined> {
if (!msg.message) { if (!msg.message) {
console.log(`${LOG_PREFIX}Redemption has no message`); console.log(`${LOG_PREFIX}Redemption has no message`);
return; return;
} }
const urlRegex = const urlRegex =
/(https?:\/\/)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&/=]*)/; /(https?:\/\/)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/;
if (urlRegex.test(msg.message)) { if (urlRegex.test(msg.message)) {
console.log(`${LOG_PREFIX}Message contains a url`); console.log(`${LOG_PREFIX}Message contains a url`);
const channel = await getUsernameFromId(parseInt(msg.channelId)); const channel = await getUsernameFromId(parseInt(msg.channelId));
if (!channel) { if (!channel) {
console.log(`${LOG_PREFIX}No channel found`); console.log(`${LOG_PREFIX}No channel found`);
return; return;
} }
try { try {
const reason = "No se permite enviar enlaces en mensajes destacados"; const reason = "No se permite enviar enlaces en mensajes destacados";
const timeoutSeconds = 10;
await timeout(channel, msg.userDisplayName, 10, reason); await timeout(channel, msg.userDisplayName, timeoutSeconds, reason);
} catch (e) { } catch (e) {
// user probably cannot be timed out // user probably cannot be timed out
} }
return; return;
} }
return msg; return msg;
} }
export { highlightMessage }; export { highlightMessage };

View File

@@ -12,56 +12,60 @@ const timeoutSeconds = 60;
const maxSafeShots = 5; const maxSafeShots = 5;
async function russianRoulette( async function russianRoulette(
msg: RedemptionMessage msg: RedemptionMessage
): Promise<RedemptionMessage | undefined> { ): Promise<RedemptionMessage | undefined> {
const { channelId, userDisplayName } = msg; const { channelId, userDisplayName } = msg;
const channel = await getUsernameFromId(parseInt(channelId)); const channel = await getUsernameFromId(parseInt(channelId));
if (!channel) { if (!channel) {
console.log(`${LOG_PREFIX}No channel found`); console.log(`${LOG_PREFIX}No channel found`);
return; return;
} }
if (!gunsSafeShots[channelId]) { if (!gunsSafeShots[channelId]) {
gunsSafeShots[channelId] = maxSafeShots; gunsSafeShots[channelId] = maxSafeShots;
} }
const win = const noShots = 0;
gunsSafeShots[channelId] > 0 &&
randomInt(gunsSafeShots[channelId]-- + 1) !== 0;
if (gunsSafeShots[channelId] < 0 || !win) { const win =
gunsSafeShots[channelId] = maxSafeShots; gunsSafeShots[channelId] > noShots &&
} // eslint-disable-next-line no-magic-numbers
randomInt(gunsSafeShots[channelId]-- + 1) !== 0;
msg.message = win ? "" : "got shot"; if (gunsSafeShots[channelId] < noShots || !win) {
gunsSafeShots[channelId] = maxSafeShots;
}
const promises: Array<Promise<unknown>> = []; // eslint-disable-next-line require-atomic-updates
msg.message = win ? "" : "got shot";
if (!win) { const promises: Array<Promise<unknown>> = [];
promises.push(
timeout(channel, userDisplayName, timeoutSeconds, "F en la ruleta")
);
promises.push(
say(
channel,
`PepeHands ${userDisplayName} no ha sobrevivido para contarlo`
)
);
} else {
promises.push(say(channel, `rdCool Clap ${userDisplayName}`));
}
try { if (!win) {
await Promise.allSettled(promises); promises.push(
} catch (e) { timeout(channel, userDisplayName, timeoutSeconds, "F en la ruleta")
if (e instanceof Error) { );
console.log(`${LOG_PREFIX}${e.message}`); promises.push(
} say(
} channel,
`PepeHands ${userDisplayName} no ha sobrevivido para contarlo`
)
);
} else {
promises.push(say(channel, `rdCool Clap ${userDisplayName}`));
}
return msg; try {
await Promise.allSettled(promises);
} catch (e) {
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
return msg;
} }
export { russianRoulette }; export { russianRoulette };

View File

@@ -7,70 +7,73 @@ import { getUsernameFromId } from "../../helpers/twitch";
// remove vip from a user to grant it to yourself // remove vip from a user to grant it to yourself
async function stealVip( async function stealVip(
msg: RedemptionMessage msg: RedemptionMessage
): Promise<RedemptionMessage | undefined> { ): Promise<RedemptionMessage | undefined> {
if (!msg.message) { if (!msg.message) {
console.log(`${LOG_PREFIX}Redemption has no message`); console.log(`${LOG_PREFIX}Redemption has no message`);
return; return;
} }
const channelId = parseInt(msg.channelId); const channelId = parseInt(msg.channelId);
const channel = await getUsernameFromId(channelId); const channel = await getUsernameFromId(channelId);
if (!channel) { if (!channel) {
console.log(`${LOG_PREFIX}No channel found`); console.log(`${LOG_PREFIX}No channel found`);
return; return;
} }
const addVipUser = msg.userDisplayName; const addVipUser = msg.userDisplayName;
const removeVipUser = msg.message.toLowerCase(); const removeVipUser = msg.message.toLowerCase();
const channelVips = vipUsers[channelId]; const channelVips = vipUsers[channelId];
if (!channelVips.find(u => u.toLowerCase() === removeVipUser)) { if (!channelVips.find((u) => u.toLowerCase() === removeVipUser)) {
const message = const message =
channelVips.length === 0 // eslint-disable-next-line no-magic-numbers
? "No hay nadie a quien puedas robar el VIP" channelVips.length === 0
: `Solo puedes robar el VIP de: "${channelVips.sort().join('", "')}"`; ? "No hay nadie a quien puedas robar el VIP"
await say(channel, message); : `Solo puedes robar el VIP de: "${channelVips.sort().join('", "')}"`;
await say(channel, message);
return; return;
} }
if (channelVips.includes(addVipUser) || (await hasVip(channel, addVipUser))) { if (channelVips.includes(addVipUser) || (await hasVip(channel, addVipUser))) {
console.log(`${LOG_PREFIX}@${addVipUser} is already VIP`); console.log(`${LOG_PREFIX}@${addVipUser} is already VIP`);
return; return;
} }
const removed = await removeVip(channel, removeVipUser); const removed = await removeVip(channel, removeVipUser);
if (!removed && (await hasVip(channel, removeVipUser))) { if (!removed && (await hasVip(channel, removeVipUser))) {
console.log(`${LOG_PREFIX}Could not remove VIP of @${removeVipUser}`); console.log(`${LOG_PREFIX}Could not remove VIP of @${removeVipUser}`);
return; return;
} }
const added = await addVip(channel, addVipUser); const added = await addVip(channel, addVipUser);
if (!added) { if (!added) {
await addVip(channel, removeVipUser); await addVip(channel, removeVipUser);
return; return;
} }
const removeIdx = channelVips.findIndex( const removeIdx = channelVips.findIndex(
u => u.toLowerCase() === removeVipUser (u) => u.toLowerCase() === removeVipUser
); );
channelVips.splice(removeIdx); channelVips.splice(removeIdx);
channelVips.push(addVipUser); channelVips.push(addVipUser);
save(); save();
msg.message = `@${addVipUser} ha "tomado prestado" el VIP de @${removeVipUser}`; // eslint-disable-next-line require-atomic-updates
await say(channel, msg.message); msg.message = `@${addVipUser} ha "tomado prestado" el VIP de @${removeVipUser}`;
return msg; await say(channel, msg.message);
return msg;
} }
export { stealVip }; export { stealVip };

View File

@@ -4,40 +4,41 @@ import { getUsernameFromId } from "../../helpers/twitch";
import { timeout } from "../../chatClient/clientActions"; import { timeout } from "../../chatClient/clientActions";
async function timeoutFriend( async function timeoutFriend(
msg: RedemptionMessage msg: RedemptionMessage
): Promise<RedemptionMessage | undefined> { ): Promise<RedemptionMessage | undefined> {
const { message, channelId, userDisplayName } = msg; const { message, channelId, userDisplayName } = msg;
if (!msg.message) { if (!msg.message) {
console.log(`${LOG_PREFIX}Redemption has no message`); console.log(`${LOG_PREFIX}Redemption has no message`);
return; return;
} }
const channel = await getUsernameFromId(parseInt(channelId)); const channel = await getUsernameFromId(parseInt(channelId));
if (!channel) { if (!channel) {
console.log(`${LOG_PREFIX}No channel found`); console.log(`${LOG_PREFIX}No channel found`);
return; return;
} }
const time = 60; const time = 60;
const reason = `Timeout dado por @${userDisplayName} con puntos del canal`; const reason = `Timeout dado por @${userDisplayName} con puntos del canal`;
try { try {
await timeout(channel, msg.message, time, reason); await timeout(channel, msg.message, time, reason);
msg.message = `@${userDisplayName} ha expulsado a @${message} por ${time} segundos`; // eslint-disable-next-line require-atomic-updates
} catch (e) { msg.message = `@${userDisplayName} ha expulsado a @${message} por ${time} segundos`;
// user can not be timed out } catch (e) {
if (e instanceof Error) { // user can not be timed out
console.error(`${LOG_PREFIX} ${e.message}`); if (e instanceof Error) {
} console.error(`${LOG_PREFIX} ${e.message}`);
}
return; return;
} }
return msg; return msg;
} }
export { timeoutFriend }; export { timeoutFriend };

View File

@@ -1,8 +1,8 @@
import { PubSubClient, PubSubRedemptionMessage } from "@twurple/pubsub"; import { PubSubClient, PubSubRedemptionMessage } from "@twurple/pubsub";
import { import {
cancelRewards, cancelRewards,
completeRewards, completeRewards,
getAuthProvider getAuthProvider,
} from "../helpers/twitch"; } from "../helpers/twitch";
import { RedemptionIds } from "../../enums/Redemptions"; import { RedemptionIds } from "../../enums/Redemptions";
@@ -20,107 +20,116 @@ import { timeoutFriend } from "./actions/timeoutFriend";
const LOG_PREFIX = "[PubSub] "; const LOG_PREFIX = "[PubSub] ";
async function registerUserListener(user: UserIdResolvable) { async function onRedemption(message: PubSubRedemptionMessage) {
const pubSubClient = new PubSubClient(); console.log(
const userId = await pubSubClient.registerUserListener( `${LOG_PREFIX}Reward: "${message.rewardTitle}" (${message.rewardId}) redeemed by ${message.userDisplayName}`
await getAuthProvider(), );
user
);
/*const listener = */ await pubSubClient.onRedemption(userId, onRedemption);
console.log(`${LOG_PREFIX}Connected & registered`); const raw = message[rawDataSymbol];
const msg: RedemptionMessage = {
id: message.id,
channelId: message.channelId,
rewardId: message.rewardId,
rewardName: message.rewardTitle,
rewardImage: message.rewardImage
? message.rewardImage.url_4x
: "https://static-cdn.jtvnw.net/custom-reward-images/default-4.png",
message: message.message,
userId: message.userId,
userDisplayName: message.userDisplayName,
backgroundColor: raw.data.redemption.reward.background_color,
};
let handledMessage: RedemptionMessage | undefined;
try {
// TODO: extract to function
// Returns the executor function, then call it inside a try/catch
switch (msg.rewardId) {
case RedemptionIds.GetVip:
handledMessage = await getVip(msg);
break;
case RedemptionIds.Hidrate:
handledMessage = await hidrate(msg);
break;
case RedemptionIds.HighlightMessage:
handledMessage = await highlightMessage(msg);
break;
case RedemptionIds.RussianRoulette:
handledMessage = await russianRoulette(msg);
break;
case RedemptionIds.StealVip:
handledMessage = await stealVip(msg);
break;
case RedemptionIds.TimeoutFriend:
handledMessage = await timeoutFriend(msg);
break;
default:
console.log(`${LOG_PREFIX}Unhandled redemption ${msg.rewardId}`);
handledMessage = msg;
break;
}
} catch (e) {
if (e instanceof Error) {
console.error(`${LOG_PREFIX}${e.message}`);
}
}
if (handledMessage) {
const rewardEnumValues = Object.values(RedemptionIds);
const rewardIdValueIndex = rewardEnumValues.indexOf(
// @ts-expect-error String is not assignable to... but all keys are strings
handledMessage.rewardId
);
const rewardName = Object.keys(RedemptionIds)[rewardIdValueIndex];
handledMessage.rewardId = rewardName;
broadcast(JSON.stringify(handledMessage));
}
// TODO: improve this check
const keepInQueueRewards = [RedemptionIds.KaraokeTime];
// @ts-expect-error String is not assignable to... but all keys are strings
if (keepInQueueRewards.includes(message.rewardId)) {
console.log(`${LOG_PREFIX}Reward kept in queue due to config`);
return;
}
const completeOrCancelReward =
handledMessage && isProduction ? completeRewards : cancelRewards;
if (message.rewardIsQueued) {
try {
await completeOrCancelReward(
message.channelId,
message.rewardId,
message.id
);
console.log(
`${LOG_PREFIX}Reward removed from queue (completed or canceled)`
);
} catch (e) {
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
}
} }
async function onRedemption(message: PubSubRedemptionMessage) { async function registerUserListener(user: UserIdResolvable) {
console.log( const pubSubClient = new PubSubClient();
`${LOG_PREFIX}Reward: "${message.rewardTitle}" (${message.rewardId}) redeemed by ${message.userDisplayName}` const userId = await pubSubClient.registerUserListener(
); await getAuthProvider(),
user
);
const raw = message[rawDataSymbol]; await pubSubClient.onRedemption(userId, onRedemption);
const msg: RedemptionMessage = { console.log(`${LOG_PREFIX}Connected & registered`);
id: message.id,
channelId: message.channelId,
rewardId: message.rewardId,
rewardName: message.rewardTitle,
rewardImage: message.rewardImage
? message.rewardImage.url_4x
: "https://static-cdn.jtvnw.net/custom-reward-images/default-4.png",
message: message.message,
userId: message.userId,
userDisplayName: message.userDisplayName,
backgroundColor: raw.data.redemption.reward.background_color
};
let handledMessage: RedemptionMessage | undefined;
switch (msg.rewardId) {
case RedemptionIds.RussianRoulette:
handledMessage = await russianRoulette(msg);
break;
case RedemptionIds.TimeoutFriend:
handledMessage = await timeoutFriend(msg);
break;
case RedemptionIds.HighlightMessage:
handledMessage = await highlightMessage(msg);
break;
case RedemptionIds.GetVip:
handledMessage = await getVip(msg);
break;
case RedemptionIds.StealVip:
handledMessage = await stealVip(msg);
break;
case RedemptionIds.Hidrate:
handledMessage = await hidrate(msg);
break;
default:
console.log(`${LOG_PREFIX}Unhandled redemption ${msg.rewardId}`);
handledMessage = msg;
break;
}
if (handledMessage) {
const rewardEnumValues = Object.values(RedemptionIds);
const rewardIdValueIndex = rewardEnumValues.indexOf(
// @ts-expect-error String is not assignable to... but all keys are strings
handledMessage.rewardId
);
const rewardName = Object.keys(RedemptionIds)[rewardIdValueIndex];
handledMessage.rewardId = rewardName;
broadcast(JSON.stringify(handledMessage));
}
// TODO: improve this check
const keepInQueueRewards = [RedemptionIds.KaraokeTime];
// @ts-expect-error String is not assignable to... but all keys are strings
if (keepInQueueRewards.includes(message.rewardId)) {
console.log(`${LOG_PREFIX}Reward kept in queue due to config`);
return;
}
const completeOrCancelReward =
handledMessage && isProduction ? completeRewards : cancelRewards;
if (message.rewardIsQueued) {
try {
await completeOrCancelReward(
message.channelId,
message.rewardId,
message.id
);
console.log(
`${LOG_PREFIX}Reward removed from queue (completed or canceled)`
);
} catch (e) {
if (e instanceof Error) {
console.log(`${LOG_PREFIX}${e.message}`);
}
}
}
} }
export { registerUserListener, LOG_PREFIX }; export { registerUserListener, LOG_PREFIX };

View File

@@ -1,9 +1,10 @@
import { ActionType } from "../../enums/ActionType"; import { ActionType } from "../../enums/ActionType";
export interface Action { export interface Action {
type: ActionType; type: ActionType;
channelId: string; channelId: string;
userId: string; userId: string;
scheduledAt?: number; scheduledAt?: number;
data: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
} }