From ab4ee3c044d354e4c19f58911bf83a0d40ea64be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 03:55:43 +0000 Subject: [PATCH 1/2] Add Supabase persistence for members, violations & join requests Replaces the in-memory _warnings dict in moderation with a Supabase-backed data layer so moderation state survives bot restarts. - New services/db.py: cached Supabase client plus helpers for warning counts, violation logging, member upserts, and join-request audit logs. Falls back to an in-memory counter when Supabase is unconfigured so the bot and tests still run without a database. - moderation.py: derive warning count from the DB and log each warn/mute/ban as a violation. - join_requests.py: record every approve/decline decision and upsert approved members for the audit trail. - admin_commands.py + main.py: add /violations so admins can query a user's violation history. - infra/schema.sql: idempotent DDL for members, violations, join_requests. - Drop stale welcome_message assertions left over from the welcome refactor. Closes #1 --- README.md | 22 ++++- infra/schema.sql | 39 +++++++++ src/handlers/admin_commands.py | 36 ++++++++ src/handlers/join_requests.py | 14 ++++ src/handlers/moderation.py | 17 ++-- src/main.py | 3 +- src/services/db.py | 149 +++++++++++++++++++++++++++++++++ tests/test_db.py | 57 +++++++++++++ tests/test_spam_checker.py | 8 -- 9 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 infra/schema.sql create mode 100644 src/services/db.py create mode 100644 tests/test_db.py diff --git a/README.md b/README.md index 77fb902..b25bfa7 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,30 @@ src/ ├── main.py # Entry point, handler registration ├── config.py # Environment & settings ├── handlers/ -│ ├── join_requests.py # Auto-approve/decline logic +│ ├── join_requests.py # Auto-approve/decline logic + audit logging │ ├── moderation.py # Spam detection & enforcement -│ └── admin_commands.py # /start, /stats, /approve_all +│ └── admin_commands.py # /start, /stats, /approve_all, /violations ├── services/ -│ └── spam_checker.py # Heuristic + AI spam assessment +│ ├── spam_checker.py # Heuristic + AI spam assessment +│ └── db.py # Supabase access (members, violations, join_requests) └── models/ # DB models (Phase 2) + +infra/ +└── schema.sql # Supabase tables & migrations ``` +### Database + +Moderation state (warnings, violations) and the join-request audit trail are +persisted in [Supabase](https://supabase.com). Apply the schema once: + +```bash +psql "$SUPABASE_DB_URL" -f infra/schema.sql # or paste into the Supabase SQL editor +``` + +Set `SUPABASE_URL` and `SUPABASE_KEY` in `.env`. If they are unset, the bot +still runs but moderation counts are kept in memory and reset on restart. + ## Deployment Designed for serverless (AWS Lambda + webhook) but runs in polling mode for development. See `infra/` for deployment configs (coming soon). diff --git a/infra/schema.sql b/infra/schema.sql new file mode 100644 index 0000000..8614b47 --- /dev/null +++ b/infra/schema.sql @@ -0,0 +1,39 @@ +-- Schema for TIHelperBot member tracking & moderation logs. +-- +-- Apply with the Supabase SQL editor, or: +-- psql "$SUPABASE_DB_URL" -f infra/schema.sql +-- +-- Tables are created idempotently so this file doubles as the migration. + +-- Members approved into the community. +create table if not exists members ( + user_id bigint primary key, + username text, + join_date timestamptz not null default now(), + approved_by text check (approved_by in ('bot', 'manual')), + spam_score real +); + +-- Moderation violations (warn / mute / ban events). +create table if not exists violations ( + id bigserial primary key, + user_id bigint not null, + message_text text, + violation_type text, + action_taken text, + timestamp timestamptz not null default now() +); + +create index if not exists violations_user_id_idx on violations (user_id); + +-- Audit log of join request decisions. +create table if not exists join_requests ( + id bigserial primary key, + user_id bigint not null, + assessment_score real, + approved boolean not null, + reason text, + timestamp timestamptz not null default now() +); + +create index if not exists join_requests_user_id_idx on join_requests (user_id); diff --git a/src/handlers/admin_commands.py b/src/handlers/admin_commands.py index d2bc037..d6df972 100644 --- a/src/handlers/admin_commands.py +++ b/src/handlers/admin_commands.py @@ -1,6 +1,7 @@ from telegram import Update from telegram.ext import ContextTypes from services.help import render_help +from services import db _GROUP_CHAT_TYPES = ("group", "supergroup") @@ -41,6 +42,41 @@ async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ) +async def violations(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + chat = update.effective_chat + member_status = "" + if chat.type in _GROUP_CHAT_TYPES: + member = await chat.get_member(update.effective_user.id) + member_status = member.status + + if not _is_admin(chat.type, member_status): + await update.message.reply_text("⛔ Admin only.") + return + + if not context.args: + await update.message.reply_text("Usage: /violations ") + return + + try: + target_id = int(context.args[0]) + except ValueError: + await update.message.reply_text("Please provide a numeric user_id.") + return + + records = db.get_violations(target_id) + if not records: + await update.message.reply_text(f"No violations recorded for user {target_id}.") + return + + lines = [f"📋 Violations for user {target_id}:"] + for r in records: + lines.append( + f"• [{r.get('action_taken', '?')}] " + f"{r.get('violation_type', '?')} — {r.get('timestamp', '?')}" + ) + await update.message.reply_text("\n".join(lines)) + + async def approve_all(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat member = await chat.get_member(update.effective_user.id) diff --git a/src/handlers/join_requests.py b/src/handlers/join_requests.py index 019d7f4..e4ec1c1 100644 --- a/src/handlers/join_requests.py +++ b/src/handlers/join_requests.py @@ -3,6 +3,7 @@ from telegram.ext import ContextTypes from services.spam_checker import assess_user from services.welcome import render_welcome +from services import db logger = logging.getLogger(__name__) @@ -13,8 +14,21 @@ async def handle_join_request(update: Update, context: ContextTypes.DEFAULT_TYPE assessment = await assess_user(user) + db.record_join_request( + user_id=user.id, + assessment_score=assessment["score"], + approved=assessment["approved"], + reason=assessment["reason"], + ) + if assessment["approved"]: await request.approve() + db.record_member( + user_id=user.id, + username=user.username, + approved_by="bot", + spam_score=assessment["score"], + ) logger.info(f"Auto-approved user {user.id} (@{user.username}) — score: {assessment['score']:.2f}") try: diff --git a/src/handlers/moderation.py b/src/handlers/moderation.py index 196a4a2..9f43182 100644 --- a/src/handlers/moderation.py +++ b/src/handlers/moderation.py @@ -2,13 +2,11 @@ from telegram import Update from telegram.ext import ContextTypes from services.spam_checker import assess_message +from services import db from config import MAX_WARNINGS logger = logging.getLogger(__name__) -# In-memory warning tracker (replace with DB in production) -_warnings: dict[int, int] = {} - async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message @@ -24,14 +22,15 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return user_id = user.id - _warnings[user_id] = _warnings.get(user_id, 0) + 1 - count = _warnings[user_id] + count = db.get_warning_count(user_id) + 1 if count > MAX_WARNINGS: + action = "ban" await chat.ban_member(user_id) await message.delete() logger.info(f"Banned user {user_id} (@{user.username}) — exceeded warning limit") elif count == MAX_WARNINGS: + action = "mute" await message.delete() await chat.restrict_member(user_id, permissions=_muted_permissions()) await message.reply_text( @@ -40,6 +39,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) logger.info(f"Muted user {user_id} (@{user.username}) — warning {count}") else: + action = "warn" await message.delete() try: await context.bot.send_message( @@ -55,6 +55,13 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> pass logger.info(f"Warned user {user_id} (@{user.username}) — warning {count}/{MAX_WARNINGS}") + db.record_violation( + user_id=user_id, + message_text=message.text, + violation_type=assessment["reason"], + action_taken=action, + ) + def _muted_permissions(): from telegram import ChatPermissions diff --git a/src/main.py b/src/main.py index 5b40aeb..bf9480b 100644 --- a/src/main.py +++ b/src/main.py @@ -10,7 +10,7 @@ from config import TELEGRAM_BOT_TOKEN from handlers.join_requests import handle_join_request from handlers.moderation import handle_message -from handlers.admin_commands import start, stats, approve_all, help_command +from handlers.admin_commands import start, stats, approve_all, help_command, violations logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -26,6 +26,7 @@ def main() -> None: app.add_handler(CommandHandler("help", help_command)) app.add_handler(CommandHandler("stats", stats)) app.add_handler(CommandHandler("approve_all", approve_all)) + app.add_handler(CommandHandler("violations", violations)) app.add_handler(ChatJoinRequestHandler(handle_join_request)) diff --git a/src/services/db.py b/src/services/db.py new file mode 100644 index 0000000..6217c9a --- /dev/null +++ b/src/services/db.py @@ -0,0 +1,149 @@ +"""Supabase data-access layer for member tracking and moderation logs. + +The bot must stay runnable without a database (local development, CI, and the +existing test suite all run without Supabase credentials). When Supabase is not +configured — or a query fails — these helpers fall back to an in-memory warning +counter so moderation still escalates within a single process. Persistence +across restarts only applies when Supabase is configured. +""" + +from __future__ import annotations + +import logging +from functools import lru_cache +from typing import Any + +from config import SUPABASE_KEY, SUPABASE_URL + +logger = logging.getLogger(__name__) + +# Per-process fallback warning counts, used only when Supabase is unavailable. +_warning_fallback: dict[int, int] = {} + + +@lru_cache(maxsize=1) +def get_client(): + """Return a cached Supabase client, or ``None`` when not configured.""" + if not SUPABASE_URL or not SUPABASE_KEY: + return None + try: + from supabase import create_client + + return create_client(SUPABASE_URL, SUPABASE_KEY) + except Exception as exc: # pragma: no cover - network/credential failures + logger.warning(f"Failed to initialize Supabase client: {exc}") + return None + + +def is_enabled() -> bool: + """Whether a Supabase connection is available.""" + return get_client() is not None + + +def get_warning_count(user_id: int) -> int: + """Return how many violations have been recorded for ``user_id``.""" + client = get_client() + if client is None: + return _warning_fallback.get(user_id, 0) + try: + resp = ( + client.table("violations") + .select("id", count="exact") + .eq("user_id", user_id) + .execute() + ) + return resp.count or 0 + except Exception as exc: + logger.warning(f"DB get_warning_count failed for {user_id}: {exc}") + return _warning_fallback.get(user_id, 0) + + +def record_violation( + user_id: int, + message_text: str, + violation_type: str, + action_taken: str, +) -> None: + """Log a moderation violation (warn / mute / ban).""" + client = get_client() + if client is None: + _warning_fallback[user_id] = _warning_fallback.get(user_id, 0) + 1 + return + try: + client.table("violations").insert( + { + "user_id": user_id, + "message_text": message_text, + "violation_type": violation_type, + "action_taken": action_taken, + } + ).execute() + except Exception as exc: + logger.warning(f"DB record_violation failed for {user_id}: {exc}") + _warning_fallback[user_id] = _warning_fallback.get(user_id, 0) + 1 + + +def get_violations(user_id: int, limit: int = 50) -> list[dict[str, Any]]: + """Return recorded violations for ``user_id``, most recent first.""" + client = get_client() + if client is None: + return [] + try: + resp = ( + client.table("violations") + .select("*") + .eq("user_id", user_id) + .order("timestamp", desc=True) + .limit(limit) + .execute() + ) + return resp.data or [] + except Exception as exc: + logger.warning(f"DB get_violations failed for {user_id}: {exc}") + return [] + + +def record_member( + user_id: int, + username: str | None, + approved_by: str, + spam_score: float, +) -> None: + """Upsert a member record after a join request is approved.""" + client = get_client() + if client is None: + return + try: + client.table("members").upsert( + { + "user_id": user_id, + "username": username, + "approved_by": approved_by, + "spam_score": spam_score, + } + ).execute() + except Exception as exc: + logger.warning(f"DB record_member failed for {user_id}: {exc}") + + +def record_join_request( + user_id: int, + assessment_score: float, + approved: bool, + reason: str, +) -> None: + """Log a join request decision for the audit trail.""" + client = get_client() + if client is None: + return + try: + client.table("join_requests").insert( + { + "user_id": user_id, + "assessment_score": assessment_score, + "approved": approved, + "reason": reason, + } + ).execute() + except Exception as exc: + logger.warning(f"DB record_join_request failed for {user_id}: {exc}") diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..6162aac --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,57 @@ +"""Tests for the db service fallback behavior (no Supabase configured). + +conftest.py runs without SUPABASE_URL / SUPABASE_KEY, so get_client() returns +None and the in-memory fallback path is exercised here. +""" + +import pytest + +from services import db + + +@pytest.fixture(autouse=True) +def reset_fallback(): + db._warning_fallback.clear() + db.get_client.cache_clear() + yield + db._warning_fallback.clear() + db.get_client.cache_clear() + + +def test_disabled_without_credentials(): + assert db.is_enabled() is False + assert db.get_client() is None + + +def test_warning_count_starts_at_zero(): + assert db.get_warning_count(123) == 0 + + +def test_record_violation_increments_fallback_count(): + db.record_violation(123, "buy crypto now", "matched: crypto", "warn") + assert db.get_warning_count(123) == 1 + + db.record_violation(123, "still spamming", "matched: crypto", "mute") + assert db.get_warning_count(123) == 2 + + +def test_warning_counts_are_per_user(): + db.record_violation(1, "spam", "promo", "warn") + db.record_violation(2, "spam", "promo", "warn") + db.record_violation(2, "spam", "promo", "mute") + + assert db.get_warning_count(1) == 1 + assert db.get_warning_count(2) == 2 + assert db.get_warning_count(999) == 0 + + +def test_get_violations_empty_without_db(): + db.record_violation(5, "spam", "promo", "warn") + # Violation history requires Supabase; fallback keeps counts only. + assert db.get_violations(5) == [] + + +def test_record_member_and_join_request_are_noops_without_db(): + # Should not raise when Supabase is unavailable. + db.record_member(7, "user7", "bot", 0.1) + db.record_join_request(7, 0.1, True, "passed checks") diff --git a/tests/test_spam_checker.py b/tests/test_spam_checker.py index 64c7e57..82efe4c 100644 --- a/tests/test_spam_checker.py +++ b/tests/test_spam_checker.py @@ -54,7 +54,6 @@ async def test_normal_user_is_approved(): result = await assess_user(user) assert result["approved"] is True assert result["score"] == 0.0 - assert "welcome_message" in result async def test_no_username_and_short_name_cumulative_score(): @@ -65,13 +64,6 @@ async def test_no_username_and_short_name_cumulative_score(): assert result["approved"] is True -async def test_welcome_message_contains_user_name(): - user = make_user(username="devguy", first_name="David") - result = await assess_user(user) - assert result["approved"] is True - assert "David" in result["welcome_message"] - - async def test_no_welcome_message_when_rejected(): user = make_user(is_bot=True) result = await assess_user(user) From 56a58da05fcb005f80ee25f39e61a50186ff81ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 04:35:31 +0000 Subject: [PATCH 2/2] Address review: non-blocking DB I/O and tighter warning-count flow - db.py: dispatch the synchronous Supabase calls through asyncio.to_thread so network I/O no longer blocks the bot's event loop; public helpers are now async and awaited by callers. - get_warning_count: add limit(1) so count="exact" no longer pulls back every matching row just to read the count. - moderation.py: decide the action, then record the violation before enforcing it, so the warning count advances even if a Telegram call fails and the read/record window stays small. - Update join_requests, admin_commands, and tests to await the async helpers. --- src/handlers/admin_commands.py | 2 +- src/handlers/join_requests.py | 4 +-- src/handlers/moderation.py | 28 +++++++++++-------- src/services/db.py | 51 ++++++++++++++++++++++++++-------- tests/test_db.py | 40 +++++++++++++------------- 5 files changed, 79 insertions(+), 46 deletions(-) diff --git a/src/handlers/admin_commands.py b/src/handlers/admin_commands.py index d6df972..38577df 100644 --- a/src/handlers/admin_commands.py +++ b/src/handlers/admin_commands.py @@ -63,7 +63,7 @@ async def violations(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None await update.message.reply_text("Please provide a numeric user_id.") return - records = db.get_violations(target_id) + records = await db.get_violations(target_id) if not records: await update.message.reply_text(f"No violations recorded for user {target_id}.") return diff --git a/src/handlers/join_requests.py b/src/handlers/join_requests.py index e4ec1c1..dc6bca0 100644 --- a/src/handlers/join_requests.py +++ b/src/handlers/join_requests.py @@ -14,7 +14,7 @@ async def handle_join_request(update: Update, context: ContextTypes.DEFAULT_TYPE assessment = await assess_user(user) - db.record_join_request( + await db.record_join_request( user_id=user.id, assessment_score=assessment["score"], approved=assessment["approved"], @@ -23,7 +23,7 @@ async def handle_join_request(update: Update, context: ContextTypes.DEFAULT_TYPE if assessment["approved"]: await request.approve() - db.record_member( + await db.record_member( user_id=user.id, username=user.username, approved_by="bot", diff --git a/src/handlers/moderation.py b/src/handlers/moderation.py index 9f43182..87788a0 100644 --- a/src/handlers/moderation.py +++ b/src/handlers/moderation.py @@ -22,15 +22,29 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return user_id = user.id - count = db.get_warning_count(user_id) + 1 + count = (await db.get_warning_count(user_id)) + 1 if count > MAX_WARNINGS: action = "ban" + elif count == MAX_WARNINGS: + action = "mute" + else: + action = "warn" + + # Record the violation before enforcing, so the warning count advances even + # if a Telegram API call below fails, and to keep the read/record window small. + await db.record_violation( + user_id=user_id, + message_text=message.text, + violation_type=assessment["reason"], + action_taken=action, + ) + + if action == "ban": await chat.ban_member(user_id) await message.delete() logger.info(f"Banned user {user_id} (@{user.username}) — exceeded warning limit") - elif count == MAX_WARNINGS: - action = "mute" + elif action == "mute": await message.delete() await chat.restrict_member(user_id, permissions=_muted_permissions()) await message.reply_text( @@ -39,7 +53,6 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) logger.info(f"Muted user {user_id} (@{user.username}) — warning {count}") else: - action = "warn" await message.delete() try: await context.bot.send_message( @@ -55,13 +68,6 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> pass logger.info(f"Warned user {user_id} (@{user.username}) — warning {count}/{MAX_WARNINGS}") - db.record_violation( - user_id=user_id, - message_text=message.text, - violation_type=assessment["reason"], - action_taken=action, - ) - def _muted_permissions(): from telegram import ChatPermissions diff --git a/src/services/db.py b/src/services/db.py index 6217c9a..5593458 100644 --- a/src/services/db.py +++ b/src/services/db.py @@ -5,10 +5,14 @@ configured — or a query fails — these helpers fall back to an in-memory warning counter so moderation still escalates within a single process. Persistence across restarts only applies when Supabase is configured. + +Supabase's client is synchronous, so the blocking calls are dispatched to a +worker thread via ``asyncio.to_thread`` to avoid stalling the bot's event loop. """ from __future__ import annotations +import asyncio import logging from functools import lru_cache from typing import Any @@ -40,25 +44,32 @@ def is_enabled() -> bool: return get_client() is not None -def get_warning_count(user_id: int) -> int: +async def get_warning_count(user_id: int) -> int: """Return how many violations have been recorded for ``user_id``.""" client = get_client() if client is None: return _warning_fallback.get(user_id, 0) - try: - resp = ( + + def _query(): + # count="exact" reports the full match count; limit(1) avoids pulling + # every matching row back just to read that count. + return ( client.table("violations") .select("id", count="exact") .eq("user_id", user_id) + .limit(1) .execute() ) + + try: + resp = await asyncio.to_thread(_query) return resp.count or 0 except Exception as exc: logger.warning(f"DB get_warning_count failed for {user_id}: {exc}") return _warning_fallback.get(user_id, 0) -def record_violation( +async def record_violation( user_id: int, message_text: str, violation_type: str, @@ -69,7 +80,8 @@ def record_violation( if client is None: _warning_fallback[user_id] = _warning_fallback.get(user_id, 0) + 1 return - try: + + def _insert(): client.table("violations").insert( { "user_id": user_id, @@ -78,18 +90,22 @@ def record_violation( "action_taken": action_taken, } ).execute() + + try: + await asyncio.to_thread(_insert) except Exception as exc: logger.warning(f"DB record_violation failed for {user_id}: {exc}") _warning_fallback[user_id] = _warning_fallback.get(user_id, 0) + 1 -def get_violations(user_id: int, limit: int = 50) -> list[dict[str, Any]]: +async def get_violations(user_id: int, limit: int = 50) -> list[dict[str, Any]]: """Return recorded violations for ``user_id``, most recent first.""" client = get_client() if client is None: return [] - try: - resp = ( + + def _query(): + return ( client.table("violations") .select("*") .eq("user_id", user_id) @@ -97,13 +113,16 @@ def get_violations(user_id: int, limit: int = 50) -> list[dict[str, Any]]: .limit(limit) .execute() ) + + try: + resp = await asyncio.to_thread(_query) return resp.data or [] except Exception as exc: logger.warning(f"DB get_violations failed for {user_id}: {exc}") return [] -def record_member( +async def record_member( user_id: int, username: str | None, approved_by: str, @@ -113,7 +132,8 @@ def record_member( client = get_client() if client is None: return - try: + + def _upsert(): client.table("members").upsert( { "user_id": user_id, @@ -122,11 +142,14 @@ def record_member( "spam_score": spam_score, } ).execute() + + try: + await asyncio.to_thread(_upsert) except Exception as exc: logger.warning(f"DB record_member failed for {user_id}: {exc}") -def record_join_request( +async def record_join_request( user_id: int, assessment_score: float, approved: bool, @@ -136,7 +159,8 @@ def record_join_request( client = get_client() if client is None: return - try: + + def _insert(): client.table("join_requests").insert( { "user_id": user_id, @@ -145,5 +169,8 @@ def record_join_request( "reason": reason, } ).execute() + + try: + await asyncio.to_thread(_insert) except Exception as exc: logger.warning(f"DB record_join_request failed for {user_id}: {exc}") diff --git a/tests/test_db.py b/tests/test_db.py index 6162aac..54729ee 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -23,35 +23,35 @@ def test_disabled_without_credentials(): assert db.get_client() is None -def test_warning_count_starts_at_zero(): - assert db.get_warning_count(123) == 0 +async def test_warning_count_starts_at_zero(): + assert await db.get_warning_count(123) == 0 -def test_record_violation_increments_fallback_count(): - db.record_violation(123, "buy crypto now", "matched: crypto", "warn") - assert db.get_warning_count(123) == 1 +async def test_record_violation_increments_fallback_count(): + await db.record_violation(123, "buy crypto now", "matched: crypto", "warn") + assert await db.get_warning_count(123) == 1 - db.record_violation(123, "still spamming", "matched: crypto", "mute") - assert db.get_warning_count(123) == 2 + await db.record_violation(123, "still spamming", "matched: crypto", "mute") + assert await db.get_warning_count(123) == 2 -def test_warning_counts_are_per_user(): - db.record_violation(1, "spam", "promo", "warn") - db.record_violation(2, "spam", "promo", "warn") - db.record_violation(2, "spam", "promo", "mute") +async def test_warning_counts_are_per_user(): + await db.record_violation(1, "spam", "promo", "warn") + await db.record_violation(2, "spam", "promo", "warn") + await db.record_violation(2, "spam", "promo", "mute") - assert db.get_warning_count(1) == 1 - assert db.get_warning_count(2) == 2 - assert db.get_warning_count(999) == 0 + assert await db.get_warning_count(1) == 1 + assert await db.get_warning_count(2) == 2 + assert await db.get_warning_count(999) == 0 -def test_get_violations_empty_without_db(): - db.record_violation(5, "spam", "promo", "warn") +async def test_get_violations_empty_without_db(): + await db.record_violation(5, "spam", "promo", "warn") # Violation history requires Supabase; fallback keeps counts only. - assert db.get_violations(5) == [] + assert await db.get_violations(5) == [] -def test_record_member_and_join_request_are_noops_without_db(): +async def test_record_member_and_join_request_are_noops_without_db(): # Should not raise when Supabase is unavailable. - db.record_member(7, "user7", "bot", 0.1) - db.record_join_request(7, 0.1, True, "passed checks") + await db.record_member(7, "user7", "bot", 0.1) + await db.record_join_request(7, 0.1, True, "passed checks")