Python · Flask · nextcord · smee.io

Snitch

A lightweight Python application that forwards GitHub webhook events to Discord channels as rich embeds — with automatic webhook management, a smee.io tunnel, and zero runtime configuration.

GitHub repo smee.io tunnel Flask server Discord bot your channels

Overview

Snitch listens to your GitHub repository and forwards events — pushes, pull requests, releases, issues, and any custom event — as formatted Discord embeds to the channel of your choice. Just call Snitch() and everything is automatic.

🔁

Auto webhook lifecycle

Registers the GitHub webhook on startup and removes it cleanly on exit via atexit.

🌐

Built-in tunnelling

Creates a smee.io tunnel automatically — no manual ngrok setup or port forwarding needed.

🎨

Customisable embeds

Control title, description, and RGB color per event using {data[...]} placeholders.

📡

Per-event routing

Each event gets its own channel_id. Route pushes, issues, and releases separately.

How It Works

Snitch orchestrates four independent classes in a fixed boot sequence inside Snitch.__init__.

1
Tunnel — smee.io
Tunnel() fetches a fresh smee.io URL and starts smee-client in a background daemon thread to forward inbound requests to your local Flask server.
2
Webhook — GitHub REST API
Webhook(token, payload_url) registers a webhook on your repo via the GitHub API. On process exit, atexit fires the delete endpoint automatically — no manual cleanup needed.
3
Client — Discord bot
Client(token) starts a nextcord bot in a daemon thread. It reads events.json, builds Discord embeds from the payload data, and sends them to the configured channel.
4
Server — Flask
Server() starts Flask on port 3000. It receives POST requests from the tunnel, reads the X-Github-Event header, and dispatches them to Client.send().

Installation

Requires Python 3.10+ and Node.js (for smee-client). Tested on Windows 10/11.

# 1. Enter the repo
cd /path/to/snitch

# 2. Create and activate a virtual environment
python -m venv .venv
.venv\Scripts\activate        # Windows
source .venv/bin/activate     # macOS / Linux

# 3. Install dependencies
pip install --upgrade pip
pip install -r requirements.txt

Node.js is installed automatically by Snitch on first run (npm install --global smee-client). You only need Node.js available on your PATH.

Quick Start

Fill in your config files, then create a main.py with a single line.

# main.py
from snitch import Snitch

Snitch()

On startup Snitch will print:

[SNITCH] Connected to tunnel: https://smee.io/xK9aQr...
[SNITCH] Created GitHub webhook with id: 489201847
[SNITCH] Initialized Discord bot
[SNITCH] Connected to Web-Server with port: 3000

Never commit tokens.env to version control. Add it to your .gitignore before your first commit.

Configuration

tokens.env

Stores your GitHub and Discord tokens. Loaded automatically at startup via python-dotenv. Located at snitch/config/tokens.env.

tokens.env snitch/config/tokens.env secrets
KeyRequiredDescription
GITHUB_TOKENyesPersonal access token with admin:repo_hook scope to create and delete webhooks.
BOT_TOKENyesDiscord bot token. The bot must be in your server with Send Messages permission.
GITHUB_TOKEN=ghp_your_personal_access_token
BOT_TOKEN=MTQ3...your_discord_bot_token

Do not wrap values in quotes — python-dotenv reads them literally and the quote characters will be included in the token string.

events.json

Defines embed templates and channel routing for every GitHub event you want to handle. The key must exactly match a GitHub webhook event name. Events absent from this file are silently ignored.

events.json snitch/config/events.json templates
FieldTypeDescription
color.r / g / bint 0–255Global embed accent color applied to all events.
<event>.channel_idintDiscord channel ID where this event is posted.
<event>.titlestringEmbed title. Supports {data[key]} placeholders.
<event>.descstringEmbed description. Supports {data[key]} and \n.
{
    "color": { "r": 121, "g": 65, "b": 231 },

    "push": {
        "channel_id": 123456789012345678,
        "title": "↗ Push from {data[pusher][name]}",
        "desc": "`Commit: {data[head_commit][message]}`\n`Forced: {data[forced]}`"
    },
    "pull_request": {
        "channel_id": 123456789012345678,
        "title": "⤵ {data[pull_request][title]} was *{data[action]}* by {data[sender][login]}",
        "desc": "`Number: {data[number]}`"
    },
    "release": {
        "channel_id": 123456789012345678,
        "title": "⬇ {data[release][name]} was *{data[action]}* by {data[release][author][login]}",
        "desc": "`Pre Release: {data[release][prerelease]}`\n`Id: {data[release][id]}`"
    },
    "issues": {
        "channel_id": 123456789012345678,
        "title": "⚠ {data[issue][title]} was *{data[action]}* by {data[issue][user][login]}",
        "desc": "`Id: {data[issue][id]}`"
    }
}

webhooks.json

Stores the GitHub API URL and request payloads used to create and delete webhooks. This file is managed automatically — you only need to update the url field to point at your own repository.

Change url to: https://api.github.com/repos/YOUR_USER/YOUR_REPO/hooks

{
    "url": "https://api.github.com/repos/your_user/your_repo/hooks",
    "add": {
        "headers": {
            "Authorization": "token {github_token}",
            "Accept": "application/vnd.github.v3+json"
        },
        "payload": {
            "name": "web",  "active": true,  "events": ["*"],
            "config": { "url": "{payload_url}", "content_type": "json" }
        }
    },
    "remove": {
        "headers": { "Authorization": "token {github_token}" }
    }
}

API Reference

Tunnel snitch/api/tunnel.py class

Creates and manages a smee.io tunnel between your local server and GitHub.

MethodReturnsDescription
__init__()Fetches a new smee.io URL via HTTP redirect. Stores it as self.url.
run()NoneInstalls smee-client via npm and starts forwarding in a daemon thread. Logs the URL on success.
get_url()str | NoneReturns the smee.io URL, or None if the tunnel failed to initialise.
from snitch.api.tunnel import Tunnel

tunnel = Tunnel()
tunnel.run()
url = tunnel.get_url()  # "https://smee.io/xK9aQr..."
Webhook snitch/api/webhook.py class

Manages the GitHub webhook lifecycle — creation on startup, automatic deletion on exit.

MethodReturnsDescription
__init__(token, payload_url)Reads webhooks.json, stores the token and payload URL.
run()NonePOSTs to the GitHub hooks API. On success, stores the webhook ID and registers a delete call via atexit.
ParameterTypeDescription
tokenstrGitHub personal access token with admin:repo_hook scope.
payload_urlstrThe smee.io URL the webhook will POST events to.
from snitch.api.webhook import Webhook

webhook = Webhook(token="ghp_...", payload_url="https://smee.io/...")
webhook.run()  # creates webhook, registers atexit cleanup
Client snitch/api/client.py class

Wraps a nextcord Bot. Reads events.json at module level and builds Discord embeds from GitHub payloads.

MethodReturnsDescription
__init__(token)Initialises the bot with all intents, registers on_ready and on_error handlers.
run()NoneStarts the bot in a daemon thread via run_thread.
send(header, payload)NoneLooks up the template for header, builds an embed, and sends it to the configured channel ID.
ParameterTypeDescription
tokenstrDiscord bot token.
from snitch.api.client import Client

client = Client(token="MTQ3...")
client.run()

# called internally by Server on each valid event
client.send(header="push", payload={...})
Server snitch/api/server.py class

A Flask application that receives POST requests from the smee.io tunnel and dispatches them to the Discord client.

MethodReturnsDescription
__init__()Initialises Flask and registers the POST / route internally.
set_handler(predicate)NoneSets the callable invoked on each valid webhook. Must accept header: str and payload: dict.
run()NoneStarts Flask on port 3000. This call blocks — it must be the last step in the boot sequence.
from snitch.api.server import Server

server = Server()
server.set_handler(client.send)  # wire up the Discord client
server.run()                       # blocks — call last

Customization

Adding Custom Events

Any GitHub webhook event can be handled by adding a matching key to events.json. The key must exactly match the value GitHub sends in the X-Github-Event header.

// events.json — add alongside existing events
"star": {
    "channel_id": 123456789012345678,
    "title": "⭐ {data[sender][login]} starred {data[repository][full_name]}",
    "desc": "`Total stars: {data[repository][stargazers_count]}`"
},
"fork": {
    "channel_id": 123456789012345678,
    "title": "🍴 {data[sender][login]} forked {data[repository][full_name]}",
    "desc": "`Forks: {data[repository][forks_count]}`"
}

Embed Templates

Title and description strings use Python's str.format() syntax. The full GitHub webhook payload is available as data.

Placeholder syntax reference
SyntaxDescription
{data[key]}Top-level payload field, e.g. {data[action]}.
{data[key][nested]}Nested field, e.g. {data[pusher][name]}.
\nLine break inside the embed description.
`text`Discord inline code — wrapping values in backticks improves readability.

See the GitHub webhook payload reference for available fields per event.

File Structure

snitch/ config/ tokens.env ← secrets — never commit events.json ← embed templates + channel IDs webhooks.json ← GitHub API config (auto-managed) api/ tunnel.py ← smee.io tunnel webhook.py ← GitHub webhook lifecycle client.py ← Discord bot + embed builder server.py ← Flask server, POST / utils/ __env.py ← reads tokens.env __json.py ← JSON reader + Flask response helper __logger.py ← log_ok / log_err __requests.py ← HTTP helpers (POST, DELETE, redirect) __thread.py ← thread + coroutine helpers __init__.py ← Snitch() entrypoint