A Discord bot that applies visible tiled watermarks to images shared in Discord servers.
When @mentioned with one or more image attachments, the Watermarker bot downloads each image, applies a tiled SVG watermark with the user's name and server name, and sends the processed images back through a channel webhook. Each job uses isolated temporary directories and all jobs are tracked in a SQLite database.
- Tiled SVG watermark — diagonal repeating tile with user and guild name, auto-scaling font size, configurable color, opacity, and quality
- WebP output — all processed images are output as lossy WebP with configurable quality
- Per-job temp isolation — each watermarking job gets a unique temp directory, cleaned up after completion
- SQLite job tracking — Sequelize-backed
WatermarkJobmodel stores userId, username, guildId, guildName, channel, image hashes, webhook message ID, and job status - Delete watermark — reply
deleteto a watermark webhook message to remove it (ownership verified via DB lookup)
Watermarks do not truly protect images. This bot adds a visible tiled overlay, but:
- Images are still uploaded to Discord without a watermark. The original, unmodified image exists on Discord's CDN until the bot deletes the source message. Anyone with the right tools (Discord cache extractors, browser dev tools, CDN URL manipulation) can retrieve the original before deletion.
- Discord clients and plugins can capture images immediately. Many tools (e.g. BetterDiscord, Powercord, image saver extensions, Discord's own cache) download every received image to the user's local machine. The watermarked version posted via webhook may arrive after these captures have already completed.
- The visible watermark can be removed. Basic image editing (cloning, content-aware fill, inpainting) can strip tiled text. Automated tools and AI-based removal make this even easier.
This bot is a deterrent, not a protection mechanism. It raises the bar slightly for casual misuse and provides an audit trail via the SQLite database, but it should not be relied upon to prevent unauthorized distribution.
The Discord bot requires the following Privileged Gateway Intents (configured at discord.dev under Bot → Privileged Gateway Intents):
| Intent | Why |
|---|---|
| Message Content | Read message text to include in webhook output and strip the bot mention |
| Guild Messages | Receive messages and delete the original source messages |
| Guilds | Basic guild information |
The bot also needs these channel permissions:
| Permission | Why |
|---|---|
| Send Messages | Post "Processing..." ephemeral replies and error messages |
| Embed Links | Webhook messages display as embeds |
| Attach Files | Send watermarked WebP images via webhook |
| Manage Webhooks | Create and fetch the "Watermarker" webhook in each channel |
| Delete Messages | Delete original user messages after processing |
- Node.js 20
- Discord bot token
- Docker (for simplified Node-only deployment, no native binary dependencies)
- Clone the repository:
git clone <repo-url>
cd watermarker-kilo- Install dependencies:
npm install- Copy the environment template and configure:
cp .env.example .env- Edit
.envand setDISCORD_TOKENto your value.
- Start the bot:
npm start- In any Discord text channel where the bot has permissions:
- Mention the bot (e.g.
@Watermarker) - Attach one or more images (PNG, JPG, etc.)
- The bot will delete the original messages, process each image, and reply with the watermarked WebP images via a webhook
- Mention the bot (e.g.
- To delete a watermark, reply
deleteto the webhook message that contains the watermarked images. Only the original user who sent the watermark request can delete it.
The bot requires Manage Webhooks and Attach Files permissions to send watermarked output and delete original messages.
All configuration comes from environment variables (.env).
| Variable | Description | Default | Required |
|---|---|---|---|
DISCORD_TOKEN |
Discord bot token | — | Yes |
NODE_ENV |
Environment (development / production) |
development |
No |
LOG_LEVEL |
Logging verbosity (debug, info, warn, error) |
debug in dev, info in prod |
No |
DB_PATH |
Path to SQLite database file | ./data/watermarker.sqlite |
No |
TEMP_DIR |
Base directory for per-job temp files | ./tmp |
No |
WATERMARK_KEY |
Not used for encryption (kept for backwards compatibility) | — | No |
WATERMARK_TEXT_COLOR |
Color of the tiled watermark text | #ffffff |
No |
WATERMARK_TEXT_OPACITY |
Opacity of the watermark text (0.0–1.0) | 0.6 |
No |
A simplified Dockerfile builds a minimal Node.js 24 Alpine image with no native dependencies. Data, logs, and temp files are persisted via volume mounts.
docker compose up -dVolumes:
| Volume | Purpose |
|---|---|
./data |
SQLite database persistence |
./logs |
Winston log files |
./tmp |
Per-job temporary files |
This project includes a GitHub Actions workflow (.github/workflows/docker-publish.yml) that automatically builds multi-arch Docker images (linux/amd64, linux/arm64) and publishes them to the GitHub Container Registry (GHCR).
-
Push this repository to GitHub.
-
Update
docker-compose.ymlwith your GHCR image path:
image: ghcr.io/<your-username>/<repo-name>:latest- On your deployment server, authenticate with GHCR:
echo $GITHUB_TOKEN | docker login ghcr.io -u <your-username> --password-stdinGenerate a fine-grained personal access token at Settings → Developer settings → Personal access tokens → Fine-grained tokens with read access to packages.
cd /opt/watermarker-bot
cp .env.example .env # then edit .env with your DISCORD_TOKEN
docker compose up -dThe workflow triggers automatically on pushes to main/master (when Dockerfile, package.json, package-lock.json, or files under src/ change). You can also trigger it manually via Actions → Build and Push Docker Image → Run workflow.
| Tag | Description |
|---|---|
latest |
Latest build from default branch |
<sha> |
Build from git commit SHA |
<semver> |
Build tagged with git semver tag |
Discord message (@mention + images)
│
├─► messageCreate handler
│ ├─ Validate mentions, attachment count (max 10), file size (max 25 MB)
│ ├─ Create per-job temp directory
│ ├─ Create WatermarkJob record (status: processing)
│ │
│ ├─► downloadImage (parallel)
│ │ ├─ Whitelist check: cdn.discordapp.com, media.discordapp.net
│ │ ├─ Content-type validation (image/*)
│ │ └─ Save to job temp dir as input-*.png
│ │
│ ├─ Delete original messages
│ │
│ ├─► Per-image processing (parallel)
│ │ └─ applyWatermark — tiled SVG composite, output as WebP
│ │
│ ├─► postViaWebhook
│ │ ├─ Get or create "Watermarker" webhook per channel
│ │ └─ Send watermarked WebP files with original author info; returns webhook message ID
│ │
│ ├─ Update WatermarkJob record (status: completed, image hashes, messageId)
│ └─► cleanupJobDir — remove per-job temp directory
│
├─► WatermarkJob DB record (SQLite, Sequelize)
│ └─ Fields: id, messageId, userId, username, guildId, guildName, channelId,
│ originalMessageContent, imageCount, imageHashes,
│ status (`processing` | `completed` | `failed` | `deleted`), errorMessage
│
└─► Cleanup (finally block)
└─ Remove temp directory, delete processing status message
│
└─► /delete (reply to watermark message)
├─ Query WatermarkJob by messageId (exclude `deleted` status)
├─ Verify job.userId matches message.author.id
├─ Fetch and delete the webhook message
├─ Update WatermarkJob status to `deleted`
└─ Reply confirmation (ephemeral DM or in-channel fallback)
npm testRuns Jest with --forceExit. Tests cover config, imageDownloader, watermark, webhookPoster, and tempFiles utilities.
- Max 10 images per mention — attempting to process more returns an error
- 25 MB per image — files exceeding this limit are rejected
- WebP-only output — all watermarked images are output as
.webpfiles
- Path traversal protection —
safePath()intempFiles.jsvalidates that resolved paths stay within the job directory - CDN hostname whitelist —
imageDownloaderonly accepts URLs fromcdn.discordapp.comandmedia.discordapp.net .envnot committed —.envis listed in.gitignoreto prevent accidental token exposure
| Issue | Likely Cause | Fix |
|---|---|---|
DISCORD_TOKEN is required |
.env not configured |
Copy .env.example to .env and set DISCORD_TOKEN |
| Bot does not respond to @mentions | Missing intents or permissions | Ensure GuildMessages, MessageContent intents are enabled; verify bot has Manage Webhooks, Attach Files, Send Messages permissions |
| Webhook send fails | Channel lacks webhook permissions | Grant bot Manage Webhooks permission in the channel |
| SQLite permission errors | data/ directory not writable |
Ensure the process can write to the configured DB_PATH directory |