Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54a0be4836 | ||
|
|
ea283dd490 | ||
|
|
0d67a4eb25 | ||
|
|
f93065a377 | ||
|
|
91f6cd1aa2 | ||
|
|
e3f585222a | ||
|
|
e8e771aaf2 | ||
|
|
1a89020243 | ||
|
|
e92a3d364f | ||
|
|
1a714f6deb | ||
|
|
c4c470c073 | ||
|
|
694cc55eaa | ||
|
|
3a5cc191fe | ||
|
|
817af0e9ba | ||
|
|
3a4f1eed68 | ||
|
|
c1154a58d5 |
@@ -15,11 +15,13 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -28,6 +30,48 @@ jobs:
|
||||
- name: Clean dist
|
||||
run: rm -f dist/*.exe
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
tag = os.environ["BUILD_VERSION"]
|
||||
|
||||
try:
|
||||
previous_tag = subprocess.check_output(
|
||||
["git", "describe", "--tags", "--abbrev=0", f"{tag}^"],
|
||||
text=True,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
previous_tag = ""
|
||||
|
||||
log_range = f"{previous_tag}..{tag}" if previous_tag else tag
|
||||
subjects = subprocess.check_output(
|
||||
["git", "log", "--pretty=format:%s", log_range],
|
||||
text=True,
|
||||
).splitlines()
|
||||
|
||||
notes = []
|
||||
for subject in subjects:
|
||||
subject = subject.strip()
|
||||
if not subject:
|
||||
continue
|
||||
if subject.startswith("chore: bump version"):
|
||||
continue
|
||||
if subject.lower() == "no message":
|
||||
continue
|
||||
notes.append(f"- {subject}")
|
||||
|
||||
if not notes:
|
||||
notes = ["- Release build and version bump."]
|
||||
|
||||
body = "## What\'s new\n\n" + "\n".join(notes) + "\n"
|
||||
pathlib.Path("release-notes.md").write_text(body, encoding="utf-8")
|
||||
print(body)
|
||||
PY
|
||||
|
||||
- name: Build Windows executable
|
||||
run: npm run build:win
|
||||
|
||||
@@ -36,13 +80,13 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: gSheets-Rate-Assistant ${{ github.ref_name }}
|
||||
body_path: release-notes.md
|
||||
files: dist/*.exe
|
||||
|
||||
- name: Mirror release to GitHub
|
||||
if: ${{ env.GITHUB_RELEASE_REPO != '' && env.GITHUB_TOKEN != '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_RELEASE_REPO: ${{ secrets.GITHUB_RELEASE_REPO }}
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}
|
||||
RELEASE_GITHUB_REPO: ${{ secrets.RELEASE_GITHUB_REPO }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
@@ -52,11 +96,18 @@ jobs:
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repository = os.environ["GITHUB_RELEASE_REPO"]
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
repository = os.environ.get("RELEASE_GITHUB_REPO", "")
|
||||
|
||||
if not token or not repository:
|
||||
print("Skipping GitHub mirror because RELEASE_GITHUB_REPO or RELEASE_GITHUB_TOKEN is missing.")
|
||||
raise SystemExit(0)
|
||||
|
||||
tag = os.environ["BUILD_VERSION"]
|
||||
release_name = f"gSheets-Rate-Assistant {tag}"
|
||||
asset_path = next((os.path.join("dist", name) for name in os.listdir("dist") if name.endswith(".exe")), None)
|
||||
with open("release-notes.md", "r", encoding="utf-8") as notes_file:
|
||||
release_notes = notes_file.read()
|
||||
|
||||
if asset_path is None:
|
||||
raise SystemExit("No Windows executable found in dist/")
|
||||
@@ -96,6 +147,7 @@ jobs:
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"name": release_name,
|
||||
"body": release_notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
"generate_release_notes": False,
|
||||
|
||||
@@ -10,6 +10,7 @@ const stringifyModule = require('./node_modules/csv-stringify/dist/cjs/sync.cjs'
|
||||
const stringify = typeof stringifyModule === 'function'
|
||||
? stringifyModule
|
||||
: stringifyModule.default || stringifyModule.stringify || stringifyModule;
|
||||
const {version: packageVersion} = require('./package.json');
|
||||
const {loadConfig, resolveOutputPath} = require('./lib/config');
|
||||
const {buildThrottle, applyRateLimitBuffer, getTotalRateLimitPerMinute} = require('./lib/rate');
|
||||
const {runOnboarding} = require('./lib/onboarding');
|
||||
@@ -336,7 +337,7 @@ async function scheduleRuns(config) {
|
||||
throw new Error('No sheets configured to pull in config.json');
|
||||
}
|
||||
|
||||
console.log('Google Sheets Rate Assistant');
|
||||
console.log(`Google Sheets Rate Assistant v${packageVersion}`);
|
||||
|
||||
const firstApiKey = getFirstApiKey(config);
|
||||
const titleMap = {};
|
||||
|
||||
+132
-27
@@ -1,5 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const {applyRateLimitBuffer} = require('./rate');
|
||||
const {fetchSpreadsheetValuesBatch} = require('./sheets');
|
||||
|
||||
@@ -24,6 +26,128 @@ function resolveConfigPath(config, candidatePath) {
|
||||
return path.resolve(getConfigBaseDir(config), candidatePath);
|
||||
}
|
||||
|
||||
function base64UrlEncode(value) {
|
||||
return Buffer.from(value)
|
||||
.toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function createServiceAccountAuthClient(credentials) {
|
||||
const scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
|
||||
const tokenUri = credentials.token_uri || 'https://oauth2.googleapis.com/token';
|
||||
const tokenCache = {accessToken: '', expiryDate: 0};
|
||||
|
||||
async function requestAccessToken() {
|
||||
if (!credentials.client_email || !credentials.private_key) {
|
||||
throw new Error('Service account credentials must include client_email and private_key');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = {alg: 'RS256', typ: 'JWT'};
|
||||
const payload = {
|
||||
iss: credentials.client_email,
|
||||
scope: scopes.join(' '),
|
||||
aud: tokenUri,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
};
|
||||
const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
|
||||
const signer = crypto.createSign('RSA-SHA256');
|
||||
signer.update(unsignedToken);
|
||||
signer.end();
|
||||
const signature = signer.sign(credentials.private_key);
|
||||
const assertion = `${unsignedToken}.${base64UrlEncode(signature)}`;
|
||||
const body = `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${encodeURIComponent(assertion)}`;
|
||||
|
||||
const tokenUrl = new URL(tokenUri);
|
||||
const requestOptions = {
|
||||
protocol: tokenUrl.protocol,
|
||||
hostname: tokenUrl.hostname,
|
||||
port: tokenUrl.port || 443,
|
||||
method: 'POST',
|
||||
path: `${tokenUrl.pathname}${tokenUrl.search}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
};
|
||||
|
||||
const responseBody = await new Promise((resolve, reject) => {
|
||||
const request = https.request(requestOptions, response => {
|
||||
let responseText = '';
|
||||
response.setEncoding('utf8');
|
||||
|
||||
response.on('data', chunk => {
|
||||
responseText += chunk;
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve(responseText);
|
||||
return;
|
||||
}
|
||||
|
||||
let message = `Token request failed with status ${response.statusCode}`;
|
||||
try {
|
||||
const parsed = JSON.parse(responseText);
|
||||
if (parsed && parsed.error && parsed.error_description) {
|
||||
message = `${parsed.error}: ${parsed.error_description}`;
|
||||
} else if (parsed && parsed.error && parsed.error.message) {
|
||||
message = parsed.error.message;
|
||||
}
|
||||
} catch (_) {
|
||||
if (responseText) {
|
||||
message = responseText;
|
||||
}
|
||||
}
|
||||
|
||||
reject(new Error(message));
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.write(body);
|
||||
request.end();
|
||||
});
|
||||
|
||||
const tokenData = JSON.parse(responseBody);
|
||||
if (!tokenData.access_token) {
|
||||
throw new Error('Token response did not include access_token');
|
||||
}
|
||||
|
||||
tokenCache.accessToken = tokenData.access_token;
|
||||
tokenCache.expiryDate = Date.now() + Math.max(0, (tokenData.expires_in || 3600) - 60) * 1000;
|
||||
return tokenData.access_token;
|
||||
}
|
||||
|
||||
return {
|
||||
scopes,
|
||||
async getAccessToken() {
|
||||
if (tokenCache.accessToken && Date.now() < tokenCache.expiryDate) {
|
||||
return {token: tokenCache.accessToken, expiry_date: tokenCache.expiryDate};
|
||||
}
|
||||
|
||||
const accessToken = await requestAccessToken();
|
||||
return {token: accessToken, expiry_date: tokenCache.expiryDate};
|
||||
},
|
||||
async getRequestHeaders() {
|
||||
const token = await this.getAccessToken();
|
||||
return {Authorization: `Bearer ${token.token}`};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readServiceAccountCredentials(resolvedPath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(resolvedPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to read/parse service account credentials at ${resolvedPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function loadApiKeys(config) {
|
||||
const apiKeys = [];
|
||||
const defaultRateLimit = config.rateLimitPerMinute || 50;
|
||||
@@ -85,21 +209,8 @@ function createServiceAccountBatchFetcher(config) {
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Service account credentials not found: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to read/parse service account credentials at ${resolvedPath}: ${e.message}`);
|
||||
}
|
||||
|
||||
let authClient;
|
||||
try {
|
||||
authClient = google.auth.fromJSON(credentials);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to initialize auth client from credentials at ${resolvedPath}: ${e.message}`);
|
||||
}
|
||||
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
|
||||
const credentials = readServiceAccountCredentials(resolvedPath);
|
||||
const authClient = createServiceAccountAuthClient(credentials);
|
||||
|
||||
const bufferedRateLimit = applyRateLimitBuffer(rateLimitPerMinute);
|
||||
const interval = Math.ceil(60000 / bufferedRateLimit);
|
||||
@@ -222,11 +333,8 @@ function getFirstServiceAccountAuthClient(config) {
|
||||
if (!fs.existsSync(resolvedPath)) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(resolvedPath, 'utf8');
|
||||
const credentials = JSON.parse(raw);
|
||||
const authClient = google.auth.fromJSON(credentials);
|
||||
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
|
||||
return authClient;
|
||||
const credentials = readServiceAccountCredentials(resolvedPath);
|
||||
return createServiceAccountAuthClient(credentials);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load first service account from ${resolvedPath}: ${e.message}`);
|
||||
return null;
|
||||
@@ -242,11 +350,8 @@ function loadAuth(config) {
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Credentials file not found: ${resolvedPath}`);
|
||||
}
|
||||
const raw = fs.readFileSync(resolvedPath, 'utf8');
|
||||
const credentials = JSON.parse(raw);
|
||||
const authClient = google.auth.fromJSON(credentials);
|
||||
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
|
||||
return authClient;
|
||||
const credentials = readServiceAccountCredentials(resolvedPath);
|
||||
return createServiceAccountAuthClient(credentials);
|
||||
}
|
||||
|
||||
return getFirstServiceAccountAuthClient(config);
|
||||
@@ -258,6 +363,6 @@ module.exports = {
|
||||
createServiceAccountBatchFetcher,
|
||||
createApiKeyBatchFetcher,
|
||||
getFirstApiKey,
|
||||
getFirstServiceAccountAuthClient
|
||||
, loadAuth
|
||||
getFirstServiceAccountAuthClient,
|
||||
loadAuth,
|
||||
};
|
||||
|
||||
+10
-2
@@ -1,6 +1,14 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let isSingleExecutableApp = false;
|
||||
try {
|
||||
const sea = require('node:sea');
|
||||
isSingleExecutableApp = typeof sea.isSea === 'function' && sea.isSea();
|
||||
} catch {
|
||||
isSingleExecutableApp = false;
|
||||
}
|
||||
|
||||
const CONFIG_FILE_NAME = 'config.json';
|
||||
const DEFAULT_CONFIG_TEMPLATE = JSON.stringify({
|
||||
apiKeys: [
|
||||
@@ -56,12 +64,12 @@ const DEFAULT_CONFIG_TEMPLATE = JSON.stringify({
|
||||
}, null, 2) + '\n';
|
||||
|
||||
function getDefaultConfigPath() {
|
||||
const packagedApp = Boolean(process.pkg);
|
||||
const packagedApp = isSingleExecutableApp;
|
||||
return path.resolve(packagedApp ? path.dirname(process.execPath) : process.cwd(), CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
function getConfigPathCandidates() {
|
||||
const packagedApp = Boolean(process.pkg);
|
||||
const packagedApp = isSingleExecutableApp;
|
||||
const externalDefault = getDefaultConfigPath();
|
||||
|
||||
return [
|
||||
|
||||
+31
-29
@@ -45,35 +45,37 @@ function extractErrorMessage(data) {
|
||||
function requestJson(urlString, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(urlString);
|
||||
const request = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: Object.assign({Accept: 'application/json'}, headers),
|
||||
},
|
||||
response => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
const requestOptions = {
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
method: 'GET',
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers: Object.assign({Accept: 'application/json'}, headers),
|
||||
};
|
||||
|
||||
response.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
const request = https.request(requestOptions, response => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
|
||||
response.on('end', () => {
|
||||
const data = parseResponseBody(body);
|
||||
response.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve({status: response.statusCode, headers: response.headers, data});
|
||||
return;
|
||||
}
|
||||
response.on('end', () => {
|
||||
const data = parseResponseBody(body);
|
||||
|
||||
const message = extractErrorMessage(data) || `Request failed with status ${response.statusCode}`;
|
||||
const error = new Error(message);
|
||||
error.response = {status: response.statusCode, headers: response.headers, data};
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve({status: response.statusCode, headers: response.headers, data});
|
||||
return;
|
||||
}
|
||||
|
||||
const message = extractErrorMessage(data) || `Request failed with status ${response.statusCode}`;
|
||||
const error = new Error(message);
|
||||
error.response = {status: response.statusCode, headers: response.headers, data};
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
@@ -96,15 +98,15 @@ async function requestSheetsJson(pathname, context, queryParams = {}) {
|
||||
const headers = {};
|
||||
|
||||
if (context && context.authClient) {
|
||||
if (typeof context.authClient.getRequestHeaders === 'function') {
|
||||
const authHeaders = await context.authClient.getRequestHeaders(url.toString());
|
||||
Object.assign(headers, authHeaders || {});
|
||||
} else if (typeof context.authClient.getAccessToken === 'function') {
|
||||
if (typeof context.authClient.getAccessToken === 'function') {
|
||||
const token = await context.authClient.getAccessToken();
|
||||
const accessToken = token && typeof token === 'object' ? token.token || token.access_token : token;
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
} else if (typeof context.authClient.getRequestHeaders === 'function') {
|
||||
const authHeaders = await context.authClient.getRequestHeaders(url.toString());
|
||||
Object.assign(headers, authHeaders || {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+460
-1523
File diff suppressed because it is too large
Load Diff
+2
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "google-sheets-rate-assistant",
|
||||
"version": "2.1.7",
|
||||
"version": "2.1.11",
|
||||
"description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.",
|
||||
"main": "index.js",
|
||||
"bin": "index.js",
|
||||
@@ -14,11 +14,6 @@
|
||||
"p-throttle": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pkg": "^5.8.1"
|
||||
},
|
||||
"pkg": {
|
||||
"assets": [
|
||||
"node_modules/csv-stringify/dist/cjs/sync.cjs"
|
||||
]
|
||||
"esbuild": "^0.25.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ This Node.js application pulls configured Google Sheets documents and exports th
|
||||
|
||||
## Setup
|
||||
|
||||
Download the latest version from the [Releases tab](./releases) and run it.
|
||||
Download the latest version from the Releases and run it.
|
||||
|
||||
1. If `config.json` does not exist, the app will ask whether you want a wizard or a starter config file.
|
||||
2. If you choose the config-file path instead of the wizard, configure authentication in `config.json` — choose one of:
|
||||
@@ -63,17 +63,9 @@ If `config.json` is missing, the app creates a starter file next to the executab
|
||||
|
||||
If you choose the wizard, it will ask for the auth method, credential count, document count, and sheet names. It will not ask for output directories or file names.
|
||||
|
||||
## Windows Packaging
|
||||
## Build Note
|
||||
|
||||
To build a Windows executable from source, run:
|
||||
|
||||
```bash
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
That creates `dist/gSheets-Rate-Assistant-v2.1.7.exe` using the current version in the filename. For distribution, ship the exe alongside a `config.json` and any service account credential files, or set `GSA_CONFIG` to point at a different config file.
|
||||
|
||||
Gitea now builds only on version tags like `v1.1.1` through the workflow in `.gitea/workflows/build.yml`.
|
||||
The Windows packaging script in `scripts/build-win.js` now builds a single executable using Node 26's SEA workflow.
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
|
||||
+28
-2
@@ -1,6 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {exec} = require('pkg');
|
||||
const {execFileSync} = require('child_process');
|
||||
const esbuild = require('esbuild');
|
||||
const {version: packageVersion} = require('../package.json');
|
||||
|
||||
function normalizeBuildVersion(value) {
|
||||
@@ -14,9 +16,33 @@ async function main() {
|
||||
fs.mkdirSync(distDir, {recursive: true});
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsa-sea-'));
|
||||
const bundlePath = path.join(tempDir, 'index.cjs');
|
||||
const seaBlobPath = path.join(tempDir, 'sea-prep.blob');
|
||||
const seaConfigPath = path.join(tempDir, 'sea-config.json');
|
||||
const buildVersion = normalizeBuildVersion(process.env.BUILD_VERSION);
|
||||
const outputFile = path.join(distDir, `gSheets-Rate-Assistant-${buildVersion}.exe`);
|
||||
await exec(['.', '--targets', 'latest-win-x64', '--output', outputFile]);
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve(__dirname, '..', 'index.js')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node26',
|
||||
outfile: bundlePath,
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
fs.writeFileSync(seaConfigPath, JSON.stringify({
|
||||
main: bundlePath,
|
||||
output: outputFile,
|
||||
mainFormat: 'commonjs',
|
||||
disableExperimentalSEAWarning: true,
|
||||
useCodeCache: false,
|
||||
useSnapshot: false,
|
||||
}, null, 2));
|
||||
|
||||
execFileSync(process.execPath, ['--build-sea', seaConfigPath], {stdio: 'inherit'});
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
|
||||
Reference in New Issue
Block a user