8.1 KiB
Moderation & Punishment System
This document describes the punishment system implemented in the bot.
Database Schema
Defined in migrations/004_punishments.sql.
CREATE TABLE punishments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
target_user_id INTEGER NOT NULL,
action_type TEXT NOT NULL, -- 'ban', 'mute', 'kick'
duration_seconds INTEGER, -- NULL = permanent / indefinite
reason TEXT,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT datetime('now'),
revoked_at TEXT,
revoked_by INTEGER,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_punishments_chat_target ON punishments(chat_id, target_user_id);
CREATE INDEX idx_punishments_active ON punishments(active);
Field Reference
| Field | Type | Description |
|---|---|---|
id |
INTEGER |
Primary key. |
chat_id |
INTEGER |
The Telegram group/chat where the punishment was applied. |
target_user_id |
INTEGER |
The punished user's Telegram ID. |
action_type |
TEXT |
One of ban, mute, or kick. |
duration_seconds |
INTEGER |
Duration before auto-expiration. NULL means permanent / indefinite. |
reason |
TEXT |
Optional moderator-provided reason. |
created_by |
INTEGER |
Telegram ID of the moderator who issued the punishment. |
created_at |
TEXT |
ISO timestamp when the punishment was created. |
revoked_at |
TEXT |
ISO timestamp when the punishment was manually or automatically revoked. |
revoked_by |
INTEGER |
Telegram ID of the revoker, or 0 for the system (auto-expiry). |
active |
INTEGER |
1 if the punishment is still in effect, 0 if revoked. |
Command Reference
All punishment commands require the caller to be an administrator or owner of the group.
Duration-based Punishments
| Command | Syntax | Description |
|---|---|---|
/sban |
/sban @user <dur> <unit> [reason] |
Ban the user for a specific duration. |
/smute |
/smute @user <dur> <unit> [reason] |
Mute the user for a specific duration. |
Permanent / Indefinite Punishments
| Command | Syntax | Description |
|---|---|---|
/mute |
/mute @user [reason] |
Mute the user indefinitely (duration_seconds = NULL). |
/pban |
/pban @user [reason] |
Permanently ban the user (duration_seconds = NULL). |
/kick |
/kick @user [reason] |
Kick the user from the group. Always recorded with NULL duration. |
Revoke Commands
| Command | Syntax | Description |
|---|---|---|
/rmute |
/rmute @user |
Revoke the active mute for this user and restore chat permissions. |
/rban |
/rban @user |
Revoke the active ban for this user and unban them. |
Target Resolution
The @user argument is resolved in the following order:
- Numeric user ID.
@username— matched against chat administrators.
If the target cannot be resolved, the bot replies with "Could not resolve target user."
Duration Units Reference
The parse_duration function in crates/cgcx-bot/src/main.rs accepts the following units (case-insensitive):
| Unit(s) | Seconds | Example |
|---|---|---|
s, sec, secs, second, seconds |
1 | /sban @user 30 s spam |
m, min, mins, minute, minutes |
60 | /smute @user 10 m offtopic |
h, hr, hrs, hour, hours |
3,600 | /sban @user 24 h raid |
d, day, days |
86,400 | /sban @user 7 d trolling |
w, week, weeks |
604,800 | /smute @user 2 w |
mo, month, months |
2,592,000 (30 days) | /sban @user 1 mo |
y, year, years |
31,536,000 (365 days) | /pban @user 1 y |
How Expiration Works (Background Task)
When the bot starts, a background Tokio task is spawned that runs every 60 seconds:
-
Query — Calls
PunishmentRepo::list_expired(), which selects rows where:active = 1duration_seconds IS NOT NULLdatetime(created_at, '+' || duration_seconds || ' seconds') <= datetime('now')
-
Action per expired punishment:
ban— Callsunban_chat_member(chat_id, target_user_id)to lift the ban.mute— Callsrestrict_chat_memberwith restored permissions:SEND_MESSAGESSEND_MEDIA_MESSAGESSEND_OTHER_MESSAGESADD_WEB_PAGE_PREVIEWS
- Other types — No automatic Telegram action is taken.
-
Record update — Calls
repo.revoke(p.id, 0):- Sets
active = 0 - Sets
revoked_at = datetime('now') - Sets
revoked_by = 0(system)
- Sets
How Revoke Works
Manual Revoke (/rmute, /rban)
- The bot resolves the target user.
- Queries
get_active_for_chat_target(chat_id, target_user_id, action_type)to find the active punishment. - If found:
/rmute— Restores the user's chat permissions viarestrict_chat_member(...)./rban— Unbans the user viaunban_chat_member(...).- Calls
repo.revoke(p.id, admin_user_id)to mark the punishment inactive.
- If no active punishment is found, the bot replies with "No active mute/ban found for this user."
Revoke via PunishmentRepo::revoke(id, revoked_by)
UPDATE punishments
SET active = 0,
revoked_at = datetime('now'),
revoked_by = ?1
WHERE id = ?2;
This is used both for:
- Automatic expiration (
revoked_by = 0) - Manual moderator revocation (
revoked_by = moderator_user_id)
Global Ban Configuration
The [groups] section in the config contains the optional global_ban flag (default false). When enabled, punishment commands (/sban, /smute, /mute, /pban, /kick) are propagated across all known chats where the bot is an administrator.
[groups]
admin_group_ids = [-1001234567890]
review_group_ids = [-1009876543210]
global_ban = false
- When
global_ban = true, issuing a punishment in any admin group applies the same action to every known chat (source chats, destination chats, review groups, and configuredadmin_group_ids/review_group_ids) where the bot has admin rights. - When
global_ban = false(default), punishments are local to the group where the command was issued.
Propagation Behavior
Each propagated punishment is recorded as a separate row in the punishments table, with its own chat_id. This means:
- The background expiration task naturally revokes each per-chat punishment independently.
- Manual
/rmuteor/rbanonly affects the chat where the revoke command was issued. - The bot skips any chat where it is not an administrator and logs a warning.
Hash Blacklist
Migration 007_hash_blacklist.sql creates the hash_blacklist table:
CREATE TABLE hash_blacklist (
hash BLOB PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
reason TEXT
);
The HashBlacklistRepo (in crates/cgcx-db/src/repos.rs) provides:
insert(hash, reason)— Adds a hash to the blacklist (ignored if already present).contains(hash)— Returnstrueif the hash is blacklisted.
During file ingestion (crates/cgcx-file-pipeline/src/lib.rs), the pipeline computes a plaintext BLAKE3 hash and checks it against hash_blacklist before deduplication and persistence. If the hash is blocked, ingestion is rejected with a BlockedHash error and the temporary file is discarded.
Username Tracking
The bot can log username changes to a JSON file for audit and moderation purposes.
Configuration
Set uname_changes_path at the top level of the config (default: "data/uname_changes.json"):
database_path = "data/db.sqlite"
uname_changes_path = "data/uname_changes.json"
How It Works
On every message and callback interaction, the bot calls UserRepo::ensure_exists(...), passing the configured path. If the user's stored username differs from the current one, a JSON line is appended to the file:
{"timestamp":"2026-05-24T12:34:56Z","user_id":123456789,"chat_id":-1001234567890,"old_username":"old_name","new_username":"new_name"}
The file is opened in append mode and created automatically if it does not exist.