From 184395695f85d32a21f5871fdc9b4e34cb893aba Mon Sep 17 00:00:00 2001 From: hoelee Date: Sun, 6 Sep 2026 07:45:18 +0800 Subject: [PATCH] Add Chinese translation of DigiKedai bot post; center search icon --- .../how-i-built-the-digikedai-telegram-bot.md | 90 +++++++++++++++++++ src/styles/global.css | 3 + 2 files changed, 93 insertions(+) create mode 100644 src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md diff --git a/src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md b/src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md new file mode 100644 index 0000000..3cc30f9 --- /dev/null +++ b/src/content/posts/zh/how-i-built-the-digikedai-telegram-bot.md @@ -0,0 +1,90 @@ +--- +title: "我如何打造 DigiKedai Telegram AI 客服机器人" +description: "一个全天候解答顾客问题、并在 Telegram 和 WhatsApp 上自动开通免费试用账号的 AI 客服机器人——TypeScript、grammY、Cloudflare 隧道 webhook,以及自托管的 LiteLLM 网关。" +pubDate: 2026-09-06 +category: case-studies +tags: [telegram, docker, cloudflare, litellm, typescript, ai] +--- + +Digi Kedai 销售数字产品——在线课程、电子书、模板——每一单都会带来一连串同样的顾客问题:*「这门课有免费试看吗?」「我怎么拿到账号?」「我该买哪个套餐?」*。对一个人的生意来说,人工回复根本忙不过来。于是我打造了一个 AI 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。 + +这篇讲的是它是怎么搭起来的——架构、LLM 接线,以及那些各自吃掉我一整个下午的 bug。 + +## 技术栈 + +- **TypeScript + Node 20**,作为单个容器跑在我的 Synology NAS 上。 +- **grammY**——一个轻量的 Telegram Bot 框架,用 webhook 模式接线。 +- **Hono**——一个极小的 HTTP 服务器,负责接收 webhook 并响应 `/health`。 +- **Cloudflare 隧道**——`bot.digikedai.com` 直连容器;无需公网 IP,无需开放端口。 +- **LiteLLM**——一个位于真实模型之前的自托管网关,这样我可以不碰机器人代码就切换供应商或加故障转移。 +- **PostgreSQL**——对话与用户记忆存储,与我的 mem0 栈共用。 + +## 架构 + +``` +顾客 → Telegram/WhatsApp → Cloudflare 隧道 → bot.digikedai.com + → Hono POST //webhook → grammY 处理器 + → 存入 PostgreSQL → Agent.respond(系统提示词 + 检索到的商品 facts + 最近 20 条消息) + → LiteLLM(模型别名 "mem0-openai")→ 持久化 + 回复 +``` + +几个值得解释的决策: + +**Cloudflare 隧道直连容器,不经过 Traefik。** 我的 homelab 在 CGNAT 后面,没有隧道就没有任何东西能被公网访问。webhook URL 藏在一个**秘密路径段**(`//webhook`)后面,这样 Telegram 的更新只有知道这个 secret 的人才能送达——这是在 Telegram 自带 token 认证之外的第一道廉价防线。 + +**模型用 LiteLLM 的*别名*寻址,从不用原始模型名。** 机器人调用 `mem0-openai`;LiteLLM 把它映射到真实模型(背后还有 OpenRouter 故障转移)。机器人永远不需要知道实际是哪个供应商在服务请求。我设了 `temperature: 0.4` 和 `45s` 超时。 + +**机器人知道自己的商品目录,但不含价格和内部路径。** 一个构建时的生成器把单一的 `catalog_sku.csv`(唯一数据源)转成机器人导入的 TypeScript 模块——只含 SKU、名称、分类、大小和商品 URL,*别的什么都不带*。价格从不定死(「以页面当前价为准」),内部资源路径从不会进机器人镜像。这让打包体积精简,也防止模型泄露内部结构。 + +## 检索:机器人如何真正*知道*商品目录 + +把整个目录塞进提示词里的通用 LLM 会瞎编。所以机器人先检索、再回答。它有一个三档检索器: + +1. **SKU token 匹配**——`/\b[A-Z]{2,}\d{2,}\b/gi` 能精确命中 `CZH01` 这样的 SKU。 +2. **整句子串匹配**——针对短而精确的查询。 +3. **分段匹配**——切分 CJK 连续串(剥离「有/吗/哪些」这类疑问助词),并过滤英文停用词。 + +前 5 个匹配结果成为注入系统提示词的「facts」,模型被要求*只*基于实际检索到的内容回答——查不到时也要明说。 + +这比看起来更重要。顾客问*「CZH01 有免费版吗?」*之所以能得到关于 `FREECZH01` 的正确回答,是因为检索器在命中付费 SKU 时,会**自动附带它的免费 twin** 并排在第二位。这个细节把一句「抱歉,没有」变成了正确的向上销售。 + +## 免费试用开通——全程无需人工 + +我最引以为豪的部分:机器人不只是*谈论*免费试用,而是真正*发放*它们。顾客有三种方式触发——`/trial` 命令、像*「我要这个试用:digikedai.com/products/czh01」*这样的自然语言消息,或者 `?start=CZH01` 这样的深链。 + +机器人不经过 n8n,而是**直接写入 NocoDB**——插入一条顾客记录和一条顾客-商品记录。我已有的 webhook 接手并自动开通真实账号,所以机器人从不碰文件服务器。整个过程是幂等的:如果顾客已有账号,就复用它而不是新建重复的;如果商品插入失败,就回滚顾客记录,这样重试不会卡死。 + +甚至还有多账号选择器。如果同一个 Telegram 用户有好几个账号,机器人会把它们列成内联按钮,问要把试用挂到哪个账号上。 + +## 那些各自吃掉我一整个下午的 bug + +上线并不顺利。有三个值得讲的调试故事: + +**1. 无限回复循环。** 如果服务器没在超时窗口内确认,Telegram 会重新投递同一条 webhook 更新。我的处理器跑得久(LLM 延迟),于是 Telegram 重发了同一条消息——机器人就一遍又一遍地回答它。修复是 `onTimeout: "return"` 加 50 秒窗口,这能让重投递的螺旋戛然而止。 + +**2. 模型别名 400。** 直接调用原始模型名(`gpt-5-mini`)返回 400。只有 LiteLLM 别名能通。这现在是仓库里的一条硬性规则:*LLM 必须用别名,从不用原始上游名。* + +**3. WhatsApp 的「m_text」bug。** 当我加 WhatsApp 通道(通过一个把 WhatsApp Web 事件 POST 到 webhook 的浏览器扩展)时,一个「自定义 webhook payload」设置里空字符串的模板值让扩展把字面字段*名* `m_text` 当成了消息文本——于是机器人回复*「我不明白 m_text」*。修复是关掉那个设置、改用默认 payload,适配器就能正确读取了。 + +三个故事共同的诚实结论是:故障不在难的部分——LLM 或检索。而在 **webhook 生命周期和 payload 契约的细节**,那些集成真正出问题的无聊边角。 + +## 在 LLM 之上叠加确定性流程 + +LLM 擅长开放式问题,却很不擅长*状态*。所以那些需要可靠的对话环节——购买、试用领取、深链——都是**确定性状态机**,而非提示词工程: + +- **购买意图**(`order`、`want to buy`、`buy`…)在进入 LLM 之前就被拦截,驱动一个内联键盘(网店 → 管理员 → 返回),从不给自由文本回复。 +- **深链**把 SKU 放在 `?start=` payload 里,但 Telegram 那里只允许 `A-Z a-z 0-9 _ -`——一个 `:` 会静默地把它弄坏。我吃过大亏,于是把分隔符从 `buy:SKU` 换成了 `buy-SKU`。 +- **WhatsApp 里的跑题消息**会让模型输出一个哨兵值(`NO_REPLY`),代理把它变成*「什么都不发」*——这样机器人在没话可说时保持沉默,而不是胡言乱语。 + +## 我会做不同的地方 + +- **启动时就加 `setMyCommands()`。** 命令列表定义在代码里,但我从没在 Telegram 注册它,所以聊天框里的命令菜单一直是空的,直到我用 Bot API 修好。小事,但因此损失了一个真实的顾客触点。 +- **在写通道适配器之前先定好 payload 契约。** WhatsApp 的 `m_text` bug 就来自我信任了一个没读过契约的自定义功能。 +- **把 webhook 超时当作一等架构**,而不是事后补丁——如果我在第一天就想到确认窗口,重投递循环本可以避免。 + +## 结果 + +现在 NAS 上的一个容器就能跨两个通道(Telegram + WhatsApp)解答顾客问题,按 SKU 或自然语言检索到正确商品,并端到端发放免费试用账号——还有一套约 175 个单元测试覆盖确定性流程、检索和 payload 契约。源码在 +[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot)。 + +如果你也在打造一个 LLM 驱动的 Telegram 机器人,教训其实很朴素:模型是最容易的部分。webhook 生命周期、payload 契约、以及开通流程的幂等性才是真正会出问题的地方——先设计好这些。 diff --git a/src/styles/global.css b/src/styles/global.css index 5cce4f7..5591033 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -134,6 +134,9 @@ body { line-height: 1.35; cursor: pointer; font-family: var(--font-sans); + display: inline-flex; + align-items: center; + justify-content: center; } .search-toggle:hover { color: var(--brand); border-color: var(--brand); } .lang-switch {