DEV Community

Howth Technology Factory
Howth Technology Factory

Posted on

Building a Slack Bot That Merges and Compresses PDFs on Command

Our team's PDF workflow used to be: someone drops a few files in a Slack channel, tags whoever's around, that person downloads everything, merges and compresses it locally, and re-uploads the result. It worked, and it was also a small tax on whoever happened to be online when the request came in. I replaced it with a bot that does the same thing in about a second, triggered by the file upload itself.

The trigger

Slack's Events API sends a file_shared event whenever a file lands in a channel the bot is in. That's the natural trigger, no slash command to remember, no separate step, just drop files in the channel and the bot picks them up:

from slack_bolt import App

app = App(token=SLACK_BOT_TOKEN, signing_secret=SLACK_SIGNING_SECRET)

pending_batches = {} # channel_id -> list of file info, reset on a timer

@app.event("file_shared")
def handle_file_shared(event, client, say):
file_info = client.files_info(file=event["file_id"])["file"]
if file_info["filetype"] != "pdf":
return
channel = event["channel_id"]
pending_batches.setdefault(channel, []).append(file_info)

The batching matters more than it looks like it should. Slack fires one file_shared event per file, so a five-file drop is five events arriving within milliseconds of each other, and a bot that reacts to each one individually ends up merging files one at a time instead of as a batch. A short debounce window, collect events for a few seconds, then process whatever accumulated, fixes this without adding much complexity.

Turning a debounce window into a batch job
import threading

def process_channel_after_delay(channel, client, say, delay=4):
def _run():
files = pending_batches.pop(channel, [])
if not files:
return
urls = [f["url_private_download"] for f in files]
merged = pdf_api.run({"action": "merge", "files": urls, "auth": SLACK_BOT_TOKEN})
if merged.status != "success":
say(channel=channel, text=f"Couldn't merge those files: {merged.status}")
return
compressed = pdf_api.run({"action": "compress", "file": merged.output_url})
say(channel=channel, text=f"Merged and compressed {len(files)} files: {compressed.output_url}")
threading.Timer(delay, _run).start()

The debounce timer resets on every new file event for that channel, so a person dragging in six files one at a time still gets treated as a single batch rather than six separate merges. That was the single biggest quality-of-life improvement over the first version, which processed eagerly and occasionally handed back a "merged" file containing exactly one page because it fired before the rest of the drop had landed.

Adding a slash command for the cases the trigger doesn't cover

The file-drop trigger handles the common case, but it assumes every file dropped in the channel should be merged together, which isn't always true. Sometimes people drop unrelated files in the same channel across a day and never intend them to be combined. For that, a /pdftk slash command sits alongside the automatic trigger, letting someone explicitly point at files by name or thread rather than relying on the debounce window to guess correctly:

@app.command("/pdftk")
def handle_slash_command(ack, command, say, client):
ack()
action, *rest = command["text"].split()
thread_files = get_files_from_thread(client, command["channel_id"])
if action == "merge":
result = pdf_api.run({"action": "merge", "files": thread_files})
elif action == "compress":
result = pdf_api.run({"action": "compress", "file": thread_files[0]})
say(text=f"{action} complete: {result.output_url}")

Having both an implicit trigger and an explicit command turned out to matter more than I expected. The automatic path handles the common, low-friction case well, and the slash command exists for the minority of situations where automatic grouping would guess wrong, which is a better split of responsibility than trying to make the automatic trigger smart enough to handle every case itself.

What actually makes this feel reliable

The part that took longest to get right wasn't the API integration, it was making failure visible in the same place the request happened. Early on, a failed merge just... didn't reply, and someone would wait a minute, assume it was still running, and eventually ask in the channel whether it had broken. Now every path, success or failure, ends in a message back to the thread, including a specific reason when something fails: a password-protected file, a corrupted upload, a batch where one file wasn't actually a PDF despite the extension. Silence is the worst response a bot like this can give, worse than an honest error.

Handling permissions and file access

Slack files aren't publicly reachable by default, they need a bot token passed alongside the download URL, or the PDF API gets a 403 instead of a file. This tripped me up in the first version, where merges failed silently because the API couldn't actually fetch the input files. Passing the Slack bot token through as part of the request, as shown above, fixed it, but it's the kind of integration detail that isn't obvious until you hit it, and it's worth checking first if a Slack-triggered pipeline works locally with test files but fails on real uploads.

Deployment, briefly

The bot runs as a small always-on process using Slack's Socket Mode, which avoids needing a public HTTPS endpoint just to receive events, a meaningful simplification for something this size. It's a few dozen lines beyond what's shown here: environment config, a health check, basic logging, nothing that changes the shape of the design.

Why this was worth doing as a bot instead of a script

A script anyone can run solves the same underlying problem, technically. It doesn't solve it in practice, because someone still has to remember it exists, find it, and run it correctly. A bot that reacts to the exact action people were already taking, dropping files in a channel, removes the remembering entirely. The best automation, in my experience, is the kind that attaches itself to a habit people already have rather than asking them to adopt a new one.

The API doing the actual work

None of this bot is PDF-handling logic. It's event handling, debouncing, and message formatting wrapped around a Slack-integrated PDF API that does the merge and compress calls, priced per successful result. If your team already lives in Slack, and PDF cleanup is a recurring interruption for whoever's around, this is a genuinely small project with an outsized quality-of-life return.

Top comments (0)