I've been running a trip ticket system for our engineering fleet for a while now — Project ATLAS, a small internal app that handles vehicle requests, supervisor approval chains, and generates the actual printable trip ticket PDF at the end. It works fine from a browser. But nobody wants to open a dashboard, log in, click through a multi-step form, and pick a destination from a dropdown just to say "I need the Hilux Tuesday morning to go to the Antipolo site." That's a five-second conversation, not a form.
So this week I finally built the thing I'd been putting off: a Telegram bot that creates and submits trip tickets through an actual conversation, and lets a supervisor approve or reject it from their phone without touching the dashboard at all.
Starting from nothing
There was already an apps/bot folder sitting in the repo — completely empty, from some earlier intention that never went anywhere. No token, no code, nothing. Before writing a line of it, I made Claude do a read-only pass over the whole system first: what fields does creating a trip request actually need, which API endpoints already exist, how does the approval chain work, what's the auth story. That turned up something I hadn't thought through: the API only does email+password login with an 8-hour JWT. No API keys, no service accounts, nothing built for a bot to use.
That's a real decision point, not a detail. If the bot stores your actual password so it can re-login every 8 hours, that password is now sitting on a server somewhere, and if that server is ever compromised, so is your ATLAS account. I didn't want that. So instead we added one new endpoint — POST /auth/bot-token — that takes your email and password once, verifies them, and hands back a 90-day token instead of the normal 8-hour one. The password itself is used and immediately forgotten. The bot only ever stores the token. Small change, but it's the difference between "the bot can act as you" and "the bot has your password."
The n8n detour
Partway through building the conversation flow, I stopped and asked to use n8n instead, since that's already my go-to for homelab automation. Fair question, and worth actually checking rather than just saying yes. It turns out n8n is a genuinely bad fit for this specific job. Every incoming Telegram message triggers a brand-new, stateless n8n execution — so a multi-step wizard (destination → purpose → vehicle → date → time → driver → passengers → confirm) means persisting "which step is this chat on" to a database and reloading it on literally every message, then rebuilding the branching logic out of IF nodes. Technically possible, genuinely painful to maintain. Where n8n is the right tool is everything downstream of the conversation — posting to a group chat, reminding a supervisor who's been sitting on an approval for two days, whatever automation makes sense later. So that's exactly where it lives: the bot exposes a webhook hook after a trip is submitted, and n8n picks up from there whenever I get around to building that part. Right tool for each half of the job, instead of forcing one tool to do both.
(I also tried to have Claude poke at my actual n8n instance to check what was already set up there, and hit an SSRF block — the tool refuses to connect to private IPs, and my n8n only lives on the internal network. Fair enough, that's the tool doing its job. Just meant the check happened by reasoning about the architecture instead of inspecting the instance directly.)
A bug that only shows up on the second click
First real test: DM the bot, link my account, run /newtrip, get a nice inline keyboard of destination sites. Click one. "That option expired, try /newtrip again."
Nothing had expired. The bug was three lines of ordering:
const session = getSession(ctx.from.id);
resetSession(ctx.from.id);
session.sites = await listSites(link.bot_token);getSession grabs the current session object. resetSession immediately replaces it with a fresh one in the map. But the local session variable still points at the old object — so session.sites = ... was populating an orphaned object nobody would ever look at again. When the button click came in and asked the map for the current session, it got the fresh one, with no sites on it at all. Classic stale-reference bug, invisible until you actually click something. Swap the two lines and it's fixed — but it's a good reminder that "reset, then populate" and "populate a reference you grabbed before the reset" are not the same thing, even though they read almost identically.
The token, in plain sight
Somewhere in the middle of getting the .env file set up, I asked Claude to check it, and the check script broke on parsing and printed the actual Telegram bot token straight into the chat. That's a real secret, sitting in a transcript now. The right move there isn't to just fix the parsing and move on — it's to say plainly "that token is compromised, go revoke it in BotFather and generate a new one," and only then fix the actual bug (the .env file had no KEY=VALUE structure at all — someone, i.e. me, had pasted just the raw token on its own line). Every check after that used a script that reports lengths and validity, never values. Small thing, but it's the kind of small thing that matters more than getting the feature working.
Where it landed
The finished flow: DM the bot, /link once with your ATLAS login, then /newtrip walks you through an inline-keyboard wizard — destination, purpose, vehicle (which auto-fills the fuel type from the vehicle record), date, time, driver, passengers, an optional note — shows you a summary, and on confirm it creates the trip request, submits it, and immediately DMs your supervisor with Approve/Reject buttons, assuming they've linked their account too. They tap one, you get a message back. No dashboard required for either side.
It's running as a proper systemd service now, alongside the API and dashboard, set to restart if it ever falls over. Next up is actually wiring the n8n hook into something useful — probably a nudge to a supervisor who's sat on an approval too long. That one's for the automation side of the house, not the bot.
Related reading
- Telegram Bot 409 Conflict: Terminated by setWebhook Request
- Teaching Our Trip-Ticket Bot to Listen: Six Walls We Hit Along the Way
- Two Signatures, In Order: Adding a Second Approval Layer to ATLAS