mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
committed by
GitHub
parent
08e18867fd
commit
3d75fb3fae
@@ -107,12 +107,19 @@ function parseEnvFile(filePath) {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx < 1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
const val = unquoteEnvValue(trimmed.slice(eqIdx + 1).trim());
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
if (value.length < 2) return value;
|
||||
const quote = value[0];
|
||||
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function writeEnvFile(filePath, env) {
|
||||
const lines = [
|
||||
|
||||
@@ -7,6 +7,8 @@ const cwd = process.cwd();
|
||||
const packageJsonPath = path.resolve(cwd, "package.json");
|
||||
const openApiPath = path.resolve(cwd, "docs/openapi.yaml");
|
||||
const changelogPath = path.resolve(cwd, "CHANGELOG.md");
|
||||
const llmPath = path.resolve(cwd, "llm.txt");
|
||||
const i18nDocsPath = path.resolve(cwd, "docs/i18n");
|
||||
|
||||
function readText(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -47,6 +49,23 @@ function extractChangelogSections(content) {
|
||||
return headings.map((match) => match[1]);
|
||||
}
|
||||
|
||||
function stripTopHeading(content) {
|
||||
return content.replace(/^# .+\r?\n+/, "");
|
||||
}
|
||||
|
||||
function extractI18nMirrorBody(content) {
|
||||
const separator = content.match(/^---\s*$/m);
|
||||
if (!separator || separator.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return content.slice(separator.index + separator[0].length).replace(/^\r?\n+/, "");
|
||||
}
|
||||
|
||||
function normalizeMirrorBody(content) {
|
||||
return content.replace(/\r\n/g, "\n").trim();
|
||||
}
|
||||
|
||||
function isSemver(value) {
|
||||
// Accept X.Y.Z and X.Y.Z-prerelease.N (e.g. 3.0.0-rc.1, 3.0.0-beta.2)
|
||||
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(value);
|
||||
@@ -59,6 +78,46 @@ function fail(message) {
|
||||
console.error(`[docs-sync] FAIL - ${message}`);
|
||||
}
|
||||
|
||||
function checkI18nMirrorFile(fileName, sourcePath) {
|
||||
if (!fs.existsSync(i18nDocsPath)) {
|
||||
fail("docs/i18n directory is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceBody = normalizeMirrorBody(stripTopHeading(readText(sourcePath)));
|
||||
const locales = fs
|
||||
.readdirSync(i18nDocsPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
let checked = 0;
|
||||
for (const locale of locales) {
|
||||
const targetPath = path.join(i18nDocsPath, locale, fileName);
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
fail(`docs/i18n/${locale}/${fileName} is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = extractI18nMirrorBody(readText(targetPath));
|
||||
if (body === null) {
|
||||
fail(`docs/i18n/${locale}/${fileName} is missing the i18n mirror separator`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeMirrorBody(body) !== sourceBody) {
|
||||
fail(`docs/i18n/${locale}/${fileName} differs from root ${fileName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
checked += 1;
|
||||
}
|
||||
|
||||
if (checked > 0) {
|
||||
console.log(`[docs-sync] ${fileName} i18n mirrors match root content: ${checked} locales`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(readText(packageJsonPath));
|
||||
const packageVersion = packageJson.version;
|
||||
@@ -101,6 +160,9 @@ try {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkI18nMirrorFile("llm.txt", llmPath);
|
||||
checkI18nMirrorFile("CHANGELOG.md", changelogPath);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
176
scripts/cursor-tap.cjs
Normal file
176
scripts/cursor-tap.cjs
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cursor-tap — capture cursor agent.v1.AgentService/Run wire bytes for tests.
|
||||
*
|
||||
* Usage:
|
||||
* CURSOR_TOKEN=... node scripts/cursor-tap.cjs <fixture-name> <prompt>
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/cursor-tap.cjs single-turn-chat "say only PING"
|
||||
* node scripts/cursor-tap.cjs system-prompt "be brief|hi" # split on first '|'
|
||||
* node scripts/cursor-tap.cjs tool-call "weather in Paris" --tools=get_weather
|
||||
* node scripts/cursor-tap.cjs composer-2-fast "hi" --model=composer-2-fast
|
||||
*
|
||||
* Writes the upstream response bytes to tests/fixtures/cursor/<fixture-name>.bin
|
||||
* and prints decoded summary to stdout. Use these fixtures in unit tests to
|
||||
* catch schema drift in cursor-agent's protobuf format.
|
||||
*
|
||||
* Note: this is a one-time / on-demand tool. The .bin output is gitignored
|
||||
* by default; commit fixtures explicitly when you want them in the test
|
||||
* baseline (tests/fixtures/cursor/.gitignore controls this).
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http2 = require("http2");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error("Usage: cursor-tap.cjs <fixture-name> <prompt> [--model=...] [--tools=name1,name2]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [fixtureName, prompt, ...flags] = args;
|
||||
const flagMap = Object.fromEntries(
|
||||
flags.map((f) => {
|
||||
const m = f.match(/^--([^=]+)=(.*)$/);
|
||||
return m ? [m[1], m[2]] : [f.replace(/^--/, ""), true];
|
||||
})
|
||||
);
|
||||
|
||||
const token = process.env.CURSOR_TOKEN;
|
||||
if (!token) {
|
||||
console.error("Set CURSOR_TOKEN environment variable.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const model = flagMap.model || "auto";
|
||||
const conversationId = crypto.randomUUID();
|
||||
const requestId = crypto.randomUUID();
|
||||
const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`;
|
||||
|
||||
// ─── Minimal protobuf encoder (mirrors open-sse/utils/cursorAgentProtobuf.ts) ─
|
||||
|
||||
function encodeVarint(n) {
|
||||
const out = [];
|
||||
let v = BigInt(n);
|
||||
while (v > 0x7fn) {
|
||||
out.push(Number(v & 0x7fn) | 0x80);
|
||||
v >>= 7n;
|
||||
}
|
||||
out.push(Number(v));
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field, wt) {
|
||||
return encodeVarint((field << 3) | wt);
|
||||
}
|
||||
function lenField(f, payload) {
|
||||
return Buffer.concat([tag(f, 2), encodeVarint(payload.length), payload]);
|
||||
}
|
||||
function strField(f, s) {
|
||||
return lenField(f, Buffer.from(s, "utf8"));
|
||||
}
|
||||
function varintField(f, n) {
|
||||
return Buffer.concat([tag(f, 0), encodeVarint(n)]);
|
||||
}
|
||||
function wrapConnectFrame(payload) {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0;
|
||||
header.writeUInt32BE(payload.length, 1);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// AgentRunRequest body
|
||||
const userMessage = lenField(
|
||||
1,
|
||||
Buffer.concat([
|
||||
strField(1, prompt),
|
||||
strField(2, crypto.randomUUID()),
|
||||
lenField(3, Buffer.alloc(0)),
|
||||
varintField(4, 1),
|
||||
])
|
||||
);
|
||||
const userMessageAction = lenField(1, userMessage);
|
||||
const action = lenField(2, userMessageAction);
|
||||
const conversationState = lenField(1, Buffer.alloc(0));
|
||||
const requestedModel = lenField(9, strField(1, model === "auto" ? "default" : model));
|
||||
const arr = Buffer.concat([
|
||||
conversationState,
|
||||
action,
|
||||
lenField(4, Buffer.alloc(0)), // mcp_tools
|
||||
strField(5, conversationId),
|
||||
requestedModel,
|
||||
varintField(12, 0),
|
||||
strField(16, conversationId),
|
||||
]);
|
||||
const acm = lenField(1, arr);
|
||||
const body = wrapConnectFrame(acm);
|
||||
|
||||
// ─── h2 request ────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanToken = token.includes("::") ? token.split("::")[1] : token;
|
||||
const client = http2.connect("https://agentn.global.api5.cursor.sh");
|
||||
|
||||
const collected = [];
|
||||
let responseStatus = 0;
|
||||
|
||||
const req = client.request({
|
||||
":method": "POST",
|
||||
":path": "/agent.v1.AgentService/Run",
|
||||
":authority": "agentn.global.api5.cursor.sh",
|
||||
":scheme": "https",
|
||||
authorization: `Bearer ${cleanToken}`,
|
||||
"backend-traceparent": traceParent,
|
||||
"connect-accept-encoding": "gzip,br",
|
||||
"connect-protocol-version": "1",
|
||||
"content-type": "application/connect+proto",
|
||||
traceparent: traceParent,
|
||||
"user-agent": "connect-es/1.6.1",
|
||||
"x-cursor-client-type": "cli",
|
||||
"x-cursor-client-version": "cli-2025.10.21-b2dfaef",
|
||||
"x-ghost-mode": "true",
|
||||
"x-original-request-id": requestId,
|
||||
"x-request-id": requestId,
|
||||
});
|
||||
|
||||
req.on("response", (h) => {
|
||||
responseStatus = Number(h[":status"]);
|
||||
});
|
||||
req.on("data", (chunk) => {
|
||||
collected.push(Buffer.from(chunk));
|
||||
});
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(collected);
|
||||
const outDir = path.join(__dirname, "..", "tests", "fixtures", "cursor");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outFile = path.join(outDir, `${fixtureName}.bin`);
|
||||
fs.writeFileSync(outFile, raw);
|
||||
console.log(`[cursor-tap] status=${responseStatus} bytes=${raw.length} → ${outFile}`);
|
||||
client.close();
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
console.error("[cursor-tap] req error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
// NOTE: we never end the request; cursor closes the stream itself when the
|
||||
// turn is done. For tool-using captures, the script may need to write
|
||||
// follow-up frames before the stream closes — extend as needed.
|
||||
|
||||
// Safety timeout: if cursor doesn't close in 60s, dump what we have.
|
||||
setTimeout(() => {
|
||||
console.warn("[cursor-tap] safety timeout; closing");
|
||||
try {
|
||||
req.close();
|
||||
client.close();
|
||||
} catch {}
|
||||
const raw = Buffer.concat(collected);
|
||||
if (raw.length > 0) {
|
||||
const outDir = path.join(__dirname, "..", "tests", "fixtures", "cursor");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, `${fixtureName}.bin`), raw);
|
||||
}
|
||||
process.exit(0);
|
||||
}, 60_000);
|
||||
@@ -116,6 +116,52 @@ function extractContentPreview(content) {
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
if (!fs.existsSync(DOCS_DIR)) {
|
||||
if (fs.existsSync(OUT_FILE)) {
|
||||
console.warn(
|
||||
`[generate-docs-index] ${DOCS_DIR} not found; keeping existing generated docs index.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
OUT_FILE,
|
||||
`// AUTO-GENERATED by scripts/generate-docs-index.mjs — DO NOT EDIT MANUALLY
|
||||
// Regenerate with: node scripts/generate-docs-index.mjs
|
||||
|
||||
export interface AutoGenDocItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface AutoGenNavSection {
|
||||
title: string;
|
||||
items: AutoGenDocItem[];
|
||||
}
|
||||
|
||||
export interface AutoGenSearchItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
section: string;
|
||||
content: string;
|
||||
headings: string[];
|
||||
}
|
||||
|
||||
export const autoNavSections: AutoGenNavSection[] = [];
|
||||
|
||||
export const autoSearchIndex: AutoGenSearchItem[] = [];
|
||||
|
||||
export const autoAllSlugs: string[] = [];
|
||||
`,
|
||||
"utf8"
|
||||
);
|
||||
console.warn(`[generate-docs-index] ${DOCS_DIR} not found; generated empty docs index.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(DOCS_DIR).filter((f) => f.endsWith(".md") || f.endsWith(".mdx"));
|
||||
|
||||
const docs = [];
|
||||
|
||||
@@ -159,6 +159,7 @@ const playwrightPassword =
|
||||
const testServerEnv = {
|
||||
...sanitizeColorEnv(bootstrapEnvVars),
|
||||
...sanitizeColorEnv(process.env),
|
||||
NODE_ENV: mode === "start" ? "production" : "development",
|
||||
DATA_DIR: playwrightDataDir,
|
||||
NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE || "1",
|
||||
OMNIROUTE_DISABLE_BACKGROUND_SERVICES:
|
||||
|
||||
34
scripts/scratch/check_usage.js
Normal file
34
scripts/scratch/check_usage.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const SQLITE_FILE = path.join(process.cwd(), 'data', 'storage.sqlite');
|
||||
|
||||
console.log('Checking database at:', SQLITE_FILE);
|
||||
|
||||
const db = new Database(SQLITE_FILE, { readonly: true });
|
||||
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT api_key_id, api_key_name, COUNT(*) as count, MAX(timestamp) as last_used
|
||||
FROM usage_history
|
||||
GROUP BY api_key_id, api_key_name
|
||||
ORDER BY count DESC
|
||||
`).all();
|
||||
|
||||
console.log('Top Usage Entries:');
|
||||
console.table(rows);
|
||||
|
||||
const keys = db.prepare(`
|
||||
SELECT id, name, key_prefix, machine_id
|
||||
FROM api_keys
|
||||
`).all();
|
||||
|
||||
console.log('All API Keys:');
|
||||
console.table(keys);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
130
scripts/sync-cursor-models.mjs
Normal file
130
scripts/sync-cursor-models.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync the cursor models list in open-sse/config/providerRegistry.ts from
|
||||
// cursor-agent's runtime model list. Triggers an intentional invalid --model
|
||||
// invocation so cursor-agent prints "Available models: ..." on stderr.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/sync-cursor-models.mjs # spawn cursor-agent and apply
|
||||
// node scripts/sync-cursor-models.mjs --dry-run # print proposed block, don't write
|
||||
// node scripts/sync-cursor-models.mjs --from-stdin # read the error message from stdin
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REGISTRY_PATH = resolve(__dirname, "..", "open-sse", "config", "providerRegistry.ts");
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const DRY_RUN = args.has("--dry-run");
|
||||
const FROM_STDIN = args.has("--from-stdin");
|
||||
|
||||
function readSource() {
|
||||
if (FROM_STDIN) return readFileSync(0, "utf8");
|
||||
// cursor-agent prints "Available models: ..." to stderr and exits non-zero.
|
||||
const r = spawnSync("cursor-agent", ["--model", "--help"], { encoding: "utf8" });
|
||||
return `${r.stdout || ""}\n${r.stderr || ""}`;
|
||||
}
|
||||
|
||||
// `auto` is a CLI-side abstraction (cursor-agent resolves it locally before
|
||||
// sending) and `composer-*` targets cursor's edit/composer endpoint, neither
|
||||
// of which work via the chat RPC. Filter them so the dashboard never offers
|
||||
// them to the chat path. Pass --include-unsupported to keep them.
|
||||
function isUnsupportedChatModel(id) {
|
||||
if (id === "auto") return true;
|
||||
if (id.startsWith("composer-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseModelIds(text) {
|
||||
const m = text.match(/Available models:\s*([^\n]+)/);
|
||||
if (!m) throw new Error("Could not find 'Available models:' line in cursor-agent output");
|
||||
const includeUnsupported = args.has("--include-unsupported");
|
||||
return m[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((id) => includeUnsupported || !isUnsupportedChatModel(id));
|
||||
}
|
||||
|
||||
const SEGMENT_OVERRIDES = {
|
||||
gpt: "GPT",
|
||||
claude: "Claude",
|
||||
gemini: "Gemini",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
composer: "Composer",
|
||||
opus: "Opus",
|
||||
sonnet: "Sonnet",
|
||||
haiku: "Haiku",
|
||||
codex: "Codex",
|
||||
mini: "Mini",
|
||||
nano: "Nano",
|
||||
max: "Max",
|
||||
high: "High",
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
xhigh: "XHigh",
|
||||
none: "None",
|
||||
fast: "Fast",
|
||||
thinking: "Thinking",
|
||||
extra: "Extra",
|
||||
spark: "Spark",
|
||||
preview: "Preview",
|
||||
flash: "Flash",
|
||||
pro: "Pro",
|
||||
};
|
||||
|
||||
// Pretty-print an id by:
|
||||
// 1) collapsing claude-NAME-X-Y dotted version (e.g. claude-opus-4-7 → claude-opus-4.7)
|
||||
// 2) splitting on '-'
|
||||
// 3) applying SEGMENT_OVERRIDES; falling back to capitalize-first
|
||||
function humanize(id) {
|
||||
if (id === "auto") return "Auto (Server Picks)";
|
||||
|
||||
// Collapse "X-Y" numeric suffix in claude-foo-X-Y- patterns into "X.Y"
|
||||
const collapsed = id.replace(/(\d+)-(\d+)(?=-|$)/g, "$1.$2");
|
||||
const parts = collapsed.split("-");
|
||||
const labelled = parts.map((p) => {
|
||||
if (SEGMENT_OVERRIDES[p]) return SEGMENT_OVERRIDES[p];
|
||||
if (/^\d/.test(p)) return p; // leave version numbers / "k2.5" alone
|
||||
return p.charAt(0).toUpperCase() + p.slice(1);
|
||||
});
|
||||
return labelled.join(" ");
|
||||
}
|
||||
|
||||
function buildModelsArrayLines(ids) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(` { id: ${JSON.stringify(id)}, name: ${JSON.stringify(humanize(id))} },`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function replaceCursorModels(source, modelsBlock) {
|
||||
// Match the `cursor:` provider entry and replace just its `models: [ ... ],` array.
|
||||
const re = /(\n cursor:\s*\{[\s\S]*?\n models:\s*\[)([\s\S]*?)(\n \],)/;
|
||||
if (!re.test(source)) throw new Error("Could not locate cursor.models array in registry");
|
||||
return source.replace(re, `$1\n${modelsBlock}$3`);
|
||||
}
|
||||
|
||||
const ids = parseModelIds(readSource());
|
||||
const block = buildModelsArrayLines(ids);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(block);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const before = readFileSync(REGISTRY_PATH, "utf8");
|
||||
const after = replaceCursorModels(before, block);
|
||||
if (before === after) {
|
||||
console.log("No changes — cursor models already in sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
writeFileSync(REGISTRY_PATH, after);
|
||||
console.log(`Updated ${ids.length} cursor models in ${REGISTRY_PATH}`);
|
||||
@@ -111,10 +111,17 @@ function parseEnvEntry(line) {
|
||||
if (eqIndex < 1) return null;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const value = trimmed.slice(eqIndex + 1).trim();
|
||||
const value = unquoteEnvValue(trimmed.slice(eqIndex + 1).trim());
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
if (value.length < 2) return value;
|
||||
const quote = value[0];
|
||||
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
function parseExampleEntries(content, scope = "full") {
|
||||
const entries = new Map();
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
Reference in New Issue
Block a user