feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
505
kiwoom_ws.py
505
kiwoom_ws.py
@@ -1,502 +1,3 @@
|
||||
"""
|
||||
kiwoom_ws.py — 키움 WebSocket 실시간 시세 캐시 (시세 마이그레이션 검증용)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
목적
|
||||
----
|
||||
KIS WS(41 한도)의 시세를 키움 WS(100 한도)로 옮기기 전에, 둘을 동시에 돌려
|
||||
가격 일치성을 검증하기 위한 키움 WebSocket 클라이언트.
|
||||
|
||||
설계 원칙 (KIS WS 와 동일)
|
||||
--------------------------
|
||||
- ``Dict[str, Dict]`` 메모리 캐시 (락만 잠그고 마이크로초 read/write)
|
||||
- ``get_price(code)`` 인터페이스를 KIS WS 와 100% 동일 포맷으로 제공
|
||||
→ 봇 코드 재사용성 100%
|
||||
- ``KiwoomTokenManager`` 싱글톤 (kis_ws.py 안에 있음) 재사용
|
||||
- 재연결 백오프, approval 갱신, 종목 등록/해지
|
||||
|
||||
키움 WebSocket 스펙
|
||||
-------------------
|
||||
URL 실전: wss://api.kiwoom.com:10000/api/dostk/websocket
|
||||
URL 모의: wss://mockapi.kiwoom.com:10000/api/dostk/websocket
|
||||
|
||||
[프로토콜]
|
||||
1. 연결 후 LOGIN: {"trnm": "LOGIN", "token": "<access_token>"}
|
||||
2. 등록: {"trnm": "REG", "grp_no": "1", "refresh": "1",
|
||||
"data": [{"item": ["005930"], "type": ["0B"]}]}
|
||||
3. 해지: {"trnm": "REMOVE","grp_no": "1",
|
||||
"data": [{"item": ["005930"], "type": ["0B"]}]}
|
||||
4. PING: {"trnm": "PING"} → 30초마다 서버가 보냄, 그대로 echo
|
||||
|
||||
[주식체결 0B 메시지 FID]
|
||||
10 = 현재가(체결가; 부호 → 음수=하락) 11 = 전일대비
|
||||
12 = 등락률 13 = 누적거래량
|
||||
14 = 누적거래대금 15 = 거래량(체결량)
|
||||
16 = 시가 17 = 고가 18 = 저가 20 = 체결시간(HHMMSS)
|
||||
|
||||
(키움 OpenAPI+ FID 정의 — 종목별 동일)
|
||||
|
||||
설치
|
||||
----
|
||||
``pip install websocket-client`` (KIS WS 와 공용)
|
||||
|
||||
토글 (DB env_config)
|
||||
--------------------
|
||||
``WS_PROVIDER`` (기본 ``kis_only`` — 키움 WS 미기동)
|
||||
``KIWOOM_WS_URL_REAL`` / ``KIWOOM_WS_URL_MOCK`` (URL 재정의용)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional, Set
|
||||
|
||||
logger = logging.getLogger("KiwoomWebSocket")
|
||||
|
||||
|
||||
# ── 환경변수 헬퍼 (KIS WS 와 동일 패턴, fallback 포함) ────────────────────
|
||||
try:
|
||||
from kis_long_ver1 import get_env_from_db, get_env_int # noqa: F401
|
||||
except ImportError:
|
||||
try:
|
||||
from kis_short_ver2 import get_env_from_db, get_env_int # noqa: F401
|
||||
except ImportError:
|
||||
def get_env_from_db(key, default=""): # type: ignore[misc]
|
||||
return default
|
||||
|
||||
def get_env_int(key, default): # type: ignore[misc]
|
||||
return default
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 메인 클래스
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
class KiwoomWebSocketPriceCache:
|
||||
"""키움 실시간 체결가 (0B) WebSocket 수신기.
|
||||
|
||||
사용법::
|
||||
|
||||
ws = KiwoomWebSocketPriceCache(app_key, app_secret, is_mock=False)
|
||||
ws.start()
|
||||
ws.subscribe("005930")
|
||||
data = ws.get_price("005930") # KIS 와 동일 dict 포맷
|
||||
ws.stop()
|
||||
|
||||
``get_price()`` 가 None 이면 → 캐시 없음/만료 → 호출자가 다른 소스 사용.
|
||||
"""
|
||||
|
||||
# 키움 0B FID
|
||||
FID_PRICE = "10" # 현재가 (체결가, 부호 포함)
|
||||
FID_CHANGE = "11" # 전일대비
|
||||
FID_CHANGE_PCT = "12" # 등락률
|
||||
FID_CUM_VOL = "13" # 누적거래량
|
||||
FID_OPEN = "16"
|
||||
FID_HIGH = "17"
|
||||
FID_LOW = "18"
|
||||
FID_TICK_VOL = "15"
|
||||
FID_TICK_TIME = "20"
|
||||
|
||||
# 한도 (키움 권장)
|
||||
MAX_SUBSCRIPTIONS_PER_GROUP = 100 # grp_no=1 그룹 1개당
|
||||
GROUP_NO = "1"
|
||||
SUB_TYPE = "0B" # 주식체결
|
||||
|
||||
# 재연결 정책
|
||||
RECONNECT_BASE_DELAY_SEC = 5.0
|
||||
RECONNECT_MAX_DELAY_SEC = 300.0
|
||||
MAX_RECONNECTS_PER_HOUR = 6
|
||||
MAX_RECONNECT_ATTEMPTS = 10
|
||||
STABLE_CONN_RESET_SEC = 300.0 # 5분 안정 연결 후 끊기면 카운터 초기화
|
||||
|
||||
# 토큰 캐시 — KiwoomTokenManager 가 알아서 처리하지만 보수적 만료 버퍼
|
||||
TOKEN_REFRESH_BUFFER_SEC = 600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
is_mock: bool = False,
|
||||
):
|
||||
self.app_key = app_key
|
||||
self.app_secret = app_secret
|
||||
self.is_mock = is_mock
|
||||
|
||||
# URL (env/DB 로 재정의 가능)
|
||||
_default_real = "wss://api.kiwoom.com:10000/api/dostk/websocket"
|
||||
_default_mock = "wss://mockapi.kiwoom.com:10000/api/dostk/websocket"
|
||||
self._ws_url = (
|
||||
get_env_from_db("KIWOOM_WS_URL_MOCK", _default_mock)
|
||||
if is_mock
|
||||
else get_env_from_db("KIWOOM_WS_URL_REAL", _default_real)
|
||||
)
|
||||
|
||||
# 메모리 캐시 — KIS WS 와 동일 포맷 (data + ts)
|
||||
self._cache: Dict[str, Dict] = {}
|
||||
self._cache_lock = threading.Lock()
|
||||
|
||||
# 구독 종목
|
||||
self._subscribed: Set[str] = set()
|
||||
self._sub_lock = threading.Lock()
|
||||
|
||||
# 연결 상태
|
||||
self._ws = None
|
||||
self._ws_thread: Optional[threading.Thread] = None
|
||||
self._running = False
|
||||
self._connected = False
|
||||
self._authenticated = False # LOGIN 응답 OK 받기 전엔 REG 못 보냄
|
||||
|
||||
# 재연결 추적
|
||||
self._reconnect_count = 0
|
||||
self._reconnect_times: list = []
|
||||
self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC
|
||||
self._last_connect_time: float = 0.0
|
||||
|
||||
# websocket-client lib
|
||||
try:
|
||||
import websocket as _ws_lib # type: ignore
|
||||
self._ws_lib = _ws_lib
|
||||
self._available = True
|
||||
except ImportError:
|
||||
self._ws_lib = None
|
||||
self._available = False
|
||||
logger.warning("⚠️ websocket-client 미설치 — 키움 WS 사용 불가")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 외부 API
|
||||
# ------------------------------------------------------------------
|
||||
def start(self) -> bool:
|
||||
"""백그라운드 수신 스레드 기동."""
|
||||
if not self._available:
|
||||
logger.warning("키움 WS 라이브러리 없음 → start 무시")
|
||||
return False
|
||||
if self._running:
|
||||
return True
|
||||
if not self.app_key or not self.app_secret:
|
||||
logger.warning("⚠️ 키움 키 없음 → 키움 WS 비활성")
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
self._ws_thread = threading.Thread(
|
||||
target=self._run_loop, daemon=True, name="KiwoomWS",
|
||||
)
|
||||
self._ws_thread.start()
|
||||
logger.info("✅ 키움 WebSocket 수신 스레드 시작 (mock=%s, url=%s)",
|
||||
self.is_mock, self._ws_url)
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""수신 스레드 종료 + 소켓 닫기."""
|
||||
self._running = False
|
||||
try:
|
||||
if self._ws is not None:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def subscribe(self, code: str) -> bool:
|
||||
"""단일 종목 등록. WS 연결되어 있어야 즉시 발송, 아니면 대기 큐에만 추가."""
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return False
|
||||
with self._sub_lock:
|
||||
if code in self._subscribed:
|
||||
return True
|
||||
if len(self._subscribed) >= self.MAX_SUBSCRIPTIONS_PER_GROUP:
|
||||
logger.warning(
|
||||
"⚠️ 키움 WS 구독 한도 초과 (%d/%d) — %s 등록 거절",
|
||||
len(self._subscribed), self.MAX_SUBSCRIPTIONS_PER_GROUP, code,
|
||||
)
|
||||
return False
|
||||
self._subscribed.add(code)
|
||||
|
||||
# 연결되어 있을 때만 즉시 발송. 아니면 _on_open 에서 일괄 발송.
|
||||
if self._connected and self._authenticated:
|
||||
return self._send_reg([code])
|
||||
return True
|
||||
|
||||
def unsubscribe(self, code: str) -> bool:
|
||||
"""단일 종목 등록 해지."""
|
||||
code = (code or "").strip()
|
||||
with self._sub_lock:
|
||||
if code not in self._subscribed:
|
||||
return False
|
||||
self._subscribed.discard(code)
|
||||
if self._connected and self._authenticated:
|
||||
return self._send_remove([code])
|
||||
return True
|
||||
|
||||
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[Dict]:
|
||||
"""KIS WS ``get_price`` 와 동일 포맷 반환.
|
||||
|
||||
반환::
|
||||
|
||||
{
|
||||
"stck_prpr": "73900", # 현재가
|
||||
"stck_oprc": "73000", # 시가
|
||||
"stck_hgpr": "74500", # 고가
|
||||
"stck_lwpr": "72800", # 저가
|
||||
"prdy_vrss": "200", # 전일 대비
|
||||
"prdy_ctrt": "0.27", # 등락률
|
||||
"_age_ms": 123, # 캐시 나이 (ms) — 검증용 메타
|
||||
}
|
||||
|
||||
``max_age_sec`` 초 초과면 None.
|
||||
"""
|
||||
with self._cache_lock:
|
||||
entry = self._cache.get(code)
|
||||
if not entry:
|
||||
return None
|
||||
age_sec = time.time() - entry.get("ts", 0)
|
||||
if age_sec > max_age_sec:
|
||||
return None
|
||||
data = dict(entry["data"])
|
||||
data["_age_ms"] = int(age_sec * 1000)
|
||||
return data
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return bool(self._connected and self._authenticated)
|
||||
|
||||
def subscribed_count(self) -> int:
|
||||
with self._sub_lock:
|
||||
return len(self._subscribed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 내부: 메인 수신 루프
|
||||
# ------------------------------------------------------------------
|
||||
def _run_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
self._connect_and_serve()
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS 루프 예외: %s", e)
|
||||
finally:
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
if self._running:
|
||||
self._reconnect_with_backoff()
|
||||
|
||||
def _connect_and_serve(self) -> None:
|
||||
"""단일 연결 수명. blocking. 끊기면 반환 → 호출자가 backoff 후 재호출."""
|
||||
if not self._ws_lib:
|
||||
return
|
||||
|
||||
# 토큰 발급
|
||||
token = self._get_kiwoom_token()
|
||||
if not token:
|
||||
logger.warning("⚠️ 키움 토큰 발급 실패 → WS 연결 보류 (60s)")
|
||||
time.sleep(60)
|
||||
return
|
||||
|
||||
# WebSocketApp 생성
|
||||
self._ws = self._ws_lib.WebSocketApp(
|
||||
self._ws_url,
|
||||
on_open=self._on_open(token),
|
||||
on_message=self._on_message,
|
||||
on_error=self._on_error,
|
||||
on_close=self._on_close,
|
||||
)
|
||||
self._last_connect_time = time.time()
|
||||
# blocking — 연결 종료까지 여기서 대기
|
||||
self._ws.run_forever(ping_interval=30, ping_timeout=10)
|
||||
|
||||
def _on_open(self, token: str):
|
||||
"""on_open 콜백 팩토리 — token 캡처 후 LOGIN 발송."""
|
||||
def _handler(ws):
|
||||
self._connected = True
|
||||
self._authenticated = False
|
||||
try:
|
||||
ws.send(json.dumps({"trnm": "LOGIN", "token": token}))
|
||||
logger.info("📡 키움 WS 연결 → LOGIN 발송")
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS LOGIN 발송 실패: %s", e)
|
||||
return _handler
|
||||
|
||||
def _on_message(self, ws, message: str) -> None:
|
||||
"""수신 메시지 디스패치 (LOGIN ack / REG ack / REAL / PING)."""
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
trnm = msg.get("trnm", "")
|
||||
|
||||
if trnm == "PING":
|
||||
# 키움 PING → 그대로 echo (서버 정책)
|
||||
try:
|
||||
ws.send(message)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if trnm == "LOGIN":
|
||||
rc = msg.get("return_code")
|
||||
rm = msg.get("return_msg", "")
|
||||
if rc == 0:
|
||||
self._authenticated = True
|
||||
logger.info("✅ 키움 WS LOGIN OK")
|
||||
# 누적된 구독 일괄 등록
|
||||
with self._sub_lock:
|
||||
pending = list(self._subscribed)
|
||||
if pending:
|
||||
self._send_reg(pending)
|
||||
# 안정 연결 카운터 초기화 (지속 5분 이상 연결됐다면)
|
||||
# 여기선 LOGIN 직후라 의미 없음, _periodic_reset_ok() 에서 처리
|
||||
else:
|
||||
logger.warning("❌ 키움 WS LOGIN 실패 rc=%s msg=%s", rc, rm)
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if trnm == "REG":
|
||||
rc = msg.get("return_code")
|
||||
if rc != 0:
|
||||
logger.warning("⚠️ 키움 WS REG 실패: %s", msg.get("return_msg", ""))
|
||||
return
|
||||
|
||||
if trnm == "REMOVE":
|
||||
return # 무관심
|
||||
|
||||
if trnm == "REAL":
|
||||
self._handle_real(msg)
|
||||
return
|
||||
|
||||
def _handle_real(self, msg: dict) -> None:
|
||||
"""실시간 데이터 처리 — 0B 만 사용 (확장 가능)."""
|
||||
items = msg.get("data") or []
|
||||
for item in items:
|
||||
sub_type = str(item.get("type", "")).strip()
|
||||
if sub_type != self.SUB_TYPE:
|
||||
continue
|
||||
code = str(item.get("item", "")).strip()
|
||||
values = item.get("values") or {}
|
||||
self._cache_tick(code, values)
|
||||
|
||||
def _cache_tick(self, code: str, values: dict) -> None:
|
||||
"""0B 체결 → 메모리 캐시 갱신 (KIS 와 동일 포맷)."""
|
||||
if not code or not values:
|
||||
return
|
||||
try:
|
||||
# 키움은 가격 부호로 등락 표시 — 절대값 취함
|
||||
price_raw = values.get(self.FID_PRICE, "0")
|
||||
price = abs(float(str(price_raw).replace(",", "")))
|
||||
if price <= 0:
|
||||
return
|
||||
|
||||
def _abs_str(v: str) -> str:
|
||||
try:
|
||||
return str(int(abs(float(str(v).replace(",", "")))))
|
||||
except (ValueError, TypeError):
|
||||
return "0"
|
||||
|
||||
# KIS inquire_price 호환 필드 (스칼라 dict)
|
||||
data_compat = {
|
||||
"stck_prpr": str(int(price)),
|
||||
"stck_oprc": _abs_str(values.get(self.FID_OPEN, "0")),
|
||||
"stck_hgpr": _abs_str(values.get(self.FID_HIGH, "0")),
|
||||
"stck_lwpr": _abs_str(values.get(self.FID_LOW, "0")),
|
||||
"prdy_vrss": _abs_str(values.get(self.FID_CHANGE, "0")),
|
||||
"prdy_ctrt": str(values.get(self.FID_CHANGE_PCT, "0")),
|
||||
}
|
||||
with self._cache_lock:
|
||||
self._cache[code] = {"data": data_compat, "ts": time.time()}
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug("키움 0B 파싱 오류 %s: %s", code, e)
|
||||
|
||||
def _on_error(self, ws, error) -> None:
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
logger.warning("⚠️ 키움 WS 오류: %s", error)
|
||||
|
||||
def _on_close(self, ws, close_status_code, close_msg) -> None:
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
logger.info("🔌 키움 WS 연결 종료 (code=%s msg=%s)",
|
||||
close_status_code, close_msg or "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 내부: 등록/해지 메시지 발송
|
||||
# ------------------------------------------------------------------
|
||||
def _send_reg(self, codes: list) -> bool:
|
||||
"""REG 발송 (그룹 1, 0B 타입). 한 번에 여러 종목 OK."""
|
||||
if not codes or not self._ws:
|
||||
return False
|
||||
try:
|
||||
self._ws.send(json.dumps({
|
||||
"trnm": "REG",
|
||||
"grp_no": self.GROUP_NO,
|
||||
"refresh": "1", # 재시작 시 등록 유지
|
||||
"data": [{"item": list(codes), "type": [self.SUB_TYPE]}],
|
||||
}))
|
||||
logger.info("📡 키움 WS REG 발송: %d종목 (총 %d/%d)",
|
||||
len(codes), self.subscribed_count(),
|
||||
self.MAX_SUBSCRIPTIONS_PER_GROUP)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS REG 실패: %s", e)
|
||||
return False
|
||||
|
||||
def _send_remove(self, codes: list) -> bool:
|
||||
"""REMOVE 발송."""
|
||||
if not codes or not self._ws:
|
||||
return False
|
||||
try:
|
||||
self._ws.send(json.dumps({
|
||||
"trnm": "REMOVE",
|
||||
"grp_no": self.GROUP_NO,
|
||||
"data": [{"item": list(codes), "type": [self.SUB_TYPE]}],
|
||||
}))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS REMOVE 실패: %s", e)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 내부: 토큰
|
||||
# ------------------------------------------------------------------
|
||||
def _get_kiwoom_token(self) -> Optional[str]:
|
||||
"""``kis_ws.KiwoomTokenManager`` 싱글톤 풀 재사용."""
|
||||
try:
|
||||
from kis_ws import _get_kiwoom_token_cached # type: ignore
|
||||
except ImportError:
|
||||
logger.warning("kis_ws._get_kiwoom_token_cached import 실패")
|
||||
return None
|
||||
return _get_kiwoom_token_cached(self.app_key, self.app_secret, self.is_mock)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 내부: 재연결 백오프 (KIS WS 와 동일 정책)
|
||||
# ------------------------------------------------------------------
|
||||
def _reconnect_with_backoff(self) -> None:
|
||||
now = time.time()
|
||||
|
||||
# 5분 이상 안정 연결 후 끊긴 거면 카운터 초기화 (정상 재연결 케이스)
|
||||
if (self._last_connect_time > 0
|
||||
and now - self._last_connect_time >= self.STABLE_CONN_RESET_SEC):
|
||||
self._reconnect_count = 0
|
||||
self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC
|
||||
|
||||
# 시간당 한도 초과 → 1시간 대기
|
||||
cutoff = now - 3600
|
||||
self._reconnect_times = [t for t in self._reconnect_times if t > cutoff]
|
||||
if len(self._reconnect_times) >= self.MAX_RECONNECTS_PER_HOUR:
|
||||
wait = 3600 - (now - self._reconnect_times[0])
|
||||
logger.warning("⚠️ 키움 WS 시간당 재연결 한도 초과 → %ds 대기", int(wait))
|
||||
time.sleep(max(60.0, wait))
|
||||
return
|
||||
|
||||
# 총 한도 초과 → 비활성
|
||||
if self._reconnect_count >= self.MAX_RECONNECT_ATTEMPTS:
|
||||
logger.warning("⛔ 키움 WS 총 재연결 한도 초과 → 자동 비활성")
|
||||
self._running = False
|
||||
return
|
||||
|
||||
delay = min(self._reconnect_delay, self.RECONNECT_MAX_DELAY_SEC)
|
||||
logger.info("⏳ 키움 WS %ds 후 재연결 시도 (#%d)",
|
||||
int(delay), self._reconnect_count + 1)
|
||||
time.sleep(delay)
|
||||
self._reconnect_times.append(time.time())
|
||||
self._reconnect_count += 1
|
||||
self._reconnect_delay = min(self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC)
|
||||
#!/usr/bin/env python3
|
||||
"""[호환] 수정 금지 — ``kis_trader.ws.kiwoom_ws`` 만 편집하세요."""
|
||||
from kis_trader.ws.kiwoom_ws import * # noqa: F401,F403
|
||||
|
||||
Reference in New Issue
Block a user