<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hoelee</title>
    <description>The latest articles on DEV Community by Hoelee (@hoelee).</description>
    <link>https://dev.to/hoelee</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4190165%2F6bd977ed-11ba-4dcf-847d-be81a134c12f.png</url>
      <title>DEV Community: Hoelee</title>
      <link>https://dev.to/hoelee</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hoelee"/>
    <language>en</language>
    <item>
      <title>One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sun, 18 Oct 2026 04:25:24 +0000</pubDate>
      <link>https://dev.to/hoelee/one-prometheus-for-unraid-a-synology-nas-and-a-vps-what-i-got-wrong-2g4g</link>
      <guid>https://dev.to/hoelee/one-prometheus-for-unraid-a-synology-nas-and-a-vps-what-i-got-wrong-2g4g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvk0o4y990lci3wkiogik.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvk0o4y990lci3wkiogik.webp" alt="One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/one-prometheus-for-unraid-synology-and-a-vps/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A homelab is not a toy when it's running other people's websites, mail and files. The&lt;br&gt;
difference between a bad week and a bad &lt;em&gt;month&lt;/em&gt; is usually how early you found out: a&lt;br&gt;
disk with a reallocated sector, a volume with 20 GB left, a VM being starved of CPU by&lt;br&gt;
its host, a container that restarted four times overnight while you slept.&lt;/p&gt;

&lt;p&gt;I had three machines — unRaid (the daily driver), a Synology NAS (the storage and&lt;br&gt;
services box) and a VPS (the public-facing one) — and three separate ways of &lt;em&gt;not&lt;/em&gt;&lt;br&gt;
knowing what they were doing. This is how they became one screen, and the four things I&lt;br&gt;
got wrong on the way, because three of them are traps you will hit too.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;One Prometheus and one Grafana, running on the host that never sleeps. Every monitored&lt;br&gt;
host runs two exporters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Host metrics&lt;/th&gt;
&lt;th&gt;Container metrics&lt;/th&gt;
&lt;th&gt;Scrape path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;unRaid&lt;/td&gt;
&lt;td&gt;node_exporter (&lt;code&gt;:9100&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;cAdvisor (&lt;code&gt;:8080&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;direct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Synology DSM&lt;/td&gt;
&lt;td&gt;node_exporter (&lt;code&gt;:9100&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;cAdvisor (&lt;code&gt;:8082&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;LAN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ServerHosh VPS&lt;/td&gt;
&lt;td&gt;node_exporter (&lt;code&gt;:9100&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;cAdvisor (&lt;code&gt;:8081&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;VPN tunnel + &lt;code&gt;nginx&lt;/code&gt; stream relay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;node_exporter and cAdvisor are not substitutes — this is the first thing people get&lt;br&gt;
wrong.&lt;/strong&gt; node_exporter sees the &lt;em&gt;host&lt;/em&gt;: CPU, memory, network, disks, filesystems,&lt;br&gt;
temperatures. It is cgroup-blind: it cannot tell you which container is eating your RAM.&lt;br&gt;
cAdvisor sees &lt;em&gt;containers only&lt;/em&gt;. If you want both host health and per-container&lt;br&gt;
accounting, you run both. Dropping either one leaves a hole that shows up months later&lt;br&gt;
as an unexplained load spike.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wrong #1: cAdvisor's "containers" that aren't containers
&lt;/h2&gt;

&lt;p&gt;My container rules started firing on things that were not containers. cAdvisor exports&lt;br&gt;
cgroup series for &lt;strong&gt;systemd slices&lt;/strong&gt; and for the machine-wide cgroup — including one&lt;br&gt;
unnamed series that reported roughly 58 GB of "memory usage" with no container attached&lt;br&gt;
to it. That is the whole host, described as a container.&lt;/p&gt;

&lt;p&gt;The fix is one filter, but you have to know to write it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# container metrics: anything with a name, and only that
container_memory_working_set_bytes{job=~"cadvisor.*", name!=""}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;name!=""&lt;/code&gt;, a "container using more than 10 GB" rule alerts on the host itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong #2: my "total storage" number was fiction
&lt;/h2&gt;

&lt;p&gt;The aggregate panel looked great and was simply wrong. The reason: &lt;strong&gt;one filesystem&lt;br&gt;
appears in the metrics more than once.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the NAS, the primary volume is &lt;code&gt;/volume1&lt;/code&gt; — and &lt;code&gt;/opt&lt;/code&gt; is the &lt;em&gt;same&lt;/em&gt; btrfs
filesystem, exposed under a second mount point.&lt;/li&gt;
&lt;li&gt;On unRaid, that same NAS volume is mounted again over CIFS as
&lt;code&gt;/mnt/remotes/&amp;lt;nas&amp;gt;_ActiveBackup&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;unRaid's &lt;code&gt;/var/lib/docker&lt;/code&gt; is a subvolume of the pool that &lt;code&gt;/mnt/ssd&lt;/code&gt; already
represents — same bytes, second identity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naive &lt;code&gt;sum(node_filesystem_size_bytes)&lt;/code&gt; therefore reported capacity for disks I don't&lt;br&gt;
have. The honest selector names exactly what counts as data storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_filesystem_size_bytes{
  mountpoint=~"/volume[0-9]+|/mnt/ssd|/mnt/disk[0-9]+",
  fstype!~"fuse.*|tmpfs|rootfs"
} or node_filesystem_size_bytes{job="vps-host", mountpoint="/"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more honesty notes that belong &lt;em&gt;in the panel description&lt;/em&gt;, because a number nobody&lt;br&gt;
can interpret is worse than no number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parity disks are invisible to the kernel.&lt;/strong&gt; unRaid's parity drive has no filesystem,
so it never appears — the sum is usable capacity, not raw spindle count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mirror inflates a raw sum.&lt;/strong&gt; Two mirrored SSDs report their bytes twice; the sum
is not what you can store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the omission that would have bitten me later: unRaid's shfs union mount (&lt;code&gt;/mnt/user&lt;/code&gt;)&lt;br&gt;
reports &lt;code&gt;avail=0&lt;/code&gt;. Including it in a "free space below X" rule produces an alert that&lt;br&gt;
can never be resolved.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wrong #3: the VM that exports no CPU frequency
&lt;/h2&gt;

&lt;p&gt;Wanting "total CPU frequency" across the lab, I reached for node_exporter's cpufreq&lt;br&gt;
collector. unRaid and the NAS reported &lt;code&gt;node_cpu_scaling_frequency_hertz&lt;/code&gt; per core. The&lt;br&gt;
VPS reported &lt;strong&gt;nothing at all&lt;/strong&gt; — it's a KVM guest, and a guest has no&lt;br&gt;
&lt;code&gt;/sys/devices/system/cpu/cpu0/cpufreq&lt;/code&gt;. The metric cannot exist there.&lt;/p&gt;

&lt;p&gt;The fix is the textfile collector: a small script that reads what the guest &lt;em&gt;can&lt;/em&gt; see&lt;br&gt;
(&lt;code&gt;/proc/cpuinfo&lt;/code&gt;) and writes Prometheus-format metrics into a directory node_exporter&lt;br&gt;
scrapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /opt/node-exporter-textfile/cpu-mhz.sh — run from cron every 5 minutes&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'
  /^processor/ { c = $2; gsub(/[ \t]/, "", c) }
  /^cpu MHz/   { f = $2; gsub(/[ \t]/, "", f); printf "node_cpu_mhz_current_hz{core=\"%s\"} %.0f\n", c, f * 1000000 }
'&lt;/span&gt; /proc/cpuinfo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--collector.textfile.directory=/textfile'&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/node-exporter-textfile:/textfile:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deliberate decisions: the metric is published under a &lt;strong&gt;different name&lt;/strong&gt; than&lt;br&gt;
node_exporter's own (&lt;code&gt;node_cpu_mhz_current_hz&lt;/code&gt;, not &lt;code&gt;node_cpu_scaling_frequency_hertz&lt;/code&gt;)&lt;br&gt;
so that if the host ever exposes real cpufreq there is no duplicate-series conflict, and&lt;br&gt;
the dashboards merge the two sources explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(node_cpu_scaling_frequency_hertz or node_cpu_mhz_current_hz)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrong #4: my exporter was reporting the container's name as the host's
&lt;/h2&gt;

&lt;p&gt;The host dropdown in the dashboard offered &lt;code&gt;9f9afcccc962&lt;/code&gt; as a machine. That's a container&lt;br&gt;
ID: node_exporter's &lt;code&gt;uname&lt;/code&gt; collector reports the &lt;em&gt;process's&lt;/em&gt; UTS namespace, and a&lt;br&gt;
container's hostname defaults to its own ID. The metric was correct, the label was&lt;br&gt;
meaningless. One line in compose fixes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;node_exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2.hoelee.com&lt;/span&gt;   &lt;span class="c1"&gt;# otherwise `nodename` = the container ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the aggregate/overview screen last, deliberately.&lt;/strong&gt; Deciding "what is total
storage?" is what forces the deduplication work — discovering it after building three
host dashboards means rebuilding the number everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin container &lt;code&gt;hostname&lt;/code&gt; from the start.&lt;/strong&gt; It costs one line and saves a confusing
dropdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume every virtualised or appliance host hides one class of metric.&lt;/strong&gt; A NAS may
cap your container monitor (DSM's Docker API version pinned my cAdvisor to v0.53.0 —
newer releases need a newer Docker API); a VM hides cpufreq; a router hides its own
CPU. Find the gap by &lt;em&gt;counting what you expected&lt;/em&gt; rather than trusting that the
collector succeeded.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Three hosts, &lt;strong&gt;7 scrape targets, one Grafana, 16 alert rules&lt;/strong&gt;, and a single screen that&lt;br&gt;
reads: &lt;strong&gt;47 CPU cores · 158 GHz currently clocked (205 GHz nominal) · 142 GB RAM · 64 TB&lt;br&gt;
storage · 155 running containers&lt;/strong&gt;, with the same host and container dashboard layout&lt;br&gt;
cloned per machine so the three read identically. Alerts land in Telegram and email;&lt;br&gt;
Prometheus keeps 30 days.&lt;/p&gt;

&lt;p&gt;The most useful outcome wasn't the dashboard. It was the alert that fired the day the&lt;br&gt;
storage rule was rewritten: a NAS volume with 21 GB left, which had been hidden behind a&lt;br&gt;
percentage threshold on a volume so large that "99% full" had become background noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want this for your business?
&lt;/h2&gt;

&lt;p&gt;If you run a NAS, a VPS and a couple of servers and have no single place to see them, I&lt;br&gt;
set up self-hosted monitoring pipelines like this one — Prometheus + Grafana across your&lt;br&gt;
machines, host &lt;em&gt;and&lt;/em&gt; container metrics, disk-health and capacity rules that mean&lt;br&gt;
something, and alerts pushed to Telegram or email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WhatsApp: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;Email: &lt;a href="mailto:me@hoelee.com?subject=Self-hosted%20monitoring%20setup"&gt;me@hoelee.com&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Website design and development is my main line of work; server hardening and&lt;br&gt;
self-hosted infrastructure is the other half of it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>selfhosted</category>
      <category>docker</category>
    </item>
    <item>
      <title>How I Built the DigiKedai Telegram AI Bot</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:30:56 +0000</pubDate>
      <link>https://dev.to/hoelee/how-i-built-the-digikedai-telegram-ai-bot-39of</link>
      <guid>https://dev.to/hoelee/how-i-built-the-digikedai-telegram-ai-bot-39of</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73ifr8ione0zbm5en3s3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73ifr8ione0zbm5en3s3.webp" alt="How I Built the DigiKedai Telegram AI Bot" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/how-i-built-the-digikedai-telegram-bot/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Digi Kedai sells digital products — online courses, ebooks, and templates — and&lt;br&gt;
every sale involves a stream of the same customer questions: &lt;em&gt;"does this course&lt;br&gt;
have a free preview?", "how do I get my account?", "which package should I&lt;br&gt;
buy?"&lt;/em&gt;. Answering them manually doesn't scale for a one-person business. So I&lt;br&gt;
built an AI customer-support bot that answers those questions around the clock&lt;br&gt;
and can even hand out free-trial accounts without a human in the loop.&lt;/p&gt;

&lt;p&gt;This is the story of how it went together — the architecture, the LLM wiring,&lt;br&gt;
and the bugs that ate an afternoon each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a business needs a bot like this
&lt;/h2&gt;

&lt;p&gt;Before the architecture, the &lt;em&gt;why&lt;/em&gt;. A support bot isn't a gimmick — it changes&lt;br&gt;
the economics of a small business:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Saves money on staff.&lt;/strong&gt; Every routine question a bot answers is one your
team doesn't have to. Digi Kedai gets the equivalent of a round-the-clock
support agent for a fraction of the cost of hiring one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answers instantly, 24/7.&lt;/strong&gt; Customers ask at midnight and on weekends. A bot
replies in seconds, in their language — no queues, no "we'll get back to you".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Converts browsers into buyers.&lt;/strong&gt; The bot doesn't just answer — it &lt;em&gt;upsells&lt;/em&gt;.
A "does this have a free trial?" question turns into a claimed trial account
in a couple of taps, with no human in the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never forgets a customer.&lt;/strong&gt; Long-term memory means repeat customers are
greeted like regulars, not strangers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scales with your catalogue.&lt;/strong&gt; Add a product and the bot already knows it —
no retraining, no new FAQ pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a one-person business like Digi Kedai, that's the difference between losing&lt;br&gt;
sales at 2 a.m. and closing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript + Node 20&lt;/strong&gt; running as a single container on my Synology NAS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;grammY&lt;/strong&gt; — a lightweight Telegram Bot framework, wired in webhook mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hono&lt;/strong&gt; — a tiny HTTP server that receives webhooks and answers &lt;code&gt;/health&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare tunnel&lt;/strong&gt; — &lt;code&gt;bot.digikedai.com&lt;/code&gt; routes straight into the container;
no public IP, no open ports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiteLLM&lt;/strong&gt; — a self-hosted gateway in front of the actual model, so I can swap
providers or add fallbacks without touching bot code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; — conversation and user-memory storage, shared with my mem0 stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com
  → Hono POST /&amp;lt;secret&amp;gt;/webhook → grammY handler
    → save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs)
      → LiteLLM (model alias "mem0-openai") → persist + reply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Cloudflare tunnel into the container, with no Traefik hop.&lt;/strong&gt; My homelab is&lt;br&gt;
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL&lt;br&gt;
lives behind a &lt;strong&gt;secret path segment&lt;/strong&gt; (&lt;code&gt;/&amp;lt;secret&amp;gt;/webhook&lt;/code&gt;) so Telegram's&lt;br&gt;
updates only land if the caller knows the secret — a cheap first line of defense&lt;br&gt;
on top of Telegram's own token auth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model is addressed by a LiteLLM &lt;em&gt;alias&lt;/em&gt;, never a raw model name.&lt;/strong&gt; The bot&lt;br&gt;
calls &lt;code&gt;mem0-openai&lt;/code&gt;; LiteLLM maps that to the real model (with an OpenRouter&lt;br&gt;
fallback behind it). The bot never needs to know which provider is actually&lt;br&gt;
serving the request. I set &lt;code&gt;temperature: 0.4&lt;/code&gt; and a &lt;code&gt;45s&lt;/code&gt; timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bot knows its catalogue without prices or internal paths.&lt;/strong&gt; A build-time&lt;br&gt;
generator turns a single &lt;code&gt;catalog_sku.csv&lt;/code&gt; (the source of truth) into a&lt;br&gt;
TypeScript module the bot imports — SKU, name, category, size, and a product URL,&lt;br&gt;
and &lt;em&gt;nothing else&lt;/em&gt;. Prices are never baked in ("check the site for the current&lt;br&gt;
price"), and internal resource paths never ship to the bot image. That keeps the&lt;br&gt;
bundle lean and prevents the model from leaking internal structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval: how the bot actually &lt;em&gt;knows&lt;/em&gt; the catalogue
&lt;/h2&gt;

&lt;p&gt;A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the&lt;br&gt;
bot retrieves first, then answers. It has a three-tier retriever:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SKU token match&lt;/strong&gt; — &lt;code&gt;/\b[A-Z]{2,}\d{2,}\b/gi&lt;/code&gt; catches exact SKUs like &lt;code&gt;CZH01&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whole-query substring&lt;/strong&gt; — for short, precise queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-segment matching&lt;/strong&gt; — splits CJK runs (stripping question particles like
有/吗/哪些) and filters English stopwords.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The top-5 matches become the "facts" injected into the system prompt, and the&lt;br&gt;
model is instructed to answer &lt;em&gt;only&lt;/em&gt; from what it actually retrieved — and to say&lt;br&gt;
so when it finds nothing.&lt;/p&gt;

&lt;p&gt;This matters more than it looks. A customer asking &lt;em&gt;"does CZH01 have a free&lt;br&gt;
version?"&lt;/em&gt; gets a correct answer about &lt;code&gt;FREECZH01&lt;/code&gt; only because the retriever,&lt;br&gt;
on hitting the paid SKU, &lt;strong&gt;automatically attaches its free twin&lt;/strong&gt; and ranks it&lt;br&gt;
second. That one detail turns a "no, sorry" into the correct upsell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free-trial provisioning — no human in the loop
&lt;/h2&gt;

&lt;p&gt;The part I'm proudest of: the bot doesn't just &lt;em&gt;talk about&lt;/em&gt; free trials, it&lt;br&gt;
&lt;em&gt;issues&lt;/em&gt; them. A customer can trigger it three ways — the &lt;code&gt;/trial&lt;/code&gt; command, a&lt;br&gt;
natural-language message like &lt;em&gt;"我要这个试用：digikedai.com/products/czh01"&lt;/em&gt;, or a&lt;br&gt;
deep link like &lt;code&gt;?start=CZH01&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Rather than route through n8n, the bot writes &lt;strong&gt;directly to NocoDB&lt;/strong&gt; — inserting&lt;br&gt;
a customer row and a customer-product row. My existing webhooks pick that up and&lt;br&gt;
provision the actual account automatically, so the bot never touches the file&lt;br&gt;
server. The whole thing is idempotent: if a customer already has an account, it&lt;br&gt;
reuses it instead of creating a duplicate; if the product insert fails, it rolls&lt;br&gt;
back the customer row so a retry doesn't dead-end.&lt;/p&gt;

&lt;p&gt;There's even a multi-account selector. If the same Telegram user has several&lt;br&gt;
accounts, the bot lists them as inline buttons and asks which one to attach the&lt;br&gt;
trial to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs that ate an afternoon each
&lt;/h2&gt;

&lt;p&gt;Shipping this was not smooth. Three debugging stories stand out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The infinite reply loop.&lt;/strong&gt; Telegram re-delivers a webhook update if the&lt;br&gt;
server doesn't acknowledge it within a timeout window. My handler was running&lt;br&gt;
long (LLM latency), so Telegram re-sent the same message — and the bot answered&lt;br&gt;
it again, and again. The fix was &lt;code&gt;onTimeout: "return"&lt;/code&gt; with a 50-second window,&lt;br&gt;
which stops the redelivery spiral dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The model alias 400.&lt;/strong&gt; Calling the raw model name (&lt;code&gt;gpt-5-mini&lt;/code&gt;) returned a&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Only the LiteLLM alias worked. This is now a hard rule in the repo: &lt;em&gt;the LLM
must be the alias, never the raw upstream name.&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;3. The WhatsApp "m_text" bug.&lt;/strong&gt; When I added a WhatsApp channel (via a browser&lt;br&gt;
extension that POSTs WhatsApp Web events to a webhook), a "customize webhook&lt;br&gt;
payload" setting with empty-string template values made the extension send the&lt;br&gt;
literal field &lt;em&gt;name&lt;/em&gt; &lt;code&gt;m_text&lt;/code&gt; as the message text — so the bot replied &lt;em&gt;"I don't&lt;br&gt;
understand m_text"&lt;/em&gt;. The fix was to turn that setting off and use the default&lt;br&gt;
payload, which the adapter reads correctly.&lt;/p&gt;

&lt;p&gt;The honest takeaway from all three: the failures weren't in the hard parts — the&lt;br&gt;
LLM or the retrieval. They were in &lt;strong&gt;webhook lifecycle and payload-contract&lt;br&gt;
details&lt;/strong&gt;, the boring edges where integrations actually break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic flows on top of the LLM
&lt;/h2&gt;

&lt;p&gt;An LLM is great for open-ended questions and terrible for &lt;em&gt;state&lt;/em&gt;. So the&lt;br&gt;
conversational pieces that need reliability — buying, trial redemption, deep&lt;br&gt;
links — are &lt;strong&gt;deterministic state machines&lt;/strong&gt;, not prompt engineering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buy intent&lt;/strong&gt; (&lt;code&gt;order&lt;/code&gt;, &lt;code&gt;want to buy&lt;/code&gt;, &lt;code&gt;buy&lt;/code&gt;…) is intercepted before the LLM
and drives an inline keyboard (online store → admin → back), never a free-form
reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep links&lt;/strong&gt; carry the SKU in a &lt;code&gt;?start=&lt;/code&gt; payload, but Telegram only allows
&lt;code&gt;A-Z a-z 0-9 _ -&lt;/code&gt; there — a &lt;code&gt;:&lt;/code&gt; silently breaks it. I learned that the hard way
and switched the separator from &lt;code&gt;buy:SKU&lt;/code&gt; to &lt;code&gt;buy-SKU&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Off-topic messages&lt;/strong&gt; in WhatsApp make the model emit a sentinel (&lt;code&gt;NO_REPLY&lt;/code&gt;)
that the agent turns into &lt;em&gt;"send nothing"&lt;/em&gt; — so the bot stays silent instead of
babbling when it has nothing useful to say.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;setMyCommands()&lt;/code&gt; at startup.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design the payload contract before the channel adapter.&lt;/strong&gt; The WhatsApp
&lt;code&gt;m_text&lt;/code&gt; bug came from trusting a customization feature I hadn't read the
contract for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat webhook timeouts as first-class architecture&lt;/strong&gt;, not an afterthought —
the redelivery loop was avoidable if I'd thought about acknowledgement windows
on day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;One container on the NAS now answers customer questions across two channels&lt;br&gt;
(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural&lt;br&gt;
language, and issues free-trial accounts end-to-end — with a prompt suite of&lt;br&gt;
~175 unit tests covering the deterministic flows, retrieval, and the payload&lt;br&gt;
contracts. The source lives at&lt;br&gt;
&lt;a href="https://git.hoelee.com/hoelee/digikedai-bot" rel="noopener noreferrer"&gt;git.hoelee.com/hoelee/digikedai-bot&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building your own Telegram bot backed by an LLM, the lesson is the&lt;br&gt;
boring one: the model is the easy part. The webhook lifecycle, the payload&lt;br&gt;
contract, and the idempotency of your provisioning are where it actually breaks —&lt;br&gt;
design those first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want a bot like this for your business?
&lt;/h2&gt;

&lt;p&gt;I build custom Telegram/WhatsApp AI bots, websites, and self-hosted&lt;br&gt;
infrastructure for businesses. If a bot like this could save you time and&lt;br&gt;
money — or you'd like to hire me — I'd love to talk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📱 &lt;strong&gt;WhatsApp:&lt;/strong&gt; &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📧 &lt;strong&gt;Email:&lt;/strong&gt; &lt;a href="mailto:me@hoelee.com"&gt;me@hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>showdev</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
