Build a URL Preview Bot for Discord or Slack
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
- Create a Slack app at api.slack.com/apps
- Add bot scopes:
chat:write,files:write,channels:history - Enable Event Subscriptions → Subscribe to
message.channels - Install to workspace
- Set Request URL to your server's
/slack/eventsendpoint
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
- Design review channels — instantly see what a shared Figma/website link looks like
- Sales teams — preview prospect websites when reps share leads
- Bug report channels — screenshot the URL alongside the bug description
- Content curation — visual context for shared articles
- Security teams — safe preview of suspicious URLs without clicking
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.