Building a URL Preview Service for Slack and Discord Bots
When someone shares a link in a Slack channel, Slack automatically fetches OG metadata and shows a preview. But for internal tools, dashboards, or sites that don't have OG tags, you get nothing. And even for public sites, the standard OG preview often doesn't show the current state of the page — it shows whatever was cached when the OG image was last generated.
A screenshot-based URL preview service solves both problems: it shows the actual current state of any URL, regardless of OG metadata, and it works for internal tools that aren't publicly accessible via Slack's fetcher.
Architecture
The service has three components:
- Screenshot service: Takes a URL, returns a PNG
- Cache layer: Avoids re-screenshotting URLs that were recently captured
- Bot integration: Listens for URLs in messages, calls the preview service, posts the image
User shares URL in Slack/Discord
→ Bot detects URL in message
→ Check cache: recent screenshot exists?
→ Yes: post cached image
→ No: call screenshot API, cache result, post image
The Preview Service
import hashlib
import time
import requests
from pathlib import Path
from typing import Optional
SCREENSHOT_API_KEY = "your-api-key"
SCREENSHOT_API_URL = "https://hermesforge.dev/api/screenshot"
CACHE_DIR = Path("./preview_cache")
CACHE_TTL_SECONDS = 3600 # 1 hour
CACHE_DIR.mkdir(exist_ok=True)
def get_cache_path(url: str) -> Path:
url_hash = hashlib.md5(url.encode()).hexdigest()
return CACHE_DIR / f"{url_hash}.png"
def get_cache_meta_path(url: str) -> Path:
url_hash = hashlib.md5(url.encode()).hexdigest()
return CACHE_DIR / f"{url_hash}.meta"
def is_cached(url: str) -> bool:
"""Check if a recent screenshot exists in cache."""
meta_path = get_cache_meta_path(url)
cache_path = get_cache_path(url)
if not meta_path.exists() or not cache_path.exists():
return False
cached_at = float(meta_path.read_text().strip())
age = time.time() - cached_at
return age < CACHE_TTL_SECONDS
def capture_preview(url: str) -> Optional[bytes]:
"""
Capture a screenshot preview of a URL.
Returns PNG bytes, or None on failure.
Uses cache to avoid redundant captures.
"""
cache_path = get_cache_path(url)
meta_path = get_cache_meta_path(url)
if is_cached(url):
return cache_path.read_bytes()
response = requests.get(
SCREENSHOT_API_URL,
params={
"url": url,
"format": "png",
"width": 1280,
"height": 720,
"wait": "networkidle",
"full_page": "false",
},
headers={"X-API-Key": SCREENSHOT_API_KEY},
timeout=30,
)
if response.status_code != 200:
return None
content = response.content
cache_path.write_bytes(content)
meta_path.write_text(str(time.time()))
return content
Slack Bot Integration
Using the Slack Bolt SDK:
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
import re
import tempfile
import os
app = App(token=os.environ["SLACK_BOT_TOKEN"])
URL_PATTERN = re.compile(r'https?://[^\s<>"]+')
# Channels where URL preview is enabled
PREVIEW_CHANNELS = {"C_GENERAL", "C_ENGINEERING", "C_LINKS"}
@app.message(URL_PATTERN)
def handle_url_message(message, say, client):
"""Respond to messages containing URLs with preview screenshots."""
channel = message.get("channel", "")
# Only preview in configured channels
if channel not in PREVIEW_CHANNELS:
return
# Don't reply to bots
if message.get("bot_id"):
return
urls = URL_PATTERN.findall(message.get("text", ""))
if not urls:
return
# Preview first URL only
url = urls[0]
# Skip common no-preview URLs
skip_domains = {"giphy.com", "tenor.com", "youtube.com", "youtu.be"}
if any(d in url for d in skip_domains):
return
preview_bytes = capture_preview(url)
if not preview_bytes:
return
# Upload image to Slack
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(preview_bytes)
temp_path = f.name
try:
client.files_upload_v2(
channel=channel,
file=temp_path,
filename="preview.png",
initial_comment=f"Preview: {url}",
thread_ts=message.get("ts"), # Reply in thread
)
finally:
os.unlink(temp_path)
if __name__ == "__main__":
handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
handler.start()
Key decisions: - Thread reply: Post the preview in a thread, not the main channel. This keeps channels clean and lets users ignore previews they don't need. - First URL only: Don't screenshot every URL in a message. One preview per message is enough. - Skip media domains: YouTube already has rich previews; screenshotting the YouTube page is redundant. - Bot message filter: Don't respond to bot messages (prevents loops if you have other bots that post URLs).
Discord Bot Integration
Using discord.py:
import discord
import re
import io
import os
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
URL_PATTERN = re.compile(r'https?://[^\s<>"]+')
# Channel IDs where preview is enabled (get from Discord developer mode)
PREVIEW_CHANNEL_IDS = {
123456789012345678, # #general
234567890123456789, # #links
}
@client.event
async def on_message(message):
# Don't respond to self
if message.author == client.user:
return
# Only in configured channels
if message.channel.id not in PREVIEW_CHANNEL_IDS:
return
urls = URL_PATTERN.findall(message.content)
if not urls:
return
url = urls[0]
# Skip media embeds Discord already handles
skip_domains = {"giphy.com", "tenor.com", "youtube.com", "youtu.be", "imgur.com"}
if any(d in url for d in skip_domains):
return
async with message.channel.typing():
preview_bytes = capture_preview(url)
if not preview_bytes:
return
# Send as file attachment in reply
file = discord.File(io.BytesIO(preview_bytes), filename="preview.png")
await message.reply(
f"Preview for <{url}>",
file=file,
mention_author=False,
)
client.run(os.environ["DISCORD_BOT_TOKEN"])
Rate Limit Management
A busy Slack workspace with 50 active users sharing links can easily hit 100+ URL previews per day. At the Starter tier (200/day), that's comfortable. At scale (1000+ users), the Business tier (5000/day) handles it with headroom.
The cache is critical for rate management: most shared URLs get shared more than once. A 1-hour cache TTL means the same URL never costs more than 1 API call per hour, regardless of how many times it's shared.
Add a simple rate limiter for extra safety:
from collections import defaultdict
from threading import Lock
class RateLimiter:
def __init__(self, max_per_minute: int = 10):
self.max_per_minute = max_per_minute
self.calls = defaultdict(list)
self.lock = Lock()
def allow(self, key: str = "global") -> bool:
"""Returns True if the call is allowed."""
now = time.time()
cutoff = now - 60
with self.lock:
self.calls[key] = [t for t in self.calls[key] if t > cutoff]
if len(self.calls[key]) >= self.max_per_minute:
return False
self.calls[key].append(now)
return True
rate_limiter = RateLimiter(max_per_minute=8) # 8 previews/min, within 200/day budget
def capture_preview_rate_limited(url: str) -> Optional[bytes]:
if is_cached(url):
return get_cache_path(url).read_bytes()
if not rate_limiter.allow():
return None # Silently skip if rate limited
return capture_preview(url)
Deployment
Run as a long-lived process with automatic restart:
# /etc/systemd/system/url-preview-bot.service
[Unit]
Description=URL Preview Bot
After=network.target
[Service]
User=botuser
WorkingDirectory=/opt/url-preview-bot
ExecStart=/opt/url-preview-bot/venv/bin/python bot.py
Restart=always
RestartSec=10
Environment="SLACK_BOT_TOKEN=xoxb-..."
Environment="SLACK_APP_TOKEN=xapp-..."
Environment="SCREENSHOT_API_KEY=your-key"
[Install]
WantedBy=multi-user.target
hermesforge.dev — screenshot API. Free: 10/day. Starter: $4/30 days (200/day). Pro: $9 (1000/day). Business: $29 (5000/day).