feat: implement full CRUD and administrative management endpoints for WAP servers in the sysadmin API
This commit is contained in:
@@ -13,6 +13,17 @@ import {
|
||||
BotView,
|
||||
IWapServer,
|
||||
IWapServerManager,
|
||||
SysAdminDeleteWapServerParams,
|
||||
SysAdminDeleteWapServerResult,
|
||||
SysAdminWapContainerDto,
|
||||
SysAdminWapServerAuditParams,
|
||||
SysAdminWapServerAuditResult,
|
||||
SysAdminWapServerRecalculateCountParams,
|
||||
SysAdminWapServerRecalculateCountResult,
|
||||
SysAdminWapServerDetachResult,
|
||||
SysAdminWapServerDeleteBotResult,
|
||||
SysAdminWapServerOrganizationActionParams,
|
||||
SysAdminWapServerQrResult,
|
||||
SendBotMessageParams,
|
||||
ValidateBotEventStatus,
|
||||
BOT_STATE,
|
||||
@@ -21,12 +32,19 @@ import {
|
||||
VALIDATE_BOT_ENVENT_ERRORS,
|
||||
PaginateWapServerResults,
|
||||
PaginateWapServerParams,
|
||||
UpdateWapServerParams,
|
||||
} from "./WapServer.Interface";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
|
||||
const BOT_ADMIN_ACTION_TIMEOUT_MS = 10000;
|
||||
const BOT_ADMIN_RESTART_STATUS_RETRIES = 5;
|
||||
const BOT_ADMIN_RESTART_STATUS_DELAY_MS = 1000;
|
||||
const MONGO_OBJECT_ID_REGEX = /^[a-fA-F0-9]{24}$/;
|
||||
const WAP_SERVER_METADATA_HOSTS = new Set(["169.254.169.254", "metadata.google.internal"]);
|
||||
|
||||
export interface CreateBotResult {
|
||||
organizationId: string;
|
||||
port: number;
|
||||
@@ -142,10 +160,259 @@ class WapServerManager implements IWapServerManager {
|
||||
return servidores;
|
||||
}
|
||||
|
||||
private normalizeBotAdminApiUrl(url: string): string {
|
||||
const rawUrl = String(url).trim();
|
||||
|
||||
if (!rawUrl) {
|
||||
throw new Error("URL is required");
|
||||
}
|
||||
|
||||
if (/\s|[\x00-\x1F\x7F]/.test(rawUrl)) {
|
||||
throw new Error("URL must not contain whitespace or control characters");
|
||||
}
|
||||
|
||||
const hasProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(rawUrl);
|
||||
const normalizedInput = hasProtocol ? rawUrl : `http://${rawUrl}`;
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(normalizedInput);
|
||||
} catch (error) {
|
||||
throw new Error("URL must be a valid HTTP(S) origin");
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
throw new Error("URL protocol must be http or https");
|
||||
}
|
||||
|
||||
if (!parsedUrl.hostname) {
|
||||
throw new Error("URL host is required");
|
||||
}
|
||||
|
||||
if (parsedUrl.username || parsedUrl.password) {
|
||||
throw new Error("URL must not include credentials");
|
||||
}
|
||||
|
||||
const path = decodeURIComponent(parsedUrl.pathname);
|
||||
if ((parsedUrl.pathname && parsedUrl.pathname !== "/") || path.includes("..")) {
|
||||
throw new Error("URL path is not allowed; use only the origin");
|
||||
}
|
||||
|
||||
if (parsedUrl.search || parsedUrl.hash) {
|
||||
throw new Error("URL must not include query string or hash");
|
||||
}
|
||||
|
||||
if (WAP_SERVER_METADATA_HOSTS.has(parsedUrl.hostname.toLowerCase())) {
|
||||
throw new Error("URL host is not allowed");
|
||||
}
|
||||
|
||||
return `${parsedUrl.origin}/`;
|
||||
}
|
||||
|
||||
private getBotAdminApiUrl(url: string): string {
|
||||
//return `https://${url}:${process.env.DEFAULT_BOT_ADMIN_PORT}/`;
|
||||
//return `https://${url}/`;
|
||||
return url;
|
||||
return this.normalizeBotAdminApiUrl(url);
|
||||
}
|
||||
|
||||
private formatResponseBody(data: unknown): string {
|
||||
if (data === undefined || data === null) {
|
||||
return "empty body";
|
||||
}
|
||||
|
||||
const body = typeof data === "string" ? data : JSON.stringify(data);
|
||||
|
||||
return body.length > 1000 ? `${body.slice(0, 1000)}...` : body;
|
||||
}
|
||||
|
||||
private createBotAdminContainersError(error: unknown, targetUrl: string): Error {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const body = this.formatResponseBody(error.response?.data);
|
||||
const statusText = status ? `status ${status}` : error.code || "no response";
|
||||
|
||||
return new Error(`Bot-admin containers request failed for ${targetUrl}: ${statusText}; body: ${body}`);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return new Error(`Bot-admin containers request failed for ${targetUrl}: ${error.message}`);
|
||||
}
|
||||
|
||||
return new Error(`Bot-admin containers request failed for ${targetUrl}: unknown error`);
|
||||
}
|
||||
|
||||
private createBotAdminActionError(error: unknown, targetUrl: string): Error {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const body = this.formatResponseBody(error.response?.data);
|
||||
const statusText = status ? `status ${status}` : error.code || "no response";
|
||||
|
||||
return new Error(`Bot-admin request failed for ${targetUrl}: ${statusText}; body: ${body}`);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return new Error(`Bot-admin request failed for ${targetUrl}: ${error.message}`);
|
||||
}
|
||||
|
||||
return new Error(`Bot-admin request failed for ${targetUrl}: unknown error`);
|
||||
}
|
||||
|
||||
private isBotAdminMissingContainerError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = error.response?.status;
|
||||
const body = this.formatResponseBody(error.response?.data).toLowerCase();
|
||||
|
||||
// Be conservative: only allow detach to continue for explicit missing-container signals.
|
||||
return status === 404 || body.includes("not found") || body.includes("no such container");
|
||||
}
|
||||
|
||||
private async getSysAdminServerActionTarget(data: SysAdminWapServerOrganizationActionParams): Promise<{
|
||||
server: any;
|
||||
baseUrl: string;
|
||||
encodedOrganizationId: string;
|
||||
}> {
|
||||
if (!data.serverId) {
|
||||
throw new Error("Server id is required");
|
||||
}
|
||||
|
||||
if (!data.organizationId) {
|
||||
throw new Error("Organization id is required");
|
||||
}
|
||||
|
||||
if (!MONGO_OBJECT_ID_REGEX.test(data.organizationId)) {
|
||||
throw new Error("Organization id must be a valid Mongo ObjectId");
|
||||
}
|
||||
|
||||
const server = await this.servers.serverList.findOne({ _id: data.serverId });
|
||||
|
||||
if (!server) {
|
||||
throw new Error("WAP server not found");
|
||||
}
|
||||
|
||||
return { server, baseUrl: this.getBotAdminApiUrl(server.ipv4), encodedOrganizationId: encodeURIComponent(data.organizationId) };
|
||||
}
|
||||
|
||||
private async postSysAdminBotAction(
|
||||
data: SysAdminWapServerOrganizationActionParams,
|
||||
action: "start" | "stop",
|
||||
): Promise<void> {
|
||||
const { baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
|
||||
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}/${action}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(targetUrl, {}, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: BOT_ADMIN_ACTION_TIMEOUT_MS,
|
||||
});
|
||||
const result: CreateBotResult = response.data;
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
throw this.createBotAdminActionError(error, targetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSysAdminRuntimeBot(
|
||||
baseUrl: string,
|
||||
encodedOrganizationId: string,
|
||||
allowMissingContainer = false,
|
||||
): Promise<{ botDeleted: boolean; botAlreadyMissing: boolean }> {
|
||||
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}`;
|
||||
|
||||
try {
|
||||
const response = await axios.delete(targetUrl, { timeout: BOT_ADMIN_ACTION_TIMEOUT_MS });
|
||||
const result: CreateBotResult = response.data;
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return { botDeleted: true, botAlreadyMissing: false };
|
||||
} catch (error) {
|
||||
if (allowMissingContainer && this.isBotAdminMissingContainerError(error)) {
|
||||
return { botDeleted: false, botAlreadyMissing: true };
|
||||
}
|
||||
|
||||
throw this.createBotAdminActionError(error, targetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForSysAdminBotStopped(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
|
||||
const { baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
|
||||
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}/status`;
|
||||
|
||||
for (let attempt = 0; attempt < BOT_ADMIN_RESTART_STATUS_RETRIES; attempt += 1) {
|
||||
try {
|
||||
const response = await axios.post(targetUrl, {}, { timeout: BOT_ADMIN_ACTION_TIMEOUT_MS });
|
||||
const status = String(response.data?.status || "").toLowerCase();
|
||||
|
||||
if (!status.includes("up")) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw this.createBotAdminActionError(error, targetUrl);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, BOT_ADMIN_RESTART_STATUS_DELAY_MS));
|
||||
}
|
||||
|
||||
throw new Error("Bot did not stop before restart timeout; start was not attempted.");
|
||||
}
|
||||
|
||||
private extractOrganizationIdFromContainer(container: any): string | undefined {
|
||||
const names = Array.isArray(container?.Names) ? container.Names : [];
|
||||
|
||||
for (const rawName of names) {
|
||||
const name = String(rawName || "").replace(/^\//, "");
|
||||
const match = name.match(/^(.+)_bot_\d+$/);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private sanitizeContainer(container: any): SysAdminWapContainerDto {
|
||||
const names = Array.isArray(container?.Names) ? container.Names : [];
|
||||
const firstName = names.length > 0 ? String(names[0]).replace(/^\//, "") : undefined;
|
||||
|
||||
return {
|
||||
id: container?.Id,
|
||||
name: firstName,
|
||||
organizationId: this.extractOrganizationIdFromContainer(container),
|
||||
image: container?.Image,
|
||||
state: container?.State,
|
||||
status: container?.Status,
|
||||
ports: Array.isArray(container?.Ports) ? container.Ports : [],
|
||||
created: container?.Created,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSysAdminRuntimeContainers(server: any): Promise<SysAdminWapContainerDto[]> {
|
||||
const containersUrl = this.getBotAdminApiUrl(server.ipv4) + "containers";
|
||||
|
||||
try {
|
||||
const response = await axios.get(containersUrl, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: BOT_ADMIN_ACTION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return Array.isArray(response.data)
|
||||
? response.data.map((container: any) => this.sanitizeContainer(container))
|
||||
: [];
|
||||
} catch (error) {
|
||||
throw this.createBotAdminContainersError(error, containersUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public async getAvailableServer(): Promise<IWapServer> {
|
||||
@@ -713,6 +980,264 @@ class WapServerManager implements IWapServerManager {
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
public async sysAdminUpdateWapServer(data: UpdateWapServerParams): Promise<IWapServer> {
|
||||
if (!data.serverId) {
|
||||
throw new Error("Server id is required");
|
||||
}
|
||||
|
||||
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
|
||||
throw new Error("Server id must be a valid Mongo ObjectId");
|
||||
}
|
||||
|
||||
const server = await this.servers.serverList.findOne({ _id: data.serverId });
|
||||
|
||||
if (!server) {
|
||||
throw new Error("WAP server not found");
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
server.name = String(data.name).trim();
|
||||
}
|
||||
|
||||
if (data.ipv4 !== undefined) {
|
||||
server.ipv4 = this.normalizeBotAdminApiUrl(data.ipv4);
|
||||
}
|
||||
|
||||
if (data.port !== undefined) {
|
||||
if (!Number.isFinite(data.port) || data.port < 0) {
|
||||
throw new Error("Port must be a finite non-negative number");
|
||||
}
|
||||
|
||||
server.port = data.port;
|
||||
}
|
||||
|
||||
if (data.maxBots !== undefined) {
|
||||
if (!Number.isFinite(data.maxBots) || data.maxBots < 0) {
|
||||
throw new Error("Max bots must be a finite non-negative number");
|
||||
}
|
||||
|
||||
server.maxBots = data.maxBots;
|
||||
}
|
||||
|
||||
if (data.active !== undefined) {
|
||||
server.active = Boolean(data.active);
|
||||
}
|
||||
|
||||
await server.save();
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
public async sysAdminAuditWapServer(data: SysAdminWapServerAuditParams): Promise<SysAdminWapServerAuditResult> {
|
||||
if (!data.serverId) {
|
||||
throw new Error("Server id is required");
|
||||
}
|
||||
|
||||
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
|
||||
throw new Error("Server id must be a valid Mongo ObjectId");
|
||||
}
|
||||
|
||||
const server = await this.servers.serverList.findOne({ _id: data.serverId });
|
||||
|
||||
if (!server) {
|
||||
throw new Error("WAP server not found");
|
||||
}
|
||||
|
||||
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
|
||||
const assignedOrganizations = assignedCompanies.map((company: any) => ({
|
||||
id: String(company.id || company._id),
|
||||
name: company.name,
|
||||
}));
|
||||
const assignedIds = new Set(assignedOrganizations.map((organization) => organization.id));
|
||||
|
||||
const detectedBots = await this.getSysAdminRuntimeContainers(server);
|
||||
const runtimeOrganizationIds = new Set(
|
||||
detectedBots
|
||||
.map((container) => container.organizationId)
|
||||
.filter((organizationId): organizationId is string => Boolean(organizationId)),
|
||||
);
|
||||
|
||||
const validBots = detectedBots.filter(
|
||||
(container) => container.organizationId && assignedIds.has(container.organizationId),
|
||||
);
|
||||
const ghostBots = detectedBots.filter(
|
||||
(container) => !container.organizationId || !assignedIds.has(container.organizationId),
|
||||
);
|
||||
const missingBots = assignedOrganizations.filter(
|
||||
(organization) => !runtimeOrganizationIds.has(organization.id),
|
||||
);
|
||||
const expectedCountBots = assignedOrganizations.length;
|
||||
const storedCountBots = server.countBots ?? 0;
|
||||
const runtimeCountBots = detectedBots.length;
|
||||
|
||||
return {
|
||||
serverId: String(server.id || server._id),
|
||||
assignedOrganizations,
|
||||
detectedBots,
|
||||
validBots,
|
||||
ghostBots,
|
||||
missingBots,
|
||||
expectedCountBots,
|
||||
storedCountBots,
|
||||
runtimeCountBots,
|
||||
countMismatch: storedCountBots !== expectedCountBots || runtimeCountBots !== expectedCountBots,
|
||||
};
|
||||
}
|
||||
|
||||
public async sysAdminRecalculateWapServerCount(
|
||||
data: SysAdminWapServerRecalculateCountParams,
|
||||
): Promise<SysAdminWapServerRecalculateCountResult> {
|
||||
if (!data.serverId) {
|
||||
throw new Error("Server id is required");
|
||||
}
|
||||
|
||||
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
|
||||
throw new Error("Server id must be a valid Mongo ObjectId");
|
||||
}
|
||||
|
||||
const server = await this.servers.serverList.findOne({ _id: data.serverId });
|
||||
|
||||
if (!server) {
|
||||
throw new Error("WAP server not found");
|
||||
}
|
||||
|
||||
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
|
||||
const before = server.countBots;
|
||||
const after = assignedCompanies.length;
|
||||
|
||||
server.countBots = after;
|
||||
await server.save();
|
||||
|
||||
return { serverId: String(server.id || server._id), before, after };
|
||||
}
|
||||
|
||||
public async sysAdminDeleteWapServer(data: SysAdminDeleteWapServerParams): Promise<SysAdminDeleteWapServerResult> {
|
||||
if (!data.serverId) {
|
||||
throw new Error("Server id is required");
|
||||
}
|
||||
|
||||
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
|
||||
throw new Error("Server id must be a valid Mongo ObjectId");
|
||||
}
|
||||
|
||||
const server = await this.servers.serverList.findOne({ _id: data.serverId });
|
||||
|
||||
if (!server) {
|
||||
throw new Error("WAP server not found");
|
||||
}
|
||||
|
||||
if (server.active) {
|
||||
throw new Error("WAP server must be inactive before deletion");
|
||||
}
|
||||
|
||||
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
|
||||
|
||||
if (assignedCompanies.length > 0) {
|
||||
throw new Error(`WAP server cannot be deleted because it has ${assignedCompanies.length} associated bot(s) in DB`);
|
||||
}
|
||||
|
||||
let detectedBots: SysAdminWapContainerDto[];
|
||||
|
||||
try {
|
||||
detectedBots = await this.getSysAdminRuntimeContainers(server);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message ? error.message : "unknown error";
|
||||
|
||||
throw new Error(`WAP server cannot be deleted because bot-admin could not be audited. Verify runtime bots manually before deleting. ${message}`);
|
||||
}
|
||||
|
||||
if (detectedBots.length > 0) {
|
||||
throw new Error(`WAP server cannot be deleted because bot-admin detected ${detectedBots.length} runtime bot(s)`);
|
||||
}
|
||||
|
||||
await this.servers.delete(data.serverId);
|
||||
|
||||
return { serverId: data.serverId, deleted: true };
|
||||
}
|
||||
|
||||
public async sysAdminStartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
|
||||
await this.postSysAdminBotAction(data, "start");
|
||||
}
|
||||
|
||||
public async sysAdminStopBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
|
||||
await this.postSysAdminBotAction(data, "stop");
|
||||
}
|
||||
|
||||
public async sysAdminRestartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
|
||||
await this.postSysAdminBotAction(data, "stop");
|
||||
await this.waitForSysAdminBotStopped(data);
|
||||
|
||||
try {
|
||||
await this.postSysAdminBotAction(data, "start");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message ? error.message : "unknown error";
|
||||
|
||||
throw new Error(`Bot restart stopped the bot, but start failed. The bot may be stopped. ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async sysAdminGetQr(data: SysAdminWapServerOrganizationActionParams): Promise<SysAdminWapServerQrResult> {
|
||||
const { baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
|
||||
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}/qr`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(targetUrl, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: BOT_ADMIN_ACTION_TIMEOUT_MS,
|
||||
});
|
||||
const qr = Buffer.from(response.data, "binary").toString("base64");
|
||||
|
||||
return { serverId: data.serverId, organizationId: data.organizationId, qr };
|
||||
} catch (error) {
|
||||
throw this.createBotAdminActionError(error, targetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public async sysAdminDeleteBot(
|
||||
data: SysAdminWapServerOrganizationActionParams,
|
||||
): Promise<SysAdminWapServerDeleteBotResult> {
|
||||
const { server, baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
|
||||
const before = server.countBots ?? 0;
|
||||
|
||||
await this.deleteSysAdminRuntimeBot(baseUrl, encodedOrganizationId);
|
||||
|
||||
// Deleting the runtime bot intentionally keeps the DB assignment; detach is a separate sysadmin action.
|
||||
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
|
||||
const after = assignedCompanies.length;
|
||||
server.countBots = after;
|
||||
await server.save();
|
||||
|
||||
return { serverId: data.serverId, organizationId: data.organizationId, assignmentRemains: true, before, after };
|
||||
}
|
||||
|
||||
public async sysAdminDetachOrganizationServer(
|
||||
data: SysAdminWapServerOrganizationActionParams,
|
||||
): Promise<SysAdminWapServerDetachResult> {
|
||||
const { server, baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
|
||||
const company = await CompaniesManager.companies.findOne({ _id: data.organizationId });
|
||||
|
||||
if (!company) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
if (String(company.wapServerId || "") !== data.serverId) {
|
||||
throw new Error("Organization is not assigned to this WAP server");
|
||||
}
|
||||
|
||||
const before = server.countBots ?? 0;
|
||||
const deleteResult = await this.deleteSysAdminRuntimeBot(baseUrl, encodedOrganizationId, true);
|
||||
|
||||
company.wapServerId = undefined;
|
||||
await company.save();
|
||||
|
||||
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
|
||||
const after = assignedCompanies.length;
|
||||
server.countBots = after;
|
||||
await server.save();
|
||||
|
||||
return { serverId: data.serverId, organizationId: data.organizationId, ...deleteResult, before, after };
|
||||
}
|
||||
}
|
||||
|
||||
const WapServerList = new WapServerManager();
|
||||
|
||||
Reference in New Issue
Block a user