mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
refactor: dedupe gateway trimmed readers
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
type ToolContentBlock,
|
||||
} from "../chat/tool-content.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { attachOpenClawTranscriptMeta } from "./session-utils.fs.js";
|
||||
|
||||
export const CLAUDE_CLI_PROVIDER = "claude-cli";
|
||||
@@ -38,7 +39,7 @@ type TranscriptLikeMessage = Record<string, unknown>;
|
||||
type ToolNameRegistry = Map<string, string>;
|
||||
|
||||
function resolveHistoryHomeDir(homeDir?: string): string {
|
||||
return homeDir?.trim() || process.env.HOME || os.homedir();
|
||||
return normalizeOptionalString(homeDir) || process.env.HOME || os.homedir();
|
||||
}
|
||||
|
||||
function resolveClaudeProjectsDir(homeDir?: string): string {
|
||||
@@ -48,15 +49,17 @@ function resolveClaudeProjectsDir(homeDir?: string): string {
|
||||
export function resolveClaudeCliBindingSessionId(
|
||||
entry: SessionEntry | undefined,
|
||||
): string | undefined {
|
||||
const bindingSessionId = entry?.cliSessionBindings?.[CLAUDE_CLI_PROVIDER]?.sessionId?.trim();
|
||||
const bindingSessionId = normalizeOptionalString(
|
||||
entry?.cliSessionBindings?.[CLAUDE_CLI_PROVIDER]?.sessionId,
|
||||
);
|
||||
if (bindingSessionId) {
|
||||
return bindingSessionId;
|
||||
}
|
||||
const legacyMapSessionId = entry?.cliSessionIds?.[CLAUDE_CLI_PROVIDER]?.trim();
|
||||
const legacyMapSessionId = normalizeOptionalString(entry?.cliSessionIds?.[CLAUDE_CLI_PROVIDER]);
|
||||
if (legacyMapSessionId) {
|
||||
return legacyMapSessionId;
|
||||
}
|
||||
const legacyClaudeSessionId = entry?.claudeCliSessionId?.trim();
|
||||
const legacyClaudeSessionId = normalizeOptionalString(entry?.claudeCliSessionId);
|
||||
return legacyClaudeSessionId || undefined;
|
||||
}
|
||||
|
||||
@@ -117,8 +120,8 @@ function normalizeClaudeCliContent(
|
||||
const block = cloneJsonValue(item as ToolContentBlock);
|
||||
const type = typeof block.type === "string" ? block.type : "";
|
||||
if (type === "tool_use") {
|
||||
const id = typeof block.id === "string" ? block.id.trim() : "";
|
||||
const name = typeof block.name === "string" ? block.name.trim() : "";
|
||||
const id = normalizeOptionalString(block.id) ?? "";
|
||||
const name = normalizeOptionalString(block.name) ?? "";
|
||||
if (id && name) {
|
||||
toolNameRegistry.set(id, name);
|
||||
}
|
||||
@@ -231,7 +234,7 @@ function parseClaudeCliHistoryEntry(
|
||||
const baseMeta = {
|
||||
importedFrom: CLAUDE_CLI_PROVIDER,
|
||||
cliSessionId,
|
||||
...(typeof entry.uuid === "string" && entry.uuid.trim() ? { externalId: entry.uuid } : {}),
|
||||
...(normalizeOptionalString(entry.uuid) ? { externalId: entry.uuid } : {}),
|
||||
};
|
||||
|
||||
const content =
|
||||
@@ -259,10 +262,8 @@ function parseClaudeCliHistoryEntry(
|
||||
content,
|
||||
api: "anthropic-messages",
|
||||
provider: CLAUDE_CLI_PROVIDER,
|
||||
...(typeof entry.message.model === "string" && entry.message.model.trim()
|
||||
? { model: entry.message.model }
|
||||
: {}),
|
||||
...(typeof entry.message.stop_reason === "string" && entry.message.stop_reason.trim()
|
||||
...(normalizeOptionalString(entry.message.model) ? { model: entry.message.model } : {}),
|
||||
...(normalizeOptionalString(entry.message.stop_reason)
|
||||
? { stopReason: entry.message.stop_reason }
|
||||
: {}),
|
||||
...(resolveClaudeCliUsage(entry.message.usage)
|
||||
|
||||
@@ -44,11 +44,11 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n
|
||||
if (cfg.hooks?.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
const token = cfg.hooks?.token?.trim();
|
||||
const token = normalizeOptionalString(cfg.hooks?.token);
|
||||
if (!token) {
|
||||
throw new Error("hooks.enabled requires hooks.token");
|
||||
}
|
||||
const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH;
|
||||
const rawPath = normalizeOptionalString(cfg.hooks?.path) || DEFAULT_HOOKS_PATH;
|
||||
const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
const trimmed = withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
||||
if (trimmed === "/") {
|
||||
@@ -107,8 +107,7 @@ function resolveKnownAgentIds(cfg: OpenClawConfig, defaultAgentId: string): Set<
|
||||
}
|
||||
|
||||
function resolveSessionKey(raw: string | undefined): string | undefined {
|
||||
const value = raw?.trim();
|
||||
return value ? value : undefined;
|
||||
return normalizeOptionalString(raw);
|
||||
}
|
||||
|
||||
function normalizeSessionKeyPrefix(raw: string): string | undefined {
|
||||
@@ -140,18 +139,14 @@ export function isSessionKeyAllowedByPrefix(sessionKey: string, prefixes: string
|
||||
}
|
||||
|
||||
export function extractHookToken(req: IncomingMessage): string | undefined {
|
||||
const auth =
|
||||
typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : "";
|
||||
const auth = normalizeOptionalString(req.headers.authorization) ?? "";
|
||||
if (normalizeLowercaseStringOrEmpty(auth).startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
const headerToken =
|
||||
typeof req.headers["x-openclaw-token"] === "string"
|
||||
? req.headers["x-openclaw-token"].trim()
|
||||
: "";
|
||||
const headerToken = normalizeOptionalString(req.headers["x-openclaw-token"]) ?? "";
|
||||
if (headerToken) {
|
||||
return headerToken;
|
||||
}
|
||||
@@ -196,12 +191,12 @@ export function normalizeWakePayload(
|
||||
):
|
||||
| { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } }
|
||||
| { ok: false; error: string } {
|
||||
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||
if (!text) {
|
||||
const normalizedText = normalizeOptionalString(payload.text) ?? "";
|
||||
if (!normalizedText) {
|
||||
return { ok: false, error: "text required" };
|
||||
}
|
||||
const mode = payload.mode === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
return { ok: true, value: { text, mode } };
|
||||
return { ok: true, value: { text: normalizedText, mode } };
|
||||
}
|
||||
|
||||
export type HookAgentPayload = {
|
||||
@@ -276,7 +271,7 @@ export function resolveHookTargetAgentId(
|
||||
hooksConfig: HooksConfigResolved,
|
||||
agentId: string | undefined,
|
||||
): string | undefined {
|
||||
const raw = agentId?.trim();
|
||||
const raw = normalizeOptionalString(agentId);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -292,7 +287,7 @@ export function isHookAgentAllowed(
|
||||
agentId: string | undefined,
|
||||
): boolean {
|
||||
// Keep backwards compatibility for callers that omit agentId.
|
||||
const raw = agentId?.trim();
|
||||
const raw = normalizeOptionalString(agentId);
|
||||
if (!raw) {
|
||||
return true;
|
||||
}
|
||||
@@ -345,7 +340,7 @@ export function normalizeHookDispatchSessionKey(params: {
|
||||
sessionKey: string;
|
||||
targetAgentId: string | undefined;
|
||||
}): string {
|
||||
const trimmed = params.sessionKey.trim();
|
||||
const trimmed = normalizeOptionalString(params.sessionKey) ?? "";
|
||||
if (!trimmed || !params.targetAgentId) {
|
||||
return trimmed;
|
||||
}
|
||||
@@ -363,7 +358,7 @@ export function normalizeAgentPayload(payload: Record<string, unknown>):
|
||||
value: HookAgentPayload;
|
||||
}
|
||||
| { ok: false; error: string } {
|
||||
const message = typeof payload.message === "string" ? payload.message.trim() : "";
|
||||
const message = normalizeOptionalString(payload.message) ?? "";
|
||||
if (!message) {
|
||||
return { ok: false, error: "message required" };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
type InputImageSource,
|
||||
} from "../media/input-files.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js";
|
||||
import {
|
||||
buildAgentMessageFromConversationEntries,
|
||||
@@ -243,11 +246,11 @@ type ActiveTurnContext = {
|
||||
function parseImageUrlToSource(url: string): InputImageSource {
|
||||
const dataUriMatch = /^data:([^,]*?),(.*)$/is.exec(url);
|
||||
if (dataUriMatch) {
|
||||
const metadata = dataUriMatch[1]?.trim() ?? "";
|
||||
const metadata = normalizeOptionalString(dataUriMatch[1]) ?? "";
|
||||
const data = dataUriMatch[2] ?? "";
|
||||
const metadataParts = metadata
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.map((part) => normalizeOptionalString(part) ?? "")
|
||||
.filter(Boolean);
|
||||
const isBase64 = metadataParts.some(
|
||||
(part) => normalizeLowercaseStringOrEmpty(part) === "base64",
|
||||
@@ -255,7 +258,7 @@ function parseImageUrlToSource(url: string): InputImageSource {
|
||||
if (!isBase64) {
|
||||
throw new Error("image_url data URI must be base64 encoded");
|
||||
}
|
||||
if (!data.trim()) {
|
||||
if (!(normalizeOptionalString(data) ?? "")) {
|
||||
throw new Error("image_url data URI is missing payload data");
|
||||
}
|
||||
const mediaTypeRaw = metadataParts.find((part) => part.includes("/"));
|
||||
@@ -275,7 +278,7 @@ function resolveActiveTurnContext(messagesUnknown: unknown): ActiveTurnContext {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
continue;
|
||||
}
|
||||
const role = typeof msg.role === "string" ? msg.role.trim() : "";
|
||||
const role = normalizeOptionalString(msg.role) ?? "";
|
||||
const normalizedRole = role === "function" ? "tool" : role;
|
||||
if (normalizedRole !== "user" && normalizedRole !== "tool") {
|
||||
continue;
|
||||
@@ -347,7 +350,7 @@ function buildAgentPrompt(
|
||||
if (!msg || typeof msg !== "object") {
|
||||
continue;
|
||||
}
|
||||
const role = typeof msg.role === "string" ? msg.role.trim() : "";
|
||||
const role = normalizeOptionalString(msg.role) ?? "";
|
||||
const content = extractTextContent(msg.content).trim();
|
||||
const hasImage = extractImageUrls(msg.content).length > 0;
|
||||
if (!role) {
|
||||
@@ -375,7 +378,7 @@ function buildAgentPrompt(
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = typeof msg.name === "string" ? msg.name.trim() : "";
|
||||
const name = normalizeOptionalString(msg.name) ?? "";
|
||||
const sender =
|
||||
normalizedRole === "assistant"
|
||||
? "Assistant"
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type DeviceBootstrapProfile,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
|
||||
import {
|
||||
isBrowserOperatorUiClient,
|
||||
isGatewayCliClient,
|
||||
@@ -659,7 +660,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
|
||||
return;
|
||||
}
|
||||
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
||||
const providedNonce = normalizeOptionalString(device.nonce) ?? "";
|
||||
if (!providedNonce) {
|
||||
rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required");
|
||||
return;
|
||||
@@ -1251,7 +1252,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
remoteIp: reportedClientIp,
|
||||
});
|
||||
const instanceIdRaw = connectParams.client.instanceId;
|
||||
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
|
||||
const instanceId = normalizeOptionalString(instanceIdRaw) ?? "";
|
||||
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
|
||||
if (instanceId) {
|
||||
nodeIdsForPairing.add(instanceId);
|
||||
|
||||
@@ -113,7 +113,7 @@ function resolveIdentityAvatarUrl(
|
||||
if (!avatar) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = avatar.trim();
|
||||
const trimmed = normalizeOptionalString(avatar) ?? "";
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -183,12 +183,12 @@ export function deriveSessionTitle(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (entry.displayName?.trim()) {
|
||||
return entry.displayName.trim();
|
||||
if (normalizeOptionalString(entry.displayName)) {
|
||||
return normalizeOptionalString(entry.displayName);
|
||||
}
|
||||
|
||||
if (entry.subject?.trim()) {
|
||||
return entry.subject.trim();
|
||||
if (normalizeOptionalString(entry.subject)) {
|
||||
return normalizeOptionalString(entry.subject);
|
||||
}
|
||||
|
||||
if (firstUserMessage?.trim()) {
|
||||
@@ -284,13 +284,14 @@ function resolveChildSessionKeys(
|
||||
): string[] | undefined {
|
||||
const childSessionKeys = new Set<string>();
|
||||
for (const entry of listSubagentRunsForController(controllerSessionKey)) {
|
||||
const childSessionKey = entry.childSessionKey?.trim();
|
||||
const childSessionKey = normalizeOptionalString(entry.childSessionKey);
|
||||
if (!childSessionKey) {
|
||||
continue;
|
||||
}
|
||||
const latest = getSessionDisplaySubagentRunByChildSessionKey(childSessionKey);
|
||||
const latestControllerSessionKey =
|
||||
latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim();
|
||||
normalizeOptionalString(latest?.controllerSessionKey) ||
|
||||
normalizeOptionalString(latest?.requesterSessionKey);
|
||||
if (latestControllerSessionKey !== controllerSessionKey) {
|
||||
continue;
|
||||
}
|
||||
@@ -300,15 +301,16 @@ function resolveChildSessionKeys(
|
||||
if (!entry || key === controllerSessionKey) {
|
||||
continue;
|
||||
}
|
||||
const spawnedBy = entry.spawnedBy?.trim();
|
||||
const parentSessionKey = entry.parentSessionKey?.trim();
|
||||
const spawnedBy = normalizeOptionalString(entry.spawnedBy);
|
||||
const parentSessionKey = normalizeOptionalString(entry.parentSessionKey);
|
||||
if (spawnedBy !== controllerSessionKey && parentSessionKey !== controllerSessionKey) {
|
||||
continue;
|
||||
}
|
||||
const latest = getSessionDisplaySubagentRunByChildSessionKey(key);
|
||||
if (latest) {
|
||||
const latestControllerSessionKey =
|
||||
latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim();
|
||||
normalizeOptionalString(latest.controllerSessionKey) ||
|
||||
normalizeOptionalString(latest.requesterSessionKey);
|
||||
if (latestControllerSessionKey !== controllerSessionKey) {
|
||||
continue;
|
||||
}
|
||||
@@ -388,13 +390,13 @@ export function loadSessionEntry(sessionKey: string) {
|
||||
const agentId = resolveSessionStoreAgentId(cfg, canonicalKey);
|
||||
const { storePath, store } = resolveGatewaySessionStoreLookup({
|
||||
cfg,
|
||||
key: sessionKey.trim(),
|
||||
key: normalizeOptionalString(sessionKey) ?? "",
|
||||
canonicalKey,
|
||||
agentId,
|
||||
});
|
||||
const target = resolveGatewaySessionStoreTarget({
|
||||
cfg,
|
||||
key: sessionKey.trim(),
|
||||
key: normalizeOptionalString(sessionKey) ?? "",
|
||||
store,
|
||||
});
|
||||
const freshestMatch = resolveFreshestSessionStoreMatchFromStoreKeys(store, target.storeKeys);
|
||||
@@ -434,7 +436,7 @@ function findFreshestStoreMatch(
|
||||
): { entry: SessionEntry; key: string } | undefined {
|
||||
const matches = new Map<string, { entry: SessionEntry; key: string }>();
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = candidate.trim();
|
||||
const trimmed = normalizeOptionalString(candidate) ?? "";
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
@@ -486,7 +488,7 @@ export function pruneLegacyStoreKeys(params: {
|
||||
}) {
|
||||
const keysToDelete = new Set<string>();
|
||||
for (const candidate of params.candidates) {
|
||||
const trimmed = String(candidate ?? "").trim();
|
||||
const trimmed = normalizeOptionalString(String(candidate ?? "")) ?? "";
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
@@ -720,7 +722,7 @@ export function resolveSessionStoreKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
const raw = (params.sessionKey ?? "").trim();
|
||||
const raw = normalizeOptionalString(params.sessionKey) ?? "";
|
||||
if (!raw) {
|
||||
return raw;
|
||||
}
|
||||
@@ -769,7 +771,7 @@ export function canonicalizeSpawnedByForAgent(
|
||||
agentId: string,
|
||||
spawnedBy?: string,
|
||||
): string | undefined {
|
||||
const raw = spawnedBy?.trim();
|
||||
const raw = normalizeOptionalString(spawnedBy) ?? "";
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -895,7 +897,7 @@ export function resolveGatewaySessionStoreTarget(params: {
|
||||
canonicalKey: string;
|
||||
storeKeys: string[];
|
||||
} {
|
||||
const key = params.key.trim();
|
||||
const key = normalizeOptionalString(params.key) ?? "";
|
||||
const canonicalKey = resolveSessionStoreKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey: key,
|
||||
@@ -1411,7 +1413,7 @@ export function listSessionsFromStore(params: {
|
||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||
const includeLastMessage = opts.includeLastMessage === true;
|
||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
||||
const label = normalizeOptionalString(opts.label) ?? "";
|
||||
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||
const search = normalizeLowercaseStringOrEmpty(opts.search);
|
||||
const activeMinutes =
|
||||
|
||||
@@ -30,7 +30,10 @@ import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-ov
|
||||
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import { parseSessionLabel } from "../sessions/session-label.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
@@ -109,7 +112,7 @@ export async function applySessionsPatchToStore(params: {
|
||||
return invalid("spawnedBy cannot be cleared once set");
|
||||
}
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
const trimmed = normalizeOptionalString(String(raw)) ?? "";
|
||||
if (!trimmed) {
|
||||
return invalid("invalid spawnedBy: empty");
|
||||
}
|
||||
@@ -133,7 +136,7 @@ export async function applySessionsPatchToStore(params: {
|
||||
if (!supportsSpawnLineage(storeKey)) {
|
||||
return invalid("spawnedWorkspaceDir is only supported for subagent:* or acp:* sessions");
|
||||
}
|
||||
const trimmed = String(raw).trim();
|
||||
const trimmed = normalizeOptionalString(String(raw)) ?? "";
|
||||
if (!trimmed) {
|
||||
return invalid("invalid spawnedWorkspaceDir: empty");
|
||||
}
|
||||
@@ -237,8 +240,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeThinkLevel(String(raw));
|
||||
if (!normalized) {
|
||||
const hintProvider = existing?.providerOverride?.trim() || resolvedDefault.provider;
|
||||
const hintModel = existing?.modelOverride?.trim() || resolvedDefault.model;
|
||||
const hintProvider =
|
||||
normalizeOptionalString(existing?.providerOverride) || resolvedDefault.provider;
|
||||
const hintModel = normalizeOptionalString(existing?.modelOverride) || resolvedDefault.model;
|
||||
return invalid(
|
||||
`invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
|
||||
);
|
||||
@@ -359,7 +363,7 @@ export async function applySessionsPatchToStore(params: {
|
||||
if (raw === null) {
|
||||
delete next.execNode;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
const trimmed = normalizeOptionalString(String(raw)) ?? "";
|
||||
if (!trimmed) {
|
||||
return invalid("invalid execNode: empty");
|
||||
}
|
||||
@@ -380,7 +384,7 @@ export async function applySessionsPatchToStore(params: {
|
||||
markLiveSwitchPending: true,
|
||||
});
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
const trimmed = normalizeOptionalString(String(raw)) ?? "";
|
||||
if (!trimmed) {
|
||||
return invalid("invalid model: empty");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user