feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API
- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를 실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응) - recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 + MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지) - manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker) - mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시 active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정 - config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL, SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가 - docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001) - frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선 - CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,13 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Node.js + Claude Code CLI (AI_PROVIDER=claude_code 테스트 모드용 — `claude -p` 헤드리스 호출)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates git \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& npm install -g @anthropic-ai/claude-code \
|
||||
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 의존성 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
@@ -9,6 +16,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# 애플리케이션 코드 복사
|
||||
COPY . .
|
||||
|
||||
# claude CLI는 root 실행 시 일부 기능 제한 → 전용 사용자로 실행
|
||||
RUN useradd -m -u 1000 mcp && chown -R mcp:mcp /app
|
||||
USER mcp
|
||||
ENV HOME=/home/mcp
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8001
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class ClaudeClient:
|
||||
params = {
|
||||
"model": use_model,
|
||||
"max_tokens": max_tokens or settings.MAX_TOKENS,
|
||||
"temperature": settings.TEMPERATURE,
|
||||
# anthropic SDK 1.x: temperature 등 샘플링 파라미터 제거됨 (전달 시 TypeError)
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
|
||||
124
mcp-server/claude_code_client.py
Normal file
124
mcp-server/claude_code_client.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Claude Code CLI 기반 AI 클라이언트 (테스트/개발용).
|
||||
|
||||
Anthropic API 크레딧 대신 Claude Code 구독(`claude -p` 헤드리스 모드)으로 분석을 실행한다.
|
||||
ClaudeClient와 동일한 인터페이스 — `analyze()`만 서브프로세스 호출로 교체하고
|
||||
나머지 도메인 메서드(assess_risk, analyze_market 등)는 그대로 상속.
|
||||
|
||||
사용:
|
||||
AI_PROVIDER=claude_code
|
||||
CLAUDE_CODE_OAUTH_TOKEN=<`claude setup-token` 출력> # 컨테이너에서 실행 시 필수
|
||||
|
||||
제약:
|
||||
- API tool_use(함수 호출) 미지원 → tools 인자는 무시하고 텍스트(JSON) 응답만 사용.
|
||||
- 프롬프트 캐시 usage 는 CLI가 보고하는 값을 그대로 전달.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from config import settings
|
||||
from claude_client import ClaudeClient, _CACHED_CONTEXT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CLI_TIMEOUT = float(os.getenv("CLAUDE_CODE_TIMEOUT", "120"))
|
||||
_CLI_BIN = os.getenv("CLAUDE_CODE_BIN", "claude")
|
||||
_MAX_CONCURRENT = int(os.getenv("CLAUDE_CODE_MAX_CONCURRENT", "2"))
|
||||
# 프로젝트 .claude/ 설정(훅·CLAUDE.md)이 끼어들지 않도록 중립 디렉터리에서 실행
|
||||
_CLI_CWD = os.getenv("CLAUDE_CODE_CWD") or os.path.join(tempfile.gettempdir(), "autostock-claude-code")
|
||||
|
||||
|
||||
class ClaudeCodeClient(ClaudeClient):
|
||||
"""`claude -p` 서브프로세스로 ClaudeClient.analyze() 를 대체."""
|
||||
|
||||
def __init__(self):
|
||||
# 부모 __init__ 은 AsyncAnthropic(api_key) 를 만들지만 여기서는 SDK 미사용
|
||||
self.client = None
|
||||
self.model = settings.CLAUDE_MODEL
|
||||
self.haiku_model = settings.CLAUDE_HAIKU_MODEL
|
||||
self._sem = asyncio.Semaphore(_MAX_CONCURRENT)
|
||||
os.makedirs(_CLI_CWD, exist_ok=True)
|
||||
if shutil.which(_CLI_BIN) is None:
|
||||
logger.error("[ClaudeCode] '%s' 실행 파일을 찾을 수 없음 — 분석 호출은 모두 실패합니다", _CLI_BIN)
|
||||
if not os.getenv("CLAUDE_CODE_OAUTH_TOKEN") and not os.getenv("ANTHROPIC_API_KEY"):
|
||||
logger.warning("[ClaudeCode] CLAUDE_CODE_OAUTH_TOKEN 미설정 — 호스트 로그인 세션이 없으면 인증 실패")
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_message: str,
|
||||
tools: List[Dict[str, Any]] = None,
|
||||
max_tokens: int = None,
|
||||
model_override: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
use_model = model_override or self.model
|
||||
if tools:
|
||||
logger.debug("[ClaudeCode] tools %d개 무시 (CLI 모드는 tool_use 미지원)", len(tools))
|
||||
|
||||
full_system = f"{_CACHED_CONTEXT}\n\n{system_prompt}"
|
||||
cmd = [
|
||||
_CLI_BIN, "-p", user_message,
|
||||
"--output-format", "json",
|
||||
"--model", use_model,
|
||||
"--system-prompt", full_system,
|
||||
"--tools", "", # 파일/셸 도구 비활성 — 순수 텍스트 분석
|
||||
"--max-turns", "1",
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
env.pop("ANTHROPIC_API_KEY", None) # 크레딧 없는 API 키가 구독 인증을 가리지 않도록
|
||||
|
||||
try:
|
||||
async with self._sem:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
cwd=_CLI_CWD,
|
||||
)
|
||||
try:
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=_CLI_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
raise RuntimeError(f"claude CLI timeout ({_CLI_TIMEOUT}s)")
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"claude CLI exit={proc.returncode}: {err.decode(errors='ignore')[-500:]}"
|
||||
)
|
||||
|
||||
data = json.loads(out.decode())
|
||||
if data.get("is_error"):
|
||||
raise RuntimeError(f"claude CLI error: {data.get('result')}")
|
||||
|
||||
usage = data.get("usage") or {}
|
||||
result = {
|
||||
"content": [{"type": "text", "text": data.get("result", "")}],
|
||||
"stop_reason": data.get("stop_reason", "end_turn"),
|
||||
"model_used": use_model,
|
||||
"usage": {
|
||||
"input_tokens": usage.get("input_tokens", 0),
|
||||
"output_tokens": usage.get("output_tokens", 0),
|
||||
"cache_read_tokens": usage.get("cache_read_input_tokens", 0),
|
||||
"cache_write_tokens": usage.get("cache_creation_input_tokens", 0),
|
||||
},
|
||||
}
|
||||
logger.debug(
|
||||
"[ClaudeCode] model=%s in=%s out=%s cost=$%s",
|
||||
use_model, usage.get("input_tokens"), usage.get("output_tokens"),
|
||||
data.get("total_cost_usd"),
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Claude Code analysis error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
claude_code_client = ClaudeCodeClient()
|
||||
@@ -42,7 +42,7 @@ class Settings:
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# Claude Settings
|
||||
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6-20250514")
|
||||
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6")
|
||||
CLAUDE_HAIKU_MODEL = os.getenv("CLAUDE_HAIKU_MODEL", "claude-haiku-4-5-20251001")
|
||||
|
||||
# Gemini Settings
|
||||
@@ -69,7 +69,7 @@ class Settings:
|
||||
if self.AI_PROVIDER == "gemini":
|
||||
self.MAX_TOKENS = int(os.getenv("GEMINI_MAX_TOKENS", "1024"))
|
||||
self.TEMPERATURE = float(os.getenv("GEMINI_TEMPERATURE", "0.3"))
|
||||
else:
|
||||
else: # claude | claude_code
|
||||
self.MAX_TOKENS = int(os.getenv("CLAUDE_MAX_TOKENS", "1024"))
|
||||
self.TEMPERATURE = float(os.getenv("CLAUDE_TEMPERATURE", "0.3"))
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ class GeminiClient:
|
||||
temperature=self.config.temperature,
|
||||
)
|
||||
|
||||
# 비동기 호출
|
||||
response = await self.client.models.generate_content(
|
||||
# 비동기 호출 (google-genai SDK: models.generate_content는 동기 함수 — aio 네임스페이스 사용)
|
||||
response = await self.client.aio.models.generate_content(
|
||||
model=self.model_id,
|
||||
contents=user_message,
|
||||
config=config_with_system
|
||||
|
||||
@@ -35,6 +35,10 @@ from mcp_tools import mcp_tools
|
||||
if settings.AI_PROVIDER == "gemini":
|
||||
from gemini_client import gemini_client
|
||||
ai_client = gemini_client
|
||||
elif settings.AI_PROVIDER == "claude_code":
|
||||
# Claude Code 구독(`claude -p`)으로 실행 — API 크레딧 불필요 (테스트용)
|
||||
from claude_code_client import claude_code_client
|
||||
ai_client = claude_code_client
|
||||
else:
|
||||
ai_client = claude_client
|
||||
|
||||
@@ -118,7 +122,7 @@ async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"ai_provider": settings.AI_PROVIDER,
|
||||
"hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER == "claude" else False,
|
||||
"hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER in ("claude", "claude_code") else False,
|
||||
"claude_model": settings.CLAUDE_MODEL,
|
||||
"claude_haiku_model": settings.CLAUDE_HAIKU_MODEL,
|
||||
"claude_configured": bool(settings.ANTHROPIC_API_KEY),
|
||||
|
||||
@@ -35,7 +35,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import Portfolio, ActiveTrade, TradeHistory
|
||||
from app.models.models import Portfolio, ActiveTrade, TradeHistory, StockBan
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -234,6 +234,16 @@ class RiskValidator:
|
||||
# Import MDD manager
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
|
||||
# MCP는 백엔드와 별도 프로세스 — 인메모리 mdd_manager는 항상 초기 상태이므로
|
||||
# 판정 전에 백엔드가 Redis에 저장한 최신 상태를 복원한다.
|
||||
# 복원 실패 시 fail-open 유지 (기존 인메모리 상태로 계속 진행 + 경고 로그).
|
||||
try:
|
||||
restored = await mdd_manager.load_from_redis()
|
||||
if not restored:
|
||||
logger.warning("MDD state restore from Redis returned no data (fail-open, using in-memory state)")
|
||||
except Exception as redis_err:
|
||||
logger.warning(f"MDD state restore from Redis failed (fail-open): {redis_err}")
|
||||
|
||||
# Check if new positions are paused due to MDD
|
||||
can_open = await mdd_manager.can_open_new_position()
|
||||
|
||||
|
||||
@@ -30,7 +30,14 @@ ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json
|
||||
MONITORING_INTERVAL_SECONDS = 60 # 60초마다 확인
|
||||
BACKEND_API_BASE = os.getenv('BACKEND_API_BASE', 'http://localhost:8000')
|
||||
|
||||
async def update_active_trade_state_via_api(stock_code: str, new_status: str, reason: str = "") -> bool:
|
||||
# 백엔드 require_api_key와 동일한 소스 (app/core/security.py: API_KEY 우선, 없으면 SECRET_KEY 폴백)
|
||||
API_KEY = settings.API_KEY if settings.API_KEY else settings.SECRET_KEY
|
||||
_API_HEADERS = {"X-API-Key": API_KEY}
|
||||
|
||||
|
||||
async def update_active_trade_state_via_api(
|
||||
stock_code: str, new_status: str, reason: str = "", remaining_pct: float = None
|
||||
) -> bool:
|
||||
"""
|
||||
CLAUDE.md Section 3.2: 직접 DB 접근 금지. 백엔드 API를 통해 상태 업데이트.
|
||||
|
||||
@@ -41,20 +48,25 @@ async def update_active_trade_state_via_api(stock_code: str, new_status: str, re
|
||||
stock_code: 종목 코드
|
||||
new_status: 목표 상태 ('active', 'pending_sell', 'closed')
|
||||
reason: 상태 변경 이유
|
||||
remaining_pct: 부분 매도 후 잔여 비율 (제공 시 ActiveTrade.remaining_pct 갱신)
|
||||
|
||||
Returns:
|
||||
성공 여부
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"{BACKEND_API_BASE}/api/portfolio/active-trades/{stock_code}/status"
|
||||
url = f"{BACKEND_API_BASE}/api/v1/portfolio/active-trades/{stock_code}/status"
|
||||
payload = {
|
||||
"status": new_status,
|
||||
"reason": reason,
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
if remaining_pct is not None:
|
||||
payload["remaining_pct"] = remaining_pct
|
||||
|
||||
async with session.patch(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
async with session.patch(
|
||||
url, json=payload, headers=_API_HEADERS, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
logging.info(f"✅ [{stock_code}] API를 통해 상태 변경: {new_status}")
|
||||
return True
|
||||
@@ -240,6 +252,7 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
should_sell = False
|
||||
sell_reason = ""
|
||||
sell_quantity = 0
|
||||
new_remaining_pct = 0.0 # 매도 후 잔여 비율 (0.0 = 전량 매도 → closed)
|
||||
|
||||
try:
|
||||
# 현재가 조회
|
||||
@@ -269,6 +282,7 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
should_sell = True
|
||||
sell_quantity = int(quantity * remaining_pct)
|
||||
sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})"
|
||||
new_remaining_pct = 0.0 # 전량 매도 → closed
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 2. 기본 익절 조건 확인
|
||||
@@ -277,6 +291,7 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
should_sell = True
|
||||
sell_quantity = int(quantity * remaining_pct)
|
||||
sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})"
|
||||
new_remaining_pct = 0.0 # 전량 매도 → closed
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 3. 부분 익절 + 트레일링 스탑 조건 (CLAUDE.md 5.2)
|
||||
@@ -296,11 +311,13 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
if should_tp:
|
||||
should_sell = True
|
||||
if remaining_pct == 1.0:
|
||||
# 첫 익절: 50% 분할 매도
|
||||
# 첫 익절: 50% 분할 매도 — 잔량 50%는 'active'로 유지 (CLAUDE.md 5.2)
|
||||
sell_quantity = int(quantity * 0.5)
|
||||
new_remaining_pct = remaining_pct * 0.5
|
||||
else:
|
||||
# 트레일링 스탑: 남은 물량 전부
|
||||
# 트레일링 스탑: 남은 물량 전부 → closed
|
||||
sell_quantity = int(quantity * remaining_pct)
|
||||
new_remaining_pct = 0.0
|
||||
sell_reason = tp_reason
|
||||
|
||||
else:
|
||||
@@ -313,7 +330,7 @@ async def check_and_execute_trades(db: AsyncSession):
|
||||
|
||||
if should_sell and sell_quantity > 0:
|
||||
await _execute_sell_with_pending_state(
|
||||
stock_code, quantity, sell_quantity, entry_price, sell_reason
|
||||
stock_code, quantity, sell_quantity, entry_price, sell_reason, new_remaining_pct
|
||||
)
|
||||
|
||||
|
||||
@@ -322,7 +339,8 @@ async def _execute_sell_with_pending_state(
|
||||
total_quantity: int,
|
||||
sell_quantity: int,
|
||||
entry_price: float,
|
||||
sell_reason: str
|
||||
sell_reason: str,
|
||||
new_remaining_pct: float = 0.0
|
||||
) -> None:
|
||||
"""
|
||||
CLAUDE.md Section 5.2: 매도 주문 실행 및 상태 전이
|
||||
@@ -330,7 +348,9 @@ async def _execute_sell_with_pending_state(
|
||||
단계:
|
||||
1. 상태를 'pending_sell'로 변경 (즉시)
|
||||
2. 매도 주문 실행
|
||||
3. 주문 성공 시 'closed'로 변경 (1-5초 대기)
|
||||
3. 주문 성공 시 상태 전이 (1-5초 대기)
|
||||
- new_remaining_pct > 0 (부분 매도): 'active' 유지 + remaining_pct 갱신
|
||||
- new_remaining_pct == 0 (전량 매도): 'closed'로 전이
|
||||
4. 실패 시 'active'로 롤백
|
||||
|
||||
Args:
|
||||
@@ -339,6 +359,7 @@ async def _execute_sell_with_pending_state(
|
||||
sell_quantity: 매도할 수량
|
||||
entry_price: 진입가
|
||||
sell_reason: 매도 이유
|
||||
new_remaining_pct: 매도 후 잔여 비율 (0.0이면 전량 매도 → closed)
|
||||
"""
|
||||
try:
|
||||
logging.warning(
|
||||
@@ -374,24 +395,41 @@ async def _execute_sell_with_pending_state(
|
||||
logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}")
|
||||
|
||||
if sell_result and sell_result.get('rt_cd') == '0':
|
||||
order_id = sell_result.get('output', {}).get('order_id', '')
|
||||
order_id = sell_result.get('output', {}).get('ODNO', '')
|
||||
logging.info(f"✅ [{stock_code}] 매도 주문 성공 (주문ID: {order_id})")
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# Step 3: 1-5초 대기 후 체결 확인 및 상태 'closed'로 변경
|
||||
# Step 3: 1-5초 대기 후 체결 확인 및 상태 전이
|
||||
# CLAUDE.md 5.2: 부분 매도(+2R 50%)는 'active' 유지 + remaining_pct 갱신,
|
||||
# 전량 매도만 'closed'로 전이 (잔량이 감시에서 이탈하지 않도록)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
await asyncio.sleep(2) # 체결 대기
|
||||
|
||||
success = await update_active_trade_state_via_api(
|
||||
stock_code,
|
||||
"closed",
|
||||
f"매도 완료: {sell_reason} (주문ID: {order_id})"
|
||||
)
|
||||
|
||||
if success:
|
||||
logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed")
|
||||
if new_remaining_pct > 0:
|
||||
success = await update_active_trade_state_via_api(
|
||||
stock_code,
|
||||
"active",
|
||||
f"부분 매도 완료: {sell_reason} (주문ID: {order_id})",
|
||||
remaining_pct=new_remaining_pct
|
||||
)
|
||||
if success:
|
||||
logging.info(
|
||||
f"✅ [{stock_code}] 상태: pending_sell → active "
|
||||
f"(잔여비율: {new_remaining_pct*100:.1f}%)"
|
||||
)
|
||||
else:
|
||||
logging.warning(f"⚠️ [{stock_code}] 부분매도 후 active 전이 실패 (order_id: {order_id})")
|
||||
else:
|
||||
logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
|
||||
success = await update_active_trade_state_via_api(
|
||||
stock_code,
|
||||
"closed",
|
||||
f"매도 완료: {sell_reason} (주문ID: {order_id})"
|
||||
)
|
||||
|
||||
if success:
|
||||
logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed")
|
||||
else:
|
||||
logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
|
||||
else:
|
||||
error_msg = sell_result.get('msg1', '알 수 없는 오류') if sell_result else "응답 없음"
|
||||
logging.error(f"❌ [{stock_code}] 매도 주문 실패: {error_msg}")
|
||||
|
||||
Reference in New Issue
Block a user