下沉浏览器会话基础运行时逻辑
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
// @ts-check
|
||||
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { resolveDefaultViewport } from './session-browser-config.js'
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..')
|
||||
|
||||
export const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions')
|
||||
export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
|
||||
export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
|
||||
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
|
||||
export const DEFAULT_VIEWPORT = resolveDefaultViewport()
|
||||
export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
||||
@@ -0,0 +1,90 @@
|
||||
// @ts-check
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import {
|
||||
buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase,
|
||||
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
|
||||
} from './session-browser-config.js'
|
||||
import { CHROME_PATH } from './session-service-config.js'
|
||||
|
||||
let browserPromise = null
|
||||
|
||||
export class SessionServiceError extends Error {
|
||||
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
|
||||
super(message)
|
||||
this.name = 'SessionServiceError'
|
||||
this.statusCode = statusCode
|
||||
this.errorCode = errorCode
|
||||
}
|
||||
}
|
||||
|
||||
export async function warmupTencentBrowser() {
|
||||
const browserLaunch = resolveBrowserLaunchOptions()
|
||||
|
||||
if (!browserLaunch.prewarm) {
|
||||
return {
|
||||
warmed: false,
|
||||
reason: 'disabled',
|
||||
browserLaunch,
|
||||
}
|
||||
}
|
||||
|
||||
await ensureBrowser()
|
||||
|
||||
return {
|
||||
warmed: true,
|
||||
reason: 'ready',
|
||||
browserLaunch,
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBrowser() {
|
||||
if (!browserPromise) {
|
||||
const launchOptions = resolveBrowserLaunchOptions()
|
||||
browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions))
|
||||
.then((browser) => {
|
||||
browser.on('disconnected', () => {
|
||||
browserPromise = null
|
||||
})
|
||||
return browser
|
||||
})
|
||||
.catch((error) => {
|
||||
browserPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return browserPromise
|
||||
}
|
||||
|
||||
export function resolveBrowserLaunchOptions() {
|
||||
return resolveBrowserLaunchOptionsBase({
|
||||
browserConfig: runtimeConfig.browser,
|
||||
chromePath: CHROME_PATH,
|
||||
})
|
||||
}
|
||||
|
||||
export async function closeBrowserIfIdle({ activeSessionCount = 0 } = {}) {
|
||||
if (activeSessionCount > 0 || !browserPromise) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resolveBrowserLaunchOptions().keepAlive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await browserPromise
|
||||
await browser.close()
|
||||
} catch {
|
||||
// ignore browser close failures
|
||||
} finally {
|
||||
browserPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function buildChromiumLaunchOptions(launchOptions) {
|
||||
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// @ts-check
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
DATA_ROOT,
|
||||
REDEEM_SUCCESS_AUTO_CLOSE_MS,
|
||||
} from './session-service-config.js'
|
||||
import { SessionServiceError } from './session-service-runtime.js'
|
||||
import {
|
||||
resolveInitialQrSessionState,
|
||||
SESSION_TTL_MS,
|
||||
} from './session-state.js'
|
||||
|
||||
const sessions = new Map()
|
||||
|
||||
export async function registerTencentBrowserSession({ loginType, browserContext, page }) {
|
||||
const sessionId = createSessionId()
|
||||
const sessionDir = path.join(DATA_ROOT, sessionId)
|
||||
await fs.mkdir(sessionDir, { recursive: true })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const session = {
|
||||
sessionId,
|
||||
loginType,
|
||||
status: 'created',
|
||||
notice: '浏览器会话已创建',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
autoCloseAt: 0,
|
||||
autoCloseTimer: null,
|
||||
sessionDir,
|
||||
browserContext,
|
||||
page,
|
||||
qrImageBase64: '',
|
||||
qrImagePath: '',
|
||||
qrUpdatedAt: '',
|
||||
lastState: null,
|
||||
lastRedeem: null,
|
||||
lastReview: null,
|
||||
lastError: '',
|
||||
presentationSyncAttempts: 0,
|
||||
}
|
||||
|
||||
sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
|
||||
export function listActiveTencentBrowserSessions() {
|
||||
return [...sessions.values()]
|
||||
}
|
||||
|
||||
export async function getRequiredSession(sessionId) {
|
||||
const key = String(sessionId || '').trim()
|
||||
|
||||
if (!key) {
|
||||
throw new SessionServiceError('缺少 sessionId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_session_id',
|
||||
})
|
||||
}
|
||||
|
||||
const session = sessions.get(key)
|
||||
|
||||
if (!session) {
|
||||
throw new SessionServiceError('浏览器会话不存在或已过期', {
|
||||
statusCode: 404,
|
||||
errorCode: 'session_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export function applyInitialQrSessionState(session) {
|
||||
resetTencentSessionAutoClose(session)
|
||||
Object.assign(session, resolveInitialQrSessionState(session.loginType))
|
||||
}
|
||||
|
||||
export function armRedeemedSessionAutoClose(session, { closeSession }) {
|
||||
resetTencentSessionAutoClose(session)
|
||||
|
||||
const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS
|
||||
session.autoCloseAt = autoCloseAt
|
||||
session.expiresAt = autoCloseAt
|
||||
session.autoCloseTimer = setTimeout(() => {
|
||||
void closeSession(session, { markClosed: true }).catch(() => {
|
||||
// ignore delayed auto-close failures
|
||||
})
|
||||
}, REDEEM_SUCCESS_AUTO_CLOSE_MS)
|
||||
}
|
||||
|
||||
export function resetTencentSessionAutoClose(session) {
|
||||
if (session?.autoCloseTimer) {
|
||||
clearTimeout(session.autoCloseTimer)
|
||||
session.autoCloseTimer = null
|
||||
}
|
||||
|
||||
if (session) {
|
||||
session.autoCloseAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupExpiredTencentBrowserSessions({ closeSession }) {
|
||||
const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now())
|
||||
|
||||
await Promise.all(expired.map((session) => closeSession(session, { markClosed: false })))
|
||||
}
|
||||
|
||||
export async function closeSession(
|
||||
session,
|
||||
{
|
||||
markClosed,
|
||||
persistSessionState,
|
||||
closeBrowserIfIdle,
|
||||
},
|
||||
) {
|
||||
resetTencentSessionAutoClose(session)
|
||||
sessions.delete(session.sessionId)
|
||||
|
||||
try {
|
||||
await session.browserContext?.close()
|
||||
} catch {
|
||||
// ignore browser context close failures
|
||||
}
|
||||
|
||||
session.browserContextClosed = true
|
||||
session.updatedAt = new Date().toISOString()
|
||||
|
||||
if (markClosed) {
|
||||
session.status = 'closed'
|
||||
session.notice = '浏览器会话已关闭'
|
||||
await persistSessionState(session)
|
||||
}
|
||||
|
||||
await closeBrowserIfIdle()
|
||||
}
|
||||
|
||||
export async function persistSessionState(session, { buildSessionPayload }) {
|
||||
await fs.mkdir(session.sessionDir, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(session.sessionDir, 'session.json'),
|
||||
JSON.stringify(buildSessionPayload(session), null, 2),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
function createSessionId() {
|
||||
let sessionId = ''
|
||||
|
||||
do {
|
||||
sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}`
|
||||
} while (sessions.has(sessionId))
|
||||
|
||||
return sessionId
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import {
|
||||
buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase,
|
||||
getLoginTypeLabel,
|
||||
normalizeLoginType,
|
||||
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
|
||||
resolveDefaultViewport,
|
||||
} from './session-browser-config.js'
|
||||
import { logBrowserSessionDebug } from './session-login-shared.js'
|
||||
import {
|
||||
@@ -58,63 +47,41 @@ import {
|
||||
resolveSessionNotice,
|
||||
resolveSessionStatus,
|
||||
resolveTencentSessionExpiresAt,
|
||||
SESSION_TTL_MS,
|
||||
} from './session-state.js'
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..')
|
||||
const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions')
|
||||
const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
|
||||
const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
|
||||
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
|
||||
const DEFAULT_VIEWPORT = resolveDefaultViewport()
|
||||
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
||||
|
||||
const sessions = new Map()
|
||||
|
||||
let browserPromise = null
|
||||
import {
|
||||
ACTIVITY_URL,
|
||||
DEFAULT_VIEWPORT,
|
||||
REDEEM_SUCCESS_AUTO_CLOSE_MS,
|
||||
SESSION_DEBUG_ENABLED,
|
||||
} from './session-service-config.js'
|
||||
import {
|
||||
closeBrowserIfIdle,
|
||||
ensureBrowser,
|
||||
resolveBrowserLaunchOptions,
|
||||
SessionServiceError,
|
||||
warmupTencentBrowser,
|
||||
} from './session-service-runtime.js'
|
||||
import {
|
||||
applyInitialQrSessionState,
|
||||
armRedeemedSessionAutoClose,
|
||||
cleanupExpiredTencentBrowserSessions,
|
||||
closeSession as closeStoredSession,
|
||||
getRequiredSession,
|
||||
listActiveTencentBrowserSessions,
|
||||
persistSessionState as persistStoredSessionState,
|
||||
registerTencentBrowserSession,
|
||||
resetTencentSessionAutoClose,
|
||||
} from './session-service-store.js'
|
||||
|
||||
export { resolveTencentSessionExpiresAt }
|
||||
|
||||
class SessionServiceError extends Error {
|
||||
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
|
||||
super(message)
|
||||
this.name = 'SessionServiceError'
|
||||
this.statusCode = statusCode
|
||||
this.errorCode = errorCode
|
||||
}
|
||||
}
|
||||
|
||||
export async function warmupTencentBrowser() {
|
||||
const browserLaunch = resolveBrowserLaunchOptions()
|
||||
|
||||
if (!browserLaunch.prewarm) {
|
||||
return {
|
||||
warmed: false,
|
||||
reason: 'disabled',
|
||||
browserLaunch,
|
||||
}
|
||||
}
|
||||
|
||||
await ensureBrowser()
|
||||
|
||||
return {
|
||||
warmed: true,
|
||||
reason: 'ready',
|
||||
browserLaunch,
|
||||
}
|
||||
}
|
||||
export { REDEEM_SUCCESS_AUTO_CLOSE_MS, warmupTencentBrowser }
|
||||
|
||||
export async function createTencentBrowserSession(payload = {}) {
|
||||
await cleanupExpiredTencentBrowserSessions()
|
||||
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
|
||||
|
||||
const loginType = normalizeLoginType(payload.loginType || payload.qrType)
|
||||
|
||||
const browser = await ensureBrowser()
|
||||
const sessionId = createSessionId()
|
||||
const sessionDir = path.join(DATA_ROOT, sessionId)
|
||||
await fs.mkdir(sessionDir, { recursive: true })
|
||||
|
||||
const browserContext = await browser.newContext({
|
||||
viewport: DEFAULT_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
@@ -123,30 +90,12 @@ export async function createTencentBrowserSession(payload = {}) {
|
||||
const page = await browserContext.newPage()
|
||||
page.setDefaultTimeout(20_000)
|
||||
|
||||
const session = {
|
||||
sessionId,
|
||||
const session = await registerTencentBrowserSession({
|
||||
loginType,
|
||||
status: 'created',
|
||||
notice: '浏览器会话已创建',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
autoCloseAt: 0,
|
||||
autoCloseTimer: null,
|
||||
sessionDir,
|
||||
browserContext,
|
||||
page,
|
||||
qrImageBase64: '',
|
||||
qrImagePath: '',
|
||||
qrUpdatedAt: '',
|
||||
lastState: null,
|
||||
lastRedeem: null,
|
||||
lastReview: null,
|
||||
lastError: '',
|
||||
presentationSyncAttempts: 0,
|
||||
}
|
||||
|
||||
sessions.set(sessionId, session)
|
||||
})
|
||||
const sessionId = session.sessionId
|
||||
|
||||
try {
|
||||
await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' })
|
||||
@@ -180,7 +129,7 @@ export async function getTencentBrowserSessionSummary(sessionId) {
|
||||
}
|
||||
|
||||
export async function reloadTencentBrowserSession(sessionId) {
|
||||
await cleanupExpiredTencentBrowserSessions()
|
||||
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
|
||||
|
||||
const session = await getRequiredSession(sessionId)
|
||||
|
||||
@@ -222,7 +171,7 @@ export async function reloadTencentBrowserSession(sessionId) {
|
||||
}
|
||||
|
||||
export async function refreshTencentBrowserSession(sessionId, { includeQrImage = true } = {}) {
|
||||
await cleanupExpiredTencentBrowserSessions()
|
||||
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
|
||||
|
||||
const session = await getRequiredSession(sessionId)
|
||||
|
||||
@@ -349,7 +298,7 @@ export async function redeemTencentBrowserSession(sessionId, payload = {}) {
|
||||
activityUrl: ACTIVITY_URL,
|
||||
})
|
||||
|
||||
armRedeemedSessionAutoClose(session)
|
||||
armRedeemedSessionAutoClose(session, { closeSession: closeManagedSession })
|
||||
await persistSessionState(session)
|
||||
|
||||
return {
|
||||
@@ -360,7 +309,7 @@ export async function redeemTencentBrowserSession(sessionId, payload = {}) {
|
||||
|
||||
export async function closeTencentBrowserSession(sessionId) {
|
||||
const session = await getRequiredSession(sessionId)
|
||||
await closeSession(session, { markClosed: true })
|
||||
await closeManagedSession(session, { markClosed: true })
|
||||
return {
|
||||
sessionId,
|
||||
closed: true,
|
||||
@@ -368,9 +317,9 @@ export async function closeTencentBrowserSession(sessionId) {
|
||||
}
|
||||
|
||||
export async function closeAllTencentBrowserSessions({ markClosed = true } = {}) {
|
||||
const activeSessions = [...sessions.values()]
|
||||
const activeSessions = listActiveTencentBrowserSessions()
|
||||
|
||||
await Promise.all(activeSessions.map((session) => closeSession(session, { markClosed })))
|
||||
await Promise.all(activeSessions.map((session) => closeManagedSession(session, { markClosed })))
|
||||
|
||||
return {
|
||||
closedCount: activeSessions.length,
|
||||
@@ -405,132 +354,8 @@ export async function getTencentBrowserSessionReviewScreenshotPath(sessionId) {
|
||||
return screenshotPath
|
||||
}
|
||||
|
||||
async function ensureBrowser() {
|
||||
if (!browserPromise) {
|
||||
const launchOptions = resolveBrowserLaunchOptions()
|
||||
browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions))
|
||||
.then((browser) => {
|
||||
browser.on('disconnected', () => {
|
||||
browserPromise = null
|
||||
})
|
||||
return browser
|
||||
})
|
||||
.catch((error) => {
|
||||
browserPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return browserPromise
|
||||
}
|
||||
|
||||
function resolveBrowserLaunchOptions() {
|
||||
return resolveBrowserLaunchOptionsBase({
|
||||
browserConfig: runtimeConfig.browser,
|
||||
chromePath: CHROME_PATH,
|
||||
})
|
||||
}
|
||||
|
||||
function buildChromiumLaunchOptions(launchOptions) {
|
||||
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
|
||||
}
|
||||
|
||||
function createSessionId() {
|
||||
let sessionId = ''
|
||||
|
||||
do {
|
||||
sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}`
|
||||
} while (sessions.has(sessionId))
|
||||
|
||||
return sessionId
|
||||
}
|
||||
|
||||
async function getRequiredSession(sessionId) {
|
||||
const key = String(sessionId || '').trim()
|
||||
|
||||
if (!key) {
|
||||
throw new SessionServiceError('缺少 sessionId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_session_id',
|
||||
})
|
||||
}
|
||||
|
||||
const session = sessions.get(key)
|
||||
|
||||
if (!session) {
|
||||
throw new SessionServiceError('浏览器会话不存在或已过期', {
|
||||
statusCode: 404,
|
||||
errorCode: 'session_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
function applyInitialQrSessionState(session) {
|
||||
resetTencentSessionAutoClose(session)
|
||||
Object.assign(session, resolveInitialQrSessionState(session.loginType))
|
||||
}
|
||||
|
||||
function armRedeemedSessionAutoClose(session) {
|
||||
resetTencentSessionAutoClose(session)
|
||||
|
||||
const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS
|
||||
session.autoCloseAt = autoCloseAt
|
||||
session.expiresAt = autoCloseAt
|
||||
session.autoCloseTimer = setTimeout(() => {
|
||||
void closeSession(session, { markClosed: true }).catch(() => {
|
||||
// ignore delayed auto-close failures
|
||||
})
|
||||
}, REDEEM_SUCCESS_AUTO_CLOSE_MS)
|
||||
}
|
||||
|
||||
function resetTencentSessionAutoClose(session) {
|
||||
if (session?.autoCloseTimer) {
|
||||
clearTimeout(session.autoCloseTimer)
|
||||
session.autoCloseTimer = null
|
||||
}
|
||||
|
||||
if (session) {
|
||||
session.autoCloseAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupExpiredTencentBrowserSessions() {
|
||||
const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now())
|
||||
|
||||
await Promise.all(expired.map((session) => closeSession(session, { markClosed: false })))
|
||||
}
|
||||
|
||||
async function closeSession(session, { markClosed }) {
|
||||
resetTencentSessionAutoClose(session)
|
||||
sessions.delete(session.sessionId)
|
||||
|
||||
try {
|
||||
await session.browserContext?.close()
|
||||
} catch {
|
||||
// ignore browser context close failures
|
||||
}
|
||||
|
||||
session.browserContextClosed = true
|
||||
session.updatedAt = new Date().toISOString()
|
||||
|
||||
if (markClosed) {
|
||||
session.status = 'closed'
|
||||
session.notice = '浏览器会话已关闭'
|
||||
await persistSessionState(session)
|
||||
}
|
||||
|
||||
await closeBrowserIfIdle()
|
||||
}
|
||||
|
||||
async function persistSessionState(session) {
|
||||
await fs.mkdir(session.sessionDir, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(session.sessionDir, 'session.json'),
|
||||
JSON.stringify(buildSessionPayload(session), null, 2),
|
||||
'utf8',
|
||||
)
|
||||
await persistStoredSessionState(session, { buildSessionPayload })
|
||||
}
|
||||
|
||||
function buildSessionPayload(session, { includeQrImage = true } = {}) {
|
||||
@@ -546,21 +371,12 @@ async function fillRedeemCodeInBrowser(page, code) {
|
||||
await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady })
|
||||
}
|
||||
|
||||
async function closeBrowserIfIdle() {
|
||||
if (sessions.size > 0 || !browserPromise) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resolveBrowserLaunchOptions().keepAlive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await browserPromise
|
||||
await browser.close()
|
||||
} catch {
|
||||
// ignore browser close failures
|
||||
} finally {
|
||||
browserPromise = null
|
||||
}
|
||||
async function closeManagedSession(session, { markClosed }) {
|
||||
await closeStoredSession(session, {
|
||||
markClosed,
|
||||
persistSessionState,
|
||||
closeBrowserIfIdle: () => closeBrowserIfIdle({
|
||||
activeSessionCount: listActiveTencentBrowserSessions().length,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user