cd ../projects

Strictly Sealed β€” Discord Bot

"Building a full-featured Discord bot to automate contests, police a trading marketplace, and keep a card collecting community safe from scammers"

Discord bot interface showing contest results and tournament bracket

1. Community Work, Again

Strictly Sealed runs one of the more active TCG card collecting communities on Discord β€” the kind where people actually talk to each other, trade, and compete. After the PSA-to-eBay automation, the next ask was about the server itself. Two problems needed solving: running a monthly photo contest without it becoming a full-time job, and keeping the trading channel from turning into a scammer's playground.

The answer was a single Discord bot, two independent systems inside it, one Linux VPS, and more async debugging than I care to admit.

2. Architecture: Keeping it Boring

The stack is deliberately unexciting: discord.py for the Discord layer, SQLite for persistence, and a plain Python venv on Ubuntu. No Docker, no message queues, no cloud functions. A Discord bot at this scale β€” one server, a few hundred users β€” does not need any of that. What it does need is to not crash and to pick up where it left off after a restart.

The project is split into three cogs: contest.py for the tournament engine, admin.py for slash commands, and forum.py for marketplace monitoring. Each cog is independently loadable, which made iterating in production much less painful.

Bot project file structure in terminal
Three cogs, one database, one config file. Boring on purpose.

3. The Contest Engine

The monthly contest works in two phases. For 20 days, the channel is unlocked for image submissions. The bot watches for image attachments, stores them keyed by user ID, and silently overrides the entry if the user resubmits β€” no fuss, no drama. When the deadline hits, the channel locks and the tournament begins.

The tournament is round-based rather than a fixed bracket, which was necessary to handle three custom rules the client wanted: ties let both entries advance, the pool's best runner-up gets a wildcard if the count is odd, and resubmissions replace previous entries cleanly. A fixed binary bracket would have broken the moment a round produced a tie.


# Odd pool β†’ best runner-up gets wildcard
if len(winners) % 2 == 1 and best_loser:
    winners.append(best_loser[0])

# Tie β†’ both advance
if total == 0 or votes_a == votes_b:
    winners.extend([uid_a, uid_b])

Each match generates two image embeds followed by a native Discord poll with a 24-hour timer. After the round closes, the bot fetches live vote counts directly from the poll object, resolves winners, handles edge cases, and posts the next round β€” all without admin intervention.

A tournament match in Discord showing two submitted images and a native poll
Two entries, one poll. Discord handles the vote UI natively β€” the bot just reads the results when time's up.

4. Persistence Without Overhead

SQLite was the right call here. The data model is simple: one row per user submission, one row per match, one row per round. The trickiest part was making the connection thread-safe without pulling in an ORM β€” threading.local() gives each thread its own connection, which is enough for discord.py's async executor model.


CREATE TABLE submissions (
    user_id      INTEGER PRIMARY KEY,
    username     TEXT    NOT NULL,
    image_url    TEXT    NOT NULL,
    submitted_at TEXT    NOT NULL
);

CREATE TABLE matches (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    round_id        INTEGER NOT NULL,
    user_a          INTEGER NOT NULL,
    user_b          INTEGER,
    poll_message_id INTEGER,
    votes_a         INTEGER DEFAULT 0,
    votes_b         INTEGER DEFAULT 0,
    resolved        INTEGER DEFAULT 0
);

The schema also includes a hall_of_fame table that accumulates stats across contests: total wins, finals reached, participation count, current streak, and best streak. It persists across resets, so the history survives even when a new contest wipes the active tournament data.

5. Async, Timers, and Not Blocking the Event Loop

The phase lifecycle β€” opening submissions, closing them, running rounds, resolving polls β€” is driven by a background task that ticks every minute and checks whether a deadline has passed. This means the bot is crash-safe: if it goes down mid-contest and comes back up, the watcher picks up the correct phase from the database on the next tick.


@tasks.loop(minutes=1)
async def _phase_watcher(self):
    contest = self.db.get_contest()
    if contest["phase"] == "idle":
        return

    deadline = datetime.fromisoformat(
        contest["end_time"]
    ).replace(tzinfo=timezone.utc)

    if utcnow() < deadline:
        return

    if contest["phase"] == "submissions":
        await self._close_submissions()
    elif contest["phase"] == "tournament":
        await self._resolve_current_round()

Fetching poll votes is async too β€” await channel.fetch_message() hits the Discord API for each match, so rounds with many matches add up quickly. A small asyncio.sleep(1) between match posts keeps the bot well inside Discord's rate limits without needing a proper queue.


async def _fetch_poll_votes(
    self, message: discord.Message
) -> tuple[int, int]:
    if message.poll is None:
        return 0, 0
    answers = message.poll.answers
    votes_a = answers[0].vote_count if len(answers) > 0 else 0
    votes_b = answers[1].vote_count if len(answers) > 1 else 0
    return votes_a, votes_b

6. Marketplace Monitoring β€” The Unglamorous Part

The trading channel (BST β€” Buy, Sell, Trade) is a forum-type channel, which means every listing is a thread. The problem: scammers were impersonating Strictly Sealed, claiming to act as a middleman, and had already taken people for over $10,000 USD combined. The community needed a systematic response, not just a pinned message nobody reads.

When a new thread is created, the bot immediately DMs the poster with a scam warning and checks the thread opener against a reputable_sellers database. Every subsequent message in any BST thread triggers the same check β€” verified sellers get a green checkmark notice, everyone else gets an orange warning with a link to DM the server owner for verification.

Bot posting an unverified seller warning in a BST thread
Every message in a BST thread gets a verdict. Green means verified, orange means proceed with caution.

The bot also checks thread titles and first messages for a price. If none is found β€” no currency symbol, no number β€” it appends a loud reminder to the notice embed. One less reason for admins to manually police listings.

7. Hosting: €4.51/month and a systemd Unit

The bot runs on a Hetzner CX22 β€” 2 vCPU, 4GB RAM, Ubuntu 24.04. It uses roughly 50MB of RAM at idle. The entire Python process is managed by systemd, which handles restarts on crash, starts the bot on boot, and provides structured logging via journalctl.


[Unit]
Description=Discord Contest Bot
After=network.target

[Service]
User=botuser
WorkingDirectory=/home/botuser/DiscordBot
ExecStart=/home/botuser/DiscordBot/venv/bin/python bot.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

The bot runs under a dedicated botuser account with no sudo rights. The .env file holding the Discord token lives in that user's home directory, readable only by that user. Not Fort Knox, but appropriate for the threat model.

journalctl output showing the bot active and logging cleanly
journalctl -u contestbot -f β€” the most useful command I typed during this entire project.

8. Conclusion

What started as "can you automate the contest" turned into a fairly complete community operations tool. The contest engine handles a full tournament lifecycle without admin babysitting. The marketplace monitor gives every transaction a baseline level of scrutiny that a pinned message never could. And the whole thing costs less per month than a large coffee.

The less glamorous lesson here is that community-facing software lives or dies on edge cases β€” ties, odd numbers, users who delete their submissions, DMs being closed, polls that never got posted. Getting the happy path right is the easy part. Making it not explode when humans do human things is the actual work.