0.3.9.5.a4
Incorporated fault-tolerant startup messages with proper fetching of commit messages
This commit is contained in:
parent
0038a1889c
commit
b780c4069e
2
bot.py
2
bot.py
@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
|
|||||||
|
|
||||||
# Version consists of:
|
# Version consists of:
|
||||||
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
||||||
VERSION = "0.3.9.5.a3"
|
VERSION = "0.3.9.5.a4"
|
||||||
|
|
||||||
# ---------- Env loading ----------
|
# ---------- Env loading ----------
|
||||||
|
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
# modules/common/boot_notice.py
|
# modules/common/boot_notice.py
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, urlencode
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from modules.common.settings import cfg
|
from modules.common.settings import cfg
|
||||||
|
|
||||||
# ---------------- Version helpers ----------------
|
# ---------- Version helpers ----------
|
||||||
|
|
||||||
_VERSION_RE = re.compile(r'\b\d+\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)?\b')
|
_VERSION_RE = re.compile(r'\b\d+\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)?\b')
|
||||||
|
|
||||||
@ -26,7 +24,6 @@ def _split_subject_body(full_message: str) -> tuple[str | None, str | None]:
|
|||||||
if not full_message:
|
if not full_message:
|
||||||
return None, None
|
return None, None
|
||||||
lines = [ln.rstrip() for ln in full_message.splitlines()]
|
lines = [ln.rstrip() for ln in full_message.splitlines()]
|
||||||
# subject = first non-empty line
|
|
||||||
subject = None
|
subject = None
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines) and subject is None:
|
while i < len(lines) and subject is None:
|
||||||
@ -37,19 +34,14 @@ def _split_subject_body(full_message: str) -> tuple[str | None, str | None]:
|
|||||||
return subject or None, (body or None)
|
return subject or None, (body or None)
|
||||||
|
|
||||||
def _cmp_versions(a: str | None, b: str | None) -> int:
|
def _cmp_versions(a: str | None, b: str | None) -> int:
|
||||||
"""
|
"""Compare 1.2.3.4.a2 style; if either missing, treat as equal (0)."""
|
||||||
Compare versions like 1.2.3.4.a2 (last segment may be alnum).
|
|
||||||
Returns -1 if a<b, 0 if equal/unknown, +1 if a>b.
|
|
||||||
If either is None, treat as equal to avoid false positives.
|
|
||||||
"""
|
|
||||||
if not a or not b:
|
if not a or not b:
|
||||||
return 0
|
return 0
|
||||||
pa = a.split('.')
|
pa, pb = a.split('.'), b.split('.')
|
||||||
pb = b.split('.')
|
|
||||||
while len(pa) < 5: pa.append('0')
|
while len(pa) < 5: pa.append('0')
|
||||||
while len(pb) < 5: pb.append('0')
|
while len(pb) < 5: pb.append('0')
|
||||||
|
|
||||||
def part_key(x: str):
|
def key(x: str):
|
||||||
if x.isdigit():
|
if x.isdigit():
|
||||||
return (int(x), '', 1)
|
return (int(x), '', 1)
|
||||||
m = re.match(r'(\d+)(.*)', x)
|
m = re.match(r'(\d+)(.*)', x)
|
||||||
@ -58,7 +50,7 @@ def _cmp_versions(a: str | None, b: str | None) -> int:
|
|||||||
return (0, x, 3)
|
return (0, x, 3)
|
||||||
|
|
||||||
for xa, xb in zip(pa, pb):
|
for xa, xb in zip(pa, pb):
|
||||||
ka, kb = part_key(xa), part_key(xb)
|
ka, kb = key(xa), key(xb)
|
||||||
if ka[0] != kb[0]:
|
if ka[0] != kb[0]:
|
||||||
return 1 if ka[0] > kb[0] else -1
|
return 1 if ka[0] > kb[0] else -1
|
||||||
if ka[2] != kb[2]:
|
if ka[2] != kb[2]:
|
||||||
@ -67,7 +59,7 @@ def _cmp_versions(a: str | None, b: str | None) -> int:
|
|||||||
return 1 if ka[1] > kb[1] else -1
|
return 1 if ka[1] > kb[1] else -1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# ---------------- Gitea helpers ----------------
|
# ---------- Gitea helpers ----------
|
||||||
|
|
||||||
def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
|
def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
@ -75,11 +67,10 @@ def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
|
|||||||
api_base = https://host/api/v1
|
api_base = https://host/api/v1
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
pr = urlparse(repo_url)
|
pr = urlparse(repo_url.strip().rstrip('/'))
|
||||||
parts = [p for p in pr.path.split('/') if p]
|
parts = [p for p in pr.path.split('/') if p]
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
owner = parts[0]
|
owner, repo = parts[0], parts[1]
|
||||||
repo = parts[1]
|
|
||||||
if repo.endswith('.git'):
|
if repo.endswith('.git'):
|
||||||
repo = repo[:-4]
|
repo = repo[:-4]
|
||||||
api_base = f"{pr.scheme}://{pr.netloc}/api/v1"
|
api_base = f"{pr.scheme}://{pr.netloc}/api/v1"
|
||||||
@ -88,68 +79,74 @@ def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
|
|||||||
pass
|
pass
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
async def _gitea_get_json(url: str, token: str | None, user: str | None, timeout_sec: int = 10):
|
def _auth_headers_from_cfg(r):
|
||||||
headers = {}
|
"""
|
||||||
if token and user:
|
Build Authorization header using SHAI_REPO_AHTOKEN (cfg: repo_ahtoken).
|
||||||
# Basic auth with user:token
|
The value may be raw; we prefix 'token ' if needed.
|
||||||
cred = base64.b64encode(f"{user}:{token}".encode()).decode()
|
Also supports SHAI_GITEA_TOKEN / SHAI_GITEA_USER as secondary.
|
||||||
headers['Authorization'] = f"Basic {cred}"
|
"""
|
||||||
elif token:
|
ahtoken = r.get('repo_ahtoken', '').strip() # SHAI_REPO_AHTOKEN
|
||||||
headers['Authorization'] = f"token {token}"
|
if ahtoken:
|
||||||
|
if not ahtoken.lower().startswith('token '):
|
||||||
|
ahtoken = f"token {ahtoken}"
|
||||||
|
return {"Authorization": ahtoken}
|
||||||
|
|
||||||
|
# Optional secondary envs for future private usage
|
||||||
|
tok = os.getenv("SHAI_GITEA_TOKEN", "").strip()
|
||||||
|
usr = os.getenv("SHAI_GITEA_USER", "").strip()
|
||||||
|
if tok and usr:
|
||||||
|
import base64
|
||||||
|
b64 = base64.b64encode(f"{usr}:{tok}".encode()).decode()
|
||||||
|
return {"Authorization": f"Basic {b64}"}
|
||||||
|
if tok:
|
||||||
|
return {"Authorization": f"token {tok}"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _http_json(url: str, headers: dict, timeout_sec: int = 10):
|
||||||
timeout = aiohttp.ClientTimeout(total=timeout_sec)
|
timeout = aiohttp.ClientTimeout(total=timeout_sec)
|
||||||
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as sess:
|
async with aiohttp.ClientSession(timeout=timeout, headers=headers or {}) as sess:
|
||||||
async with sess.get(url) as resp:
|
async with sess.get(url) as resp:
|
||||||
|
ctype = resp.headers.get("Content-Type", "")
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise RuntimeError(f"Gitea GET {url} -> {resp.status}: {text[:200]}")
|
raise RuntimeError(f"Gitea GET {url} -> {resp.status} ({ctype}): {text[:200]}")
|
||||||
# Gitea returns either dict or list depending on endpoint
|
|
||||||
ctype = resp.headers.get("Content-Type", "")
|
|
||||||
if "application/json" not in ctype:
|
if "application/json" not in ctype:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise RuntimeError(f"Gitea GET {url} non-JSON: {ctype} {text[:200]}")
|
raise RuntimeError(f"Gitea GET {url} non-JSON {ctype}: {text[:200]}")
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
|
|
||||||
async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str,
|
async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str | None,
|
||||||
token: str | None, user: str | None) -> tuple[str | None, str | None, str | None]:
|
headers: dict) -> tuple[str | None, str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Returns (sha, subject, body) for the latest commit on branch using the
|
Returns (sha, subject, body) for the latest commit using the list-commits endpoint:
|
||||||
list-commits endpoint which includes the full commit message:
|
/api/v1/repos/{owner}/{repo}/commits?sha=main&stat=false&verification=false&files=false&limit=1
|
||||||
/api/v1/repos/{owner}/{repo}/commits?sha={branch}&limit=1&stat=false&verification=false&files=false
|
If branch is falsy, omits 'sha' and lets server default.
|
||||||
"""
|
"""
|
||||||
url = (
|
params = {
|
||||||
f"{api_base}/repos/{owner}/{repo}/commits"
|
"stat": "false",
|
||||||
f"?sha={branch}&limit=1&stat=false&verification=false&files=false"
|
"verification": "false",
|
||||||
)
|
"files": "false",
|
||||||
data = await _gitea_get_json(url, token, user)
|
"limit": "1",
|
||||||
|
}
|
||||||
|
if branch:
|
||||||
|
params["sha"] = branch
|
||||||
|
url = f"{api_base}/repos/{owner}/{repo}/commits?{urlencode(params)}"
|
||||||
|
|
||||||
|
data = await _http_json(url, headers)
|
||||||
if not isinstance(data, list) or not data:
|
if not isinstance(data, list) or not data:
|
||||||
raise RuntimeError("Commits list empty or invalid.")
|
raise RuntimeError("Commits list empty or invalid")
|
||||||
|
|
||||||
latest = data[0]
|
latest = data[0]
|
||||||
sha = latest.get("sha") or latest.get("id")
|
sha = latest.get("sha") or latest.get("id")
|
||||||
message = ""
|
message = ""
|
||||||
commit_obj = latest.get("commit") or {}
|
commit_obj = latest.get("commit") or {}
|
||||||
if isinstance(commit_obj, dict):
|
if isinstance(commit_obj, dict):
|
||||||
message = commit_obj.get("message") or ""
|
message = commit_obj.get("message") or ""
|
||||||
if not message:
|
|
||||||
# Extremely unlikely on this endpoint, but try secondary fetch
|
subject, body = _split_subject_body(message or "")
|
||||||
if sha:
|
|
||||||
for endpoint in (
|
|
||||||
f"{api_base}/repos/{owner}/{repo}/git/commits/{sha}",
|
|
||||||
f"{api_base}/repos/{owner}/{repo}/commits/{sha}",
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
det = await _gitea_get_json(endpoint, token, user)
|
|
||||||
if isinstance(det, dict):
|
|
||||||
m = det.get("message") or det.get("commit", {}).get("message")
|
|
||||||
if m:
|
|
||||||
message = str(m)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
subject, body = _split_subject_body(message)
|
|
||||||
return sha, (subject or ""), (body or "")
|
return sha, (subject or ""), (body or "")
|
||||||
|
|
||||||
# ---------------- Boot reason inference ----------------
|
# ---------- Boot reason inference ----------
|
||||||
|
|
||||||
def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int = 5) -> bool:
|
def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int = 5) -> bool:
|
||||||
if not hhmm_utc:
|
if not hhmm_utc:
|
||||||
@ -159,8 +156,7 @@ def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
sched = now_utc.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
sched = now_utc.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
||||||
delta = abs((now_utc - sched).total_seconds())
|
return abs((now_utc - sched).total_seconds()) <= window_min * 60
|
||||||
return delta <= window_min * 60
|
|
||||||
|
|
||||||
def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) -> str:
|
def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) -> str:
|
||||||
if kind == "updated":
|
if kind == "updated":
|
||||||
@ -173,25 +169,20 @@ def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) ->
|
|||||||
return f"⚠️ Version rollback detected: **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**"
|
return f"⚠️ Version rollback detected: **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**"
|
||||||
return "🟢 Bot started"
|
return "🟢 Bot started"
|
||||||
|
|
||||||
# ---------------- Main entry ----------------
|
# ---------- Main entry ----------
|
||||||
|
|
||||||
async def post_boot_notice(bot):
|
async def post_boot_notice(bot):
|
||||||
"""
|
"""
|
||||||
Always post a boot status to the modlog channel.
|
Always posts a startup status + the latest commit message (Version + md body)
|
||||||
- Waits for ready.
|
to the modlog channel. Uses SHAI_REPO_URL and SHAI_REPO_AHTOKEN via cfg().
|
||||||
- Infers reason (updated/scheduled/manual/rollback).
|
|
||||||
- Fetches latest commit (full message) via list-commits endpoint.
|
|
||||||
- Posts status + commit message (Version bold + md details).
|
|
||||||
- Pings guild owner only on rollback.
|
|
||||||
- Persists last sha/version in data_manager['boot_state'].
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await bot.wait_until_ready()
|
await bot.wait_until_ready()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[boot_notice] wait_until_ready failed: {e}")
|
print(f"[boot_notice] wait_until_ready failed: {e}")
|
||||||
|
|
||||||
# Resolve modlog channel
|
r = cfg(bot)
|
||||||
modlog_channel_id = cfg(bot).int('modlog_channel_id', 0)
|
modlog_channel_id = r.int('modlog_channel_id', 0)
|
||||||
if not modlog_channel_id:
|
if not modlog_channel_id:
|
||||||
print("[boot_notice] modlog_channel_id not configured; skipping.")
|
print("[boot_notice] modlog_channel_id not configured; skipping.")
|
||||||
return
|
return
|
||||||
@ -206,37 +197,34 @@ async def post_boot_notice(bot):
|
|||||||
print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.")
|
print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Repo info
|
repo_url = r.get('repo_url', '') # SHAI_REPO_URL
|
||||||
r = cfg(bot)
|
# Branch optional; if empty, we omit 'sha='
|
||||||
repo_url = r.get('repo_url', '')
|
branch = r.get('repo_branch', 'main') or None # SHAI_REPO_BRANCH (optional)
|
||||||
branch = r.get('repo_branch', 'main')
|
check_time_utc = r.get('check_time_utc', '') # SHAI_CHECK_TIME_UTC (optional, e.g. "03:00")
|
||||||
check_time_utc = r.get('check_time_utc', '') # e.g., "03:00"
|
headers = _auth_headers_from_cfg(r)
|
||||||
now_utc = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
api_base = owner = repo = None
|
api_base = owner = repo = None
|
||||||
if repo_url:
|
if repo_url:
|
||||||
api_base, owner, repo = _parse_repo_url(repo_url)
|
api_base, owner, repo = _parse_repo_url(repo_url)
|
||||||
|
if not all([api_base, owner, repo]):
|
||||||
|
print(f"[boot_notice] failed to parse repo_url={repo_url!r}")
|
||||||
else:
|
else:
|
||||||
print("[boot_notice] repo_url missing; commit lookup skipped.")
|
print("[boot_notice] repo_url missing; commit lookup skipped.")
|
||||||
|
|
||||||
token = os.getenv("SHAI_GITEA_TOKEN", "").strip() or None
|
|
||||||
user = os.getenv("SHAI_GITEA_USER", "").strip() or None
|
|
||||||
|
|
||||||
# State
|
# State
|
||||||
dm = getattr(bot, "data_manager", None)
|
dm = getattr(bot, "data_manager", None)
|
||||||
if not dm:
|
if not dm:
|
||||||
print("[boot_notice] data_manager missing on bot; cannot persist state.")
|
print("[boot_notice] data_manager missing on bot; cannot persist state.")
|
||||||
return
|
return
|
||||||
|
|
||||||
prev = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {}
|
prev = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {}
|
||||||
prev_sha = prev.get('last_sha') or None
|
prev_sha = prev.get('last_sha') or None
|
||||||
prev_ver = prev.get('last_version') or None
|
prev_ver = prev.get('last_version') or None
|
||||||
|
|
||||||
# Fetch latest commit (sha, subject, body)
|
# Fetch latest commit
|
||||||
sha = subject = body = None
|
sha = subject = body = None
|
||||||
if api_base and owner and repo:
|
if api_base and owner and repo:
|
||||||
try:
|
try:
|
||||||
sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, token, user)
|
sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, headers)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[boot_notice] fetch latest commit failed: {e}")
|
print(f"[boot_notice] fetch latest commit failed: {e}")
|
||||||
else:
|
else:
|
||||||
@ -245,34 +233,31 @@ async def post_boot_notice(bot):
|
|||||||
curr_ver = _extract_version(subject) if subject else None
|
curr_ver = _extract_version(subject) if subject else None
|
||||||
|
|
||||||
# Decide reason
|
# Decide reason
|
||||||
reason = "manual"
|
now_utc = datetime.now(timezone.utc)
|
||||||
mention_owner = False
|
|
||||||
|
|
||||||
if prev_ver and curr_ver:
|
if prev_ver and curr_ver:
|
||||||
cmpv = _cmp_versions(prev_ver, curr_ver)
|
cmpv = _cmp_versions(prev_ver, curr_ver)
|
||||||
if cmpv < 0:
|
if cmpv < 0:
|
||||||
reason = "updated"
|
reason, ping_owner = "updated", False
|
||||||
elif cmpv > 0:
|
elif cmpv > 0:
|
||||||
reason = "rollback"
|
reason, ping_owner = "rollback", True
|
||||||
mention_owner = True
|
|
||||||
else:
|
else:
|
||||||
reason = "scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"
|
reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
|
||||||
else:
|
else:
|
||||||
if prev_sha and sha and prev_sha != sha:
|
if prev_sha and sha and prev_sha != sha:
|
||||||
reason = "updated"
|
reason, ping_owner = "updated", False
|
||||||
else:
|
else:
|
||||||
reason = "scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"
|
reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
|
||||||
|
|
||||||
# Post status line
|
# Post status
|
||||||
status_line = _format_status_line(reason, prev_ver, curr_ver)
|
status_line = _format_status_line(reason, prev_ver, curr_ver)
|
||||||
try:
|
try:
|
||||||
allowed = discord.AllowedMentions(
|
allowed = discord.AllowedMentions(
|
||||||
everyone=False,
|
everyone=False,
|
||||||
users=True if mention_owner else False,
|
users=True if (ping_owner and ch.guild and ch.guild.owner_id) else False,
|
||||||
roles=False,
|
roles=False,
|
||||||
replied_user=False
|
replied_user=False
|
||||||
)
|
)
|
||||||
if mention_owner and ch.guild and ch.guild.owner_id:
|
if ping_owner and ch.guild and ch.guild.owner_id:
|
||||||
status_line = f"{status_line}\n<@{ch.guild.owner_id}>"
|
status_line = f"{status_line}\n<@{ch.guild.owner_id}>"
|
||||||
await ch.send(status_line, allowed_mentions=allowed)
|
await ch.send(status_line, allowed_mentions=allowed)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -281,7 +266,7 @@ async def post_boot_notice(bot):
|
|||||||
|
|
||||||
# Post commit message (Version + md details)
|
# Post commit message (Version + md details)
|
||||||
try:
|
try:
|
||||||
title = curr_ver or (subject or "Latest commit")
|
title = (curr_ver or subject or "Latest commit").strip()
|
||||||
if title or body:
|
if title or body:
|
||||||
commit_msg = f"**{title}**\n{body}" if body else f"**{title}**"
|
commit_msg = f"**{title}**\n{body}" if body else f"**{title}**"
|
||||||
await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())
|
await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())
|
||||||
@ -290,12 +275,11 @@ async def post_boot_notice(bot):
|
|||||||
|
|
||||||
# Persist state
|
# Persist state
|
||||||
try:
|
try:
|
||||||
new_state = {
|
dm.add('boot_state', {
|
||||||
'last_sha': sha,
|
'last_sha': sha,
|
||||||
'last_version': curr_ver,
|
'last_version': curr_ver,
|
||||||
'last_subject': subject,
|
'last_subject': subject,
|
||||||
'last_boot_ts': time.time(),
|
'last_boot_ts': time.time(),
|
||||||
}
|
})
|
||||||
dm.add('boot_state', new_state)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[boot_notice] failed to persist boot_state: {e}")
|
print(f"[boot_notice] failed to persist boot_state: {e}")
|
||||||
|
Loading…
Reference in New Issue
Block a user