This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
---
|
||||
title: "How I Built the DigiKedai Telegram AI Bot"
|
||||
description: "A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway."
|
||||
pubDate: 2026-09-06
|
||||
category: case-studies
|
||||
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
|
||||
---
|
||||
|
||||
Digi Kedai sells digital products — online courses, ebooks, and templates — and
|
||||
every sale involves a stream of the same customer questions: *"does this course
|
||||
have a free preview?", "how do I get my account?", "which package should I
|
||||
buy?"*. Answering them manually doesn't scale for a one-person business. So I
|
||||
built an AI customer-support bot that answers those questions around the clock
|
||||
and can even hand out free-trial accounts without a human in the loop.
|
||||
|
||||
This is the story of how it went together — the architecture, the LLM wiring,
|
||||
and the bugs that ate an afternoon each.
|
||||
|
||||
## The stack
|
||||
|
||||
- **TypeScript + Node 20** running as a single container on my Synology NAS.
|
||||
- **grammY** — a lightweight Telegram Bot framework, wired in webhook mode.
|
||||
- **Hono** — a tiny HTTP server that receives webhooks and answers `/health`.
|
||||
- **Cloudflare tunnel** — `bot.digikedai.com` routes straight into the container;
|
||||
no public IP, no open ports.
|
||||
- **LiteLLM** — a self-hosted gateway in front of the actual model, so I can swap
|
||||
providers or add fallbacks without touching bot code.
|
||||
- **PostgreSQL** — conversation and user-memory storage, shared with my mem0 stack.
|
||||
|
||||
## The architecture
|
||||
|
||||
```
|
||||
Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com
|
||||
→ Hono POST /<secret>/webhook → grammY handler
|
||||
→ save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs)
|
||||
→ LiteLLM (model alias "mem0-openai") → persist + reply
|
||||
```
|
||||
|
||||
A few decisions worth explaining:
|
||||
|
||||
**A Cloudflare tunnel into the container, with no Traefik hop.** My homelab is
|
||||
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL
|
||||
lives behind a **secret path segment** (`/<secret>/webhook`) so Telegram's
|
||||
updates only land if the caller knows the secret — a cheap first line of defense
|
||||
on top of Telegram's own token auth.
|
||||
|
||||
**The model is addressed by a LiteLLM *alias*, never a raw model name.** The bot
|
||||
calls `mem0-openai`; LiteLLM maps that to the real model (with an OpenRouter
|
||||
fallback behind it). The bot never needs to know which provider is actually
|
||||
serving the request. I set `temperature: 0.4` and a `45s` timeout.
|
||||
|
||||
**The bot knows its catalogue without prices or internal paths.** A build-time
|
||||
generator turns a single `catalog_sku.csv` (the source of truth) into a
|
||||
TypeScript module the bot imports — SKU, name, category, size, and a product URL,
|
||||
and *nothing else*. Prices are never baked in ("check the site for the current
|
||||
price"), and internal resource paths never ship to the bot image. That keeps the
|
||||
bundle lean and prevents the model from leaking internal structure.
|
||||
|
||||
## Retrieval: how the bot actually *knows* the catalogue
|
||||
|
||||
A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the
|
||||
bot retrieves first, then answers. It has a three-tier retriever:
|
||||
|
||||
1. **SKU token match** — `/\b[A-Z]{2,}\d{2,}\b/gi` catches exact SKUs like `CZH01`.
|
||||
2. **Whole-query substring** — for short, precise queries.
|
||||
3. **Per-segment matching** — splits CJK runs (stripping question particles like
|
||||
有/吗/哪些) and filters English stopwords.
|
||||
|
||||
The top-5 matches become the "facts" injected into the system prompt, and the
|
||||
model is instructed to answer *only* from what it actually retrieved — and to say
|
||||
so when it finds nothing.
|
||||
|
||||
This matters more than it looks. A customer asking *"does CZH01 have a free
|
||||
version?"* gets a correct answer about `FREECZH01` only because the retriever,
|
||||
on hitting the paid SKU, **automatically attaches its free twin** and ranks it
|
||||
second. That one detail turns a "no, sorry" into the correct upsell.
|
||||
|
||||
## Free-trial provisioning — no human in the loop
|
||||
|
||||
The part I'm proudest of: the bot doesn't just *talk about* free trials, it
|
||||
*issues* them. A customer can trigger it three ways — the `/trial` command, a
|
||||
natural-language message like *"我要这个试用:digikedai.com/products/czh01"*, or a
|
||||
deep link like `?start=CZH01`.
|
||||
|
||||
Rather than route through n8n, the bot writes **directly to NocoDB** — inserting
|
||||
a customer row and a customer-product row. My existing webhooks pick that up and
|
||||
provision the actual account automatically, so the bot never touches the file
|
||||
server. The whole thing is idempotent: if a customer already has an account, it
|
||||
reuses it instead of creating a duplicate; if the product insert fails, it rolls
|
||||
back the customer row so a retry doesn't dead-end.
|
||||
|
||||
There's even a multi-account selector. If the same Telegram user has several
|
||||
accounts, the bot lists them as inline buttons and asks which one to attach the
|
||||
trial to.
|
||||
|
||||
## The bugs that ate an afternoon each
|
||||
|
||||
Shipping this was not smooth. Three debugging stories stand out:
|
||||
|
||||
**1. The infinite reply loop.** Telegram re-delivers a webhook update if the
|
||||
server doesn't acknowledge it within a timeout window. My handler was running
|
||||
long (LLM latency), so Telegram re-sent the same message — and the bot answered
|
||||
it again, and again. The fix was `onTimeout: "return"` with a 50-second window,
|
||||
which stops the redelivery spiral dead.
|
||||
|
||||
**2. The model alias 400.** Calling the raw model name (`gpt-5-mini`) returned a
|
||||
400. Only the LiteLLM alias worked. This is now a hard rule in the repo: *the LLM
|
||||
must be the alias, never the raw upstream name.*
|
||||
|
||||
**3. The WhatsApp "m_text" bug.** When I added a WhatsApp channel (via a browser
|
||||
extension that POSTs WhatsApp Web events to a webhook), a "customize webhook
|
||||
payload" setting with empty-string template values made the extension send the
|
||||
literal field *name* `m_text` as the message text — so the bot replied *"I don't
|
||||
understand m_text"*. The fix was to turn that setting off and use the default
|
||||
payload, which the adapter reads correctly.
|
||||
|
||||
The honest takeaway from all three: the failures weren't in the hard parts — the
|
||||
LLM or the retrieval. They were in **webhook lifecycle and payload-contract
|
||||
details**, the boring edges where integrations actually break.
|
||||
|
||||
## Deterministic flows on top of the LLM
|
||||
|
||||
An LLM is great for open-ended questions and terrible for *state*. So the
|
||||
conversational pieces that need reliability — buying, trial redemption, deep
|
||||
links — are **deterministic state machines**, not prompt engineering:
|
||||
|
||||
- **Buy intent** (`order`, `want to buy`, `buy`…) is intercepted before the LLM
|
||||
and drives an inline keyboard (online store → admin → back), never a free-form
|
||||
reply.
|
||||
- **Deep links** carry the SKU in a `?start=` payload, but Telegram only allows
|
||||
`A-Z a-z 0-9 _ -` there — a `:` silently breaks it. I learned that the hard way
|
||||
and switched the separator from `buy:SKU` to `buy-SKU`.
|
||||
- **Off-topic messages** in WhatsApp make the model emit a sentinel (`NO_REPLY`)
|
||||
that the agent turns into *"send nothing"* — so the bot stays silent instead of
|
||||
babbling when it has nothing useful to say.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
- **Add `setMyCommands()` at startup.** The command list is defined in code, but
|
||||
I never registered it with Telegram, so the in-chat command menu was empty
|
||||
until I fixed it via the Bot API. Small, but it cost a real customer touchpoint.
|
||||
- **Design the payload contract before the channel adapter.** The WhatsApp
|
||||
`m_text` bug came from trusting a customization feature I hadn't read the
|
||||
contract for.
|
||||
- **Treat webhook timeouts as first-class architecture**, not an afterthought —
|
||||
the redelivery loop was avoidable if I'd thought about acknowledgement windows
|
||||
on day one.
|
||||
|
||||
## The result
|
||||
|
||||
One container on the NAS now answers customer questions across two channels
|
||||
(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural
|
||||
language, and issues free-trial accounts end-to-end — with a prompt suite of
|
||||
~175 unit tests covering the deterministic flows, retrieval, and the payload
|
||||
contracts. The source lives at
|
||||
[git.hoelee.com/hoelee/dsm-resource-management](https://git.hoelee.com/hoelee/dsm-resource-management).
|
||||
|
||||
If you're building your own Telegram bot backed by an LLM, the lesson is the
|
||||
boring one: the model is the easy part. The webhook lifecycle, the payload
|
||||
contract, and the idempotency of your provisioning are where it actually breaks —
|
||||
design those first.
|
||||
Reference in New Issue
Block a user