Build a URL Preview Bot for Discord or Slack

2026-04-23 | Tags: [screenshot-api, discord, slack, bot, automation]

You know the experience: someone pastes a URL in your team chat and you get a tiny, unhelpful preview card. Sometimes no preview at all. What if every shared URL generated a full visual screenshot?

Here's how to build a URL preview bot that captures screenshots of shared links and posts them back to your channel.

How It Works

User posts URL → Bot detects it → Screenshot API → Image posted to channel

The bot watches for messages containing URLs, captures a screenshot, and posts it as a rich embed. It takes about 50 lines of code.

Discord Bot Version

Setup

pip install discord.py requests

The Bot

#!/usr/bin/env python3
"""Discord bot that screenshots URLs shared in channels."""

import os
import re
import discord
import requests
from io import BytesIO

DISCORD_TOKEN = os.environ["DISCORD_TOKEN"]
API_BASE = "https://hermesforge.dev/api"
URL_PATTERN = re.compile(r'https?://[^\s<>"]+')

# Channels to monitor (empty = all channels)
MONITORED_CHANNELS = set()  # e.g., {123456789, 987654321}

# Don't screenshot these domains (avoid loops and noise)
SKIP_DOMAINS = {
    "discord.com", "discord.gg", "cdn.discordapp.com",
    "tenor.com", "giphy.com", "imgur.com",
    "youtube.com", "youtu.be",  # already have good embeds
}

intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)

def should_skip(url):
    """Skip URLs that already have good previews or would cause loops."""
    from urllib.parse import urlparse
    domain = urlparse(url).netloc.lower()
    return any(skip in domain for skip in SKIP_DOMAINS)

def take_screenshot(url):
    """Capture a screenshot and return the image bytes."""
    resp = requests.get(f"{API_BASE}/screenshot", params={
        "url": url,
        "width": 1280,
        "height": 800,
        "format": "webp",
        "block_ads": "true",
        "delay": 1500,
    }, timeout=30)
    if resp.status_code == 200:
        return resp.content
    return None

@client.event
async def on_message(message):
    # Don't respond to own messages
    if message.author == client.user:
        return

    # Channel filter
    if MONITORED_CHANNELS and message.channel.id not in MONITORED_CHANNELS:
        return

    # Find URLs in message
    urls = URL_PATTERN.findall(message.content)
    urls = [u for u in urls if not should_skip(u)]

    if not urls:
        return

    # Screenshot the first URL (avoid spam for multi-link messages)
    url = urls[0]
    async with message.channel.typing():
        img_data = take_screenshot(url)

    if img_data:
        file = discord.File(BytesIO(img_data),
                          filename="preview.webp")
        embed = discord.Embed(
            title="URL Preview",
            description=url,
            color=0x5865F2,
        )
        embed.set_image(url="attachment://preview.webp")
        await message.reply(embed=embed, file=file,
                          mention_author=False)

client.run(DISCORD_TOKEN)

Running It

export DISCORD_TOKEN="your-bot-token"
python3 preview_bot.py

To get a Discord bot token: 1. Go to Discord Developer Portal 2. Create a new application → Bot → Reset Token 3. Enable "Message Content Intent" under Privileged Intents 4. Invite with permissions: Send Messages, Attach Files, Embed Links, Read Messages

Slack Bot Version

For Slack, use the Events API to receive messages and the Web API to post screenshots.

#!/usr/bin/env python3
"""Slack bot that screenshots URLs shared in channels."""

import os
import re
import requests
from flask import Flask, request, jsonify

SLACK_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
API_BASE = "https://hermesforge.dev/api"
URL_PATTERN = re.compile(r'https?://[^\s<>|]+')

SKIP_DOMAINS = {
    "slack.com", "slack-imgs.com",
    "youtube.com", "youtu.be",
    "tenor.com", "giphy.com",
}

app = Flask(__name__)

def should_skip(url):
    from urllib.parse import urlparse
    domain = urlparse(url).netloc.lower()
    return any(skip in domain for skip in SKIP_DOMAINS)

def take_screenshot(url):
    resp = requests.get(f"{API_BASE}/screenshot", params={
        "url": url,
        "width": 1280,
        "height": 800,
        "format": "png",
        "block_ads": "true",
        "delay": 1500,
    }, timeout=30)
    if resp.status_code == 200:
        return resp.content
    return None

def upload_to_slack(channel, img_data, url):
    """Upload screenshot to Slack channel."""
    resp = requests.post(
        "https://slack.com/api/files.uploadV2",
        headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
        data={
            "channel_id": channel,
            "title": f"Preview: {url[:80]}",
            "initial_comment": f"Screenshot of <{url}>",
            "filename": "preview.png",
        },
        files={"file": ("preview.png", img_data, "image/png")},
    )
    return resp.json()

@app.route("/slack/events", methods=["POST"])
def slack_events():
    data = request.json

    # URL verification challenge
    if data.get("type") == "url_verification":
        return jsonify({"challenge": data["challenge"]})

    # Process message events
    if data.get("type") == "event_callback":
        event = data.get("event", {})

        # Only process new messages (not edits, bot messages, etc.)
        if (event.get("type") == "message"
                and "subtype" not in event
                and "bot_id" not in event):

            urls = URL_PATTERN.findall(event.get("text", ""))
            urls = [u for u in urls if not should_skip(u)]

            if urls:
                url = urls[0]
                img_data = take_screenshot(url)
                if img_data:
                    upload_to_slack(event["channel"],
                                  img_data, url)

    return jsonify({"ok": True})

if __name__ == "__main__":
    app.run(port=3001)

Slack Setup

  1. Create a Slack app at api.slack.com/apps
  2. Add bot scopes: chat:write, files:write, channels:history
  3. Enable Event Subscriptions → Subscribe to message.channels
  4. Install to workspace
  5. Set Request URL to your server's /slack/events endpoint

Generic Webhook Version

For any chat platform with incoming webhooks (Microsoft Teams, Mattermost, Google Chat):

def post_preview_webhook(webhook_url, screenshot_url, original_url):
    """Post a preview card to any webhook-compatible platform."""
    payload = {
        "text": f"Preview of {original_url}",
        "attachments": [{
            "title": "URL Preview",
            "title_link": original_url,
            "image_url": screenshot_url,
            "color": "#36a64f",
        }],
    }
    requests.post(webhook_url, json=payload)

For webhook-based platforms, you can use a direct screenshot URL instead of uploading the image:

# Direct URL approach — no upload needed
screenshot_url = (
    f"{API_BASE}/screenshot"
    f"?url={original_url}"
    f"&width=1280&height=800"
    f"&format=webp&block_ads=true"
)

The webhook card will fetch the image from the screenshot API directly. This is simpler but means the image is generated on-demand each time someone views the card.

Making It Production-Ready

Rate Limiting

Don't screenshot-bomb your channel. Add a simple per-channel cooldown:

from time import time

_last_preview = {}  # channel_id -> timestamp
COOLDOWN = 30  # seconds between previews per channel

def should_preview(channel_id):
    now = time()
    last = _last_preview.get(channel_id, 0)
    if now - last < COOLDOWN:
        return False
    _last_preview[channel_id] = now
    return True

Caching

If the same URL is shared multiple times, don't re-screenshot it:

import hashlib

_cache = {}  # url_hash -> (bytes, timestamp)
CACHE_TTL = 3600  # 1 hour

def get_screenshot_cached(url):
    key = hashlib.md5(url.encode()).hexdigest()
    if key in _cache:
        data, ts = _cache[key]
        if time() - ts < CACHE_TTL:
            return data
    data = take_screenshot(url)
    if data:
        _cache[key] = (data, time())
    return data

Error Handling

Some URLs will fail — paywalls, CAPTCHAs, slow-loading SPAs. Handle gracefully:

async def on_message(message):
    # ... URL detection ...

    async with message.channel.typing():
        try:
            img_data = take_screenshot(url)
        except requests.Timeout:
            return  # silently skip — don't spam errors
        except Exception:
            return

    if not img_data:
        return  # API returned non-200, skip silently

Silent failure is the right default. Nobody wants error messages cluttering chat.

Use Cases

Why a Bot Instead of Browser Extensions?

Platform-native bots work for everyone in the channel without installation. The preview appears automatically — no one needs to install anything, configure anything, or change their workflow. The screenshot just appears.


Built with the Screenshot API — capture any webpage as an image with a single HTTP call. No signup required.