Dùng AI đọc code thư viện lạ: bắt nó chỉ ra file và dòng

Dùng AI đọc code của một thư viện hay codebase lạ mà không bị dẫn sai: cho AI đọc mã nguồn thật, đòi file và số dòng, rồi tự kiểm chứng bằng một script nhỏ.

Dùng AI đọc code của một thư viện lạ thế nào cho đúng? Bạn cho AI đọc mã nguồn thật trong node_modules, yêu cầu mỗi câu trả lời kèm tên file và số dòng, rồi tự chạy một script nhỏ để kiểm chứng. Bạn từng tin một câu trả lời trôi chảy mà sai chưa? Cùng mình thử nhé!

App sổ chi tiêu cần một thư viện mình chưa từng dùng

Ở bài trước, app sổ chi tiêu đã đọc lệnh đúng hơn nhờ bảy mẫu prompt. App nhận một tin nhắn như "cà phê 35k" và ghi lại một khoản chi, còn /tong thì trả về tổng tháng. Toàn bộ nằm trong một hàm handleMessage(text, now, entries), nhận nội dung tin nhắn, thời điểm nhắn và sổ chi tiêu, rồi trả về câu trả lời.

Nhưng app mới chỉ chạy trong test. Để nhắn tin thật từ điện thoại, mình chọn Telegram, vì tạo bot ở đó miễn phí và nhanh. Thư viện phổ biến cho Telegram bot bằng TypeScript là grammY, và mình chưa dùng nó bao giờ.

Bài này không dạy grammY. Nó kể cách mình dùng AI để hiểu một thư viện lạ đủ để viết 15 dòng code nối, và biết chắc từng dòng trong đó. Mọi output dưới đây là mình chạy thật với grammY 1.46.0, bản mới nhất mình cài được ngày 10/11/2026.

Nhờ AI viết luôn theo trí nhớ thì sao?

Cách nhanh nhất là gõ "viết cho mình một Telegram bot bằng grammY, có cả đoạn test không cần mạng". Mình thử đúng như vậy với Claude Code, và dặn thêm là chưa được mở node_modules, chỉ viết theo những gì nó nhớ. Đây là bản nháp nhận về, mình giữ nguyên trong thư mục ban-nhap/:

ban-nhap/thu-tu-tri-nho.ts
// BẢN NHÁP ĐẦU TIÊN: AI viết theo trí nhớ, chưa đọc mã nguồn của grammY trong node_modules.
// Giữ lại để so sánh. Chạy `npx tsc` sẽ thấy nó sai ở đâu.
import { Bot } from "grammy";

const bot = new Bot("token-gia", {
  botInfo: {
    id: 1,
    is_bot: true,
    first_name: "Sổ chi tiêu",
    username: "so_chi_tieu_bot",
    can_join_groups: false,
    can_read_all_group_messages: false,
    supports_inline_queries: false,
  },
});

bot.on("message:text", (ctx) => ctx.reply(`Bạn vừa nhắn: ${ctx.message.text}`));

// Làm theo đúng lời nhắc mà grammY in ra khi chưa có error handler.
bot.catch = (err) => console.error("Lỗi:", err.error);

Code trông rất ổn. Nó ngắn, có chú thích, tên hàm nghe hợp lý. Nếu bạn chưa dùng grammY, bạn không có cách nào nhìn mà biết nó đúng hay sai, và đó chính là vấn đề. Mình cho TypeScript kiểm tra:

npx tsc --noEmit ban-nhap/thu-tu-tri-nho.ts
ban-nhap/thu-tu-tri-nho.ts(6,3): error TS2740: Type '{ id: number; is_bot: true; first_name: string; username: string; can_join_groups: false; can_read_all_group_messages: false; supports_inline_queries: false; }' is missing the following properties from type 'UserFromGetMe': can_connect_to_business, has_main_web_app, has_topics_enabled, allows_users_to_create_topics, and 2 more.
ban-nhap/thu-tu-tri-nho.ts(20,48): error TS2339: Property 'error' does not exist on type 'ErrorHandler<Context>'.

Có hai lỗi, và cả hai đều đáng học.

Lỗi thứ nhất nói đối tượng botInfo thiếu sáu trường. Bảy trường trong bản nháp từng là đủ ở các phiên bản cũ. Telegram thêm tính năng mới theo thời gian, thư viện cập nhật theo, còn trí nhớ của mô hình thì dừng ở thời điểm nó được huấn luyện. AI không nói dối. Nó trả lời đúng cho một phiên bản không còn là phiên bản trên máy bạn.

Lỗi thứ hai thú vị hơn. Dòng bot.catch = ... gán một hàm vào catch, trong khi catch là một phương thức mà bạn phải gọi, tức bot.catch(...).

Điều lạ là cách viết sai này lại xuất hiện trong chính thông báo mà thư viện in ra. Trong file node_modules/grammy/out/bot.js, dòng 95, có câu Set your own error handler with bot.catch = .... Vài dòng dưới, ở dòng 383, phương thức thật được định nghĩa là catch(errorHandler) { this.errorHandler = errorHandler; }. Nếu bạn làm theo lời nhắc trong log mà không dùng TypeScript, bạn sẽ ghi đè phương thức catch, error handler không bao giờ được đăng ký, và không có lỗi nào báo cho bạn biết.

Mình rút ra hai điều. Câu trả lời theo trí nhớ có thể sai theo cách bạn không nhìn ra được. Và nguồn đáng tin nhất về một thư viện là mã nguồn của đúng phiên bản bạn đã cài, đáng tin hơn cả lời nhắc trong log của nó.

Cho AI đọc mã nguồn thật bằng cách nào?

Khi bạn chạy npm install grammy, toàn bộ thư viện nằm trong node_modules/grammy/. Trong đó có các file .d.ts mô tả kiểu dữ liệu kèm chú thích của tác giả, và các file .js là code thật sự chạy. Các công cụ AI làm việc trong thư mục dự án (Claude Code, Cursor, Copilot ở chế độ agent) đều đọc được các file này, chỉ là chúng thường không tự đọc nếu bạn không yêu cầu.

Đây là mẫu prompt mình dùng để mở đầu:

Mẫu 1: bản đồ thư viện
Mình sắp dùng thư viện <tên> lần đầu, phiên bản trong package.json.
Đọc mã nguồn trong node_modules/<tên>/ (không trả lời theo trí nhớ). Cho mình biết:
1. File nào là điểm vào, và 5 thứ mình sẽ dùng nhiều nhất nằm ở file nào.
2. Với việc <mô tả việc cần làm>, mình cần những hàm nào.
Mỗi ý kèm đường dẫn file và số dòng. Chỗ nào không tìm thấy trong mã nguồn, nói là không tìm thấy.

Câu "không trả lời theo trí nhớ" nghe hơi thừa, nhưng nó đổi hẳn cách công cụ làm việc. Thay vì viết ngay, nó mở file trước. Còn yêu cầu "đường dẫn file và số dòng" là thứ cho phép bạn kiểm tra trong mười giây: bạn mở đúng file đó, tới đúng dòng đó, và đọc bằng mắt mình.

Với grammY, câu trả lời chỉ mình tới bốn chỗ. out/bot.js chứa lớp Bot, gồm hàm khởi động và xử lý lỗi. out/filter.js định nghĩa các chuỗi lọc như "message:text".

out/context.js là đối tượng ctx mà mỗi handler nhận. Còn kiểu dữ liệu của tin nhắn nằm ở một gói riêng, node_modules/@grammyjs/types/message.d.ts. Mình mở từng file để xác nhận chúng tồn tại và đúng là chứa những thứ đó, rồi mới hỏi tiếp.

Hỏi những câu gì trước khi viết code?

Mẫu thứ hai là mẫu mình thấy có giá trị nhất, vì nó bắt AI nghĩ tới chỗ sai trước khi viết:

Mẫu 2: giả định dễ sai
Mình định viết: <mô tả ngắn đoạn code>.
Liệt kê những giả định mà đoạn code này dựa vào và dễ sai nhất. Với mỗi giả định:
- trích dòng trong mã nguồn (file:dòng) chứng minh nó đúng hay sai,
- đề xuất một cách để mình tự kiểm chứng bằng cách chạy code, không cần mạng.
Chưa viết code.

Mình mô tả đoạn code là "nhận mọi tin nhắn có chữ, gọi handleMessage với nội dung và thời điểm của tin nhắn, rồi trả lời". Danh sách nhận về có bốn giả định, và mình kiểm tra từng cái trong mã nguồn.

Giả định đầu tiên là về đơn vị thời gian. handleMessage cần một Date, và tin nhắn Telegram có trường date. Trong @grammyjs/types/message.d.ts, dòng 18 ghi Date the message was sent in Unix time. Unix time tính bằng giây, còn new Date(...) của JavaScript nhận mili giây. Viết new Date(ctx.message.date) sẽ không báo lỗi gì, chỉ cho ra một ngày của tháng 1 năm 1970.

Giả định thứ hai là "message:text" khớp những gì. Trong out/filter.js, dòng 403 tới 407 định nghĩa các tên tắt: msg gồm message và channel_post, còn edit gồm edited_message và edited_channel_post. Vậy "message:text" chỉ khớp tin nhắn mới có chữ. Ảnh không có chữ thì không khớp. Tin nhắn được sửa là một loại update khác hẳn, cũng không khớp.

Điều này quan trọng với app của mình: nếu người dùng sửa "cà phê 35k" thành "cà phê 45k", bot sẽ không biết.

Giả định thứ ba là lệnh /tong có đi qua "message:text" không. Có, vì với Telegram, một lệnh chỉ là tin nhắn chữ bắt đầu bằng dấu gạch chéo. grammY có bot.command() để lọc riêng lệnh, nhưng app của mình đã tự xử lý lệnh trong handleMessage từ bài trước, nên mình không cần.

Giả định thứ tư là chuyện gì xảy ra khi handler ném lỗi. Trong out/bot.js, dòng 91 tới 100 là error handler mặc định. Nó in lỗi ra, và nếu bot đang chạy kiểu long polling thì gọi this.stop(). Nghĩa là một lỗi trong lúc xử lý một tin nhắn sẽ làm bot ngừng nhận mọi tin nhắn sau đó, cho tới khi có người khởi động lại. Vì vậy bot.catch(...) không phải thứ để thêm sau.

Bốn câu trả lời này, mình không cần tin AI ở câu nào. Mỗi câu có một dòng trong mã nguồn mà mình tự mở ra đọc.

Làm sao thử bot mà không cần token?

Đọc mã nguồn cho mình biết code nên làm gì. Nhưng mình vẫn muốn thấy nó chạy, và chưa muốn tạo bot thật hay phụ thuộc vào mạng. Mình hỏi tiếp:

Mẫu 3: chạy thử không cần dịch vụ thật
Trong mã nguồn của <thư viện>, có cách nào đưa dữ liệu giả vào và xem thư viện định gửi gì ra ngoài,
mà không gọi mạng không? Chỉ ra hàm và file:dòng. Rồi viết một script nhỏ nhất dùng cách đó.

Câu trả lời dựa trên hai thứ có sẵn trong grammY. Thứ nhất là bot.handleUpdate(update) ở out/bot.js dòng 222, hàm mà chính thư viện gọi cho mỗi update nhận từ Telegram. Mình có thể tự gọi nó với một update tự dựng. Dòng 223 tới 226 còn cho biết điều kiện: bot phải có botInfo, nếu không hàm ném lỗi Bot not initialized!. Truyền botInfo vào constructor thì bot không cần gọi lên Telegram để hỏi thông tin của chính nó.

Thứ hai là transformer, một hàm đứng giữa bot và Telegram, được đăng ký bằng bot.api.config.use(...). Tài liệu về transformer của grammY mô tả nó là cách can thiệp vào mọi lời gọi API. Nếu transformer không gọi tiếp mà trả về luôn một kết quả giả, không có request nào rời khỏi máy.

Trước hết là file nối app với Telegram. Hàm taoBot nhận token, sổ chi tiêu và cấu hình, rồi trả về bot đã gắn handler. Tham số donVi chỉ tồn tại để mình cho bạn thấy lỗi đơn vị thời gian ở ngay bên dưới. Code thật của bạn không cần nó:

src/bot.ts
// Nối app sổ chi tiêu với Telegram qua grammY. Mọi logic vẫn nằm trong handleMessage.
import { Bot, type BotConfig, type Context } from "grammy";

import { handleMessage, type Entry } from "./so-chi-tieu.ts";

// donVi chỉ để minh họa lỗi đơn vị thời gian trong bài; code thật luôn dùng "giay".
export function taoBot(token: string, so: Entry[], config?: BotConfig<Context>, donVi: "giay" | "mili-giay" = "giay"): Bot {
  const bot = new Bot(token, config);

  // "message:text" chỉ khớp tin nhắn MỚI có chữ: không khớp ảnh, sticker hay tin nhắn được sửa.
  bot.on("message:text", async (ctx) => {
    // Telegram gửi thời điểm theo GIÂY (Unix time), còn Date của JavaScript cần mili giây.
    const luc = new Date(donVi === "giay" ? ctx.message.date * 1000 : ctx.message.date);
    await ctx.reply(handleMessage(ctx.message.text, luc, so));
  });

  // catch là một HÀM nhận error handler. Không có nó, lỗi trong handler sẽ làm bot dừng nhận tin.
  bot.catch((err) => console.error(`Lỗi khi xử lý update ${err.ctx.update.update_id}:`, err.error));
  return bot;
}

File này import handleMessage và kiểu Entry từ src/so-chi-tieu.ts. Mình không chép lại file đó và file src/parse-expense.ts mà nó dùng, vì chúng giữ nguyên từng dòng so với bài trước, nơi có đủ nội dung. Thứ duy nhất bài này cần ở chúng là chữ ký handleMessage(text: string, now: Date, entries: Entry[]): string.

Còn đây là script thử. Nó dựng năm update giả theo đúng hình dạng Telegram gửi, đưa từng cái vào bot, và in ra những gì bot định gửi đi. Nó chạy cả năm update hai lần, một lần với đơn vị đúng và một lần với đơn vị sai:

thu-offline.ts
// Thử bot mà không cần token hay mạng: tự dựng update giả, đưa vào bot.handleUpdate,
// và chặn mọi lời gọi tới Telegram bằng một transformer để xem bot định gửi gì.
import type { Update, UserFromGetMe } from "grammy/types";

import { taoBot } from "./src/bot.ts";
import type { Entry } from "./src/so-chi-tieu.ts";

// Có botInfo thì bot không cần gọi getMe lên Telegram lúc khởi động.
const botInfo: UserFromGetMe = {
  id: 1,
  is_bot: true,
  first_name: "Sổ chi tiêu",
  username: "so_chi_tieu_bot",
  can_join_groups: false,
  can_read_all_group_messages: false,
  supports_inline_queries: false,
  can_connect_to_business: false,
  has_main_web_app: false,
  has_topics_enabled: false,
  allows_users_to_create_topics: false,
  can_manage_bots: false,
  supports_join_request_queries: false,
};

const nguoiNhan = { id: 42, is_bot: false, first_name: "Mình" };
const chat = { id: 42, type: "private" as const, first_name: "Mình" };
const giay = (isoGioVN: string) => Math.floor(new Date(`${isoGioVN}+07:00`).getTime() / 1000);

let updateId = 0;
const tinNhan = (text: string, luc: string): Update => ({
  update_id: ++updateId,
  message: { message_id: updateId, date: giay(luc), chat, from: nguoiNhan, text },
});

const UPDATES: [string, Update][] = [
  ['tin nhắn "cà phê 35k"', tinNhan("cà phê 35k", "2026-12-01T06:30")],
  ['tin nhắn "/tong"', tinNhan("/tong", "2026-12-01T06:31")],
  ["một tấm ảnh, không có chữ", { update_id: ++updateId, message: { message_id: updateId, date: giay("2026-12-01T06:32"), chat, from: nguoiNhan, photo: [{ file_id: "f", file_unique_id: "u", width: 90, height: 90 }] } }],
  ['SỬA tin nhắn cũ thành "cà phê 45k"', { update_id: ++updateId, edited_message: { message_id: 1, date: giay("2026-12-01T06:30"), edit_date: giay("2026-12-01T06:33"), chat, from: nguoiNhan, text: "cà phê 45k" } }],
  ['tin nhắn "/tong" lần nữa', tinNhan("/tong", "2026-12-01T06:34")],
];

async function chay(donVi: "giay" | "mili-giay") {
  const so: Entry[] = [];
  const bot = taoBot("token-gia", so, { botInfo }, donVi);
  const daGui: string[] = [];
  // Transformer đứng giữa bot và Telegram: ghi lại lời gọi rồi trả về kết quả giả, không gọi mạng.
  bot.api.config.use(async (_prev, method, payload) => {
    daGui.push(`${method}: ${(payload as { text?: string }).text ?? ""}`);
    return { ok: true, result: { message_id: 0, date: 0, chat, text: "" } } as never;
  });

  console.log(`--- thời điểm tính theo ${donVi === "giay" ? "GIÂY (đúng)" : "MILI GIÂY (sai)"} ---`);
  for (const [moTa, update] of UPDATES) {
    daGui.length = 0;
    await bot.handleUpdate(update);
    console.log(`${moTa} → ${daGui.length ? daGui.join(" | ") : "(bot không trả lời)"}`);
  }
  console.log();
}

await chay("giay");
await chay("mili-giay");

Bạn để ý botInfo giờ có đủ mười ba trường, gồm cả sáu trường mà bản nháp theo trí nhớ thiếu. Mình không cần nhớ chúng. TypeScript liệt kê tên từng trường còn thiếu, và file @grammyjs/types/manage.d.ts có chú thích cho từng trường.

Mình chạy với TZ=UTC như các bài trước, để giống một máy chủ không đặt ở Việt Nam:

TZ=UTC node thu-offline.ts
--- thời điểm tính theo GIÂY (đúng) ---
tin nhắn "cà phê 35k" → sendMessage: Đã ghi: cà phê, 35.000đ
tin nhắn "/tong" → sendMessage: Tháng 12/2026: 1 khoản, tổng 35.000đ
một tấm ảnh, không có chữ → (bot không trả lời)
SỬA tin nhắn cũ thành "cà phê 45k" → (bot không trả lời)
tin nhắn "/tong" lần nữa → sendMessage: Tháng 12/2026: 1 khoản, tổng 35.000đ

--- thời điểm tính theo MILI GIÂY (sai) ---
tin nhắn "cà phê 35k" → sendMessage: Đã ghi: cà phê, 35.000đ
tin nhắn "/tong" → sendMessage: Tháng 01/1970: 1 khoản, tổng 35.000đ
một tấm ảnh, không có chữ → (bot không trả lời)
SỬA tin nhắn cũ thành "cà phê 45k" → (bot không trả lời)
tin nhắn "/tong" lần nữa → sendMessage: Tháng 01/1970: 1 khoản, tổng 35.000đ

Nửa trên xác nhận cả bốn giả định. Tin nhắn chữ và lệnh /tong đều tới được handleMessage. Tin nhắn lúc 6:30 sáng 1/12 giờ Việt Nam được tính vào tháng 12, dù máy đang chạy giờ UTC. Tấm ảnh và tin nhắn sửa không được trả lời, đúng như filter.js nói.

Nửa dưới là lỗi đơn vị. Bạn nhìn dòng thứ hai của nó, "Tháng 01/1970". Dòng đầu tiên thì giống hệt bản đúng, vì câu "Đã ghi" không in ra ngày. Nếu chỉ thử một tin nhắn "cà phê 35k" rồi thấy bot trả lời, mình sẽ tưởng mọi thứ ổn, và lỗi chỉ lộ ra ở cuối tháng khi tổng chi bằng không.

Hai dòng "bot không trả lời" cũng là một phát hiện về sản phẩm. Người dùng sửa tin nhắn từ 35k thành 45k, và sổ vẫn ghi 35.000đ mà không báo gì. Mình chưa sửa chuyện này trong bài, nhưng nhờ đọc filter.js mà mình biết nó tồn tại trước khi có người dùng phàn nàn. Mình ghi nó vào spec ở mục ngoài phạm vi.

Chạy bot thật thì cần gì?

File chạy thật chỉ có vài dòng, vì mọi thứ đã nằm trong taoBot:

src/chay-bot.ts
// Chạy bot thật: BOT_TOKEN=<token từ @BotFather> node src/chay-bot.ts
import { taoBot } from "./bot.ts";
import type { Entry } from "./so-chi-tieu.ts";

const token = process.env.BOT_TOKEN;
if (!token) throw new Error("Thiếu biến môi trường BOT_TOKEN (lấy từ @BotFather trên Telegram).");

const so: Entry[] = []; // tạm giữ trong bộ nhớ: tắt bot là mất
const bot = taoBot(token, so);
await bot.start({ onStart: (me) => console.log(`Bot @${me.username} đang nhận tin nhắn. Nhấn Ctrl+C để dừng.`) });

Token lấy bằng cách nhắn /newbot cho tài khoản @BotFather trên Telegram, và nó là một bí mật giống mật khẩu. Token chỉ nằm trong biến môi trường, không viết vào code và không dán vào prompt. package.json của dự án chỉ có một thư viện, ghi đúng phiên bản để lần cài sau không tự nhảy sang bản khác:

package.json
{
  "name": "so-chi-tieu-04",
  "private": true,
  "type": "module",
  "dependencies": {
    "grammy": "1.46.0"
  }
}

Mình nói thật là trong bài này mình chỉ chạy phần offline, nên chay-bot.ts mới được kiểm tra kiểu bằng TypeScript chứ chưa nhận tin nhắn thật nào.

Cách này có dùng được cho codebase lạ không?

Dùng được, và theo mình còn hợp hơn. Một codebase của công ty không có trong dữ liệu huấn luyện của mô hình nào, nên AI buộc phải đọc code thật. Ba mẫu ở trên chỉ cần đổi vài chữ: "bản đồ thư viện" thành "bản đồ của thư mục này", "giả định dễ sai" giữ nguyên, và "chạy thử không cần dịch vụ thật" thành "test nào đang chạy qua đoạn code này".

Mình có một ví dụ thật từ chính blog này, ngày 10/11/2026. Sitemap của Tech Notes không tự cập nhật bài mới. Bản sửa đầu tiên dựa trên một giả định nghe rất hợp lý: chuyển sitemap từ file sitemap.ts sang một route handler ở cùng đường dẫn /sitemap.xml thì nó sẽ được làm mới như trang RSS. Bản sửa được merge, deploy, và sitemap vẫn đứng yên.

Lần thứ hai, thay vì đoán tiếp, câu hỏi được đổi thành "trong mã nguồn Next.js, cái gì quyết định một route là sitemap?". Câu trả lời nằm trong node_modules/next/dist/lib/metadata/is-metadata-route.js, nơi một biểu thức chính quy coi mọi đường dẫn kết thúc bằng /sitemap.xml là route đặc biệt, bất kể bạn viết nó bằng file gì. Gọi thử hàm isMetadataRoute("/sitemap.xml/route") trả về true. Bản sửa thứ hai chuyển route sang một đường dẫn khác, và lần này sitemap cập nhật.

Bản sửa đầu tốn một vòng merge và deploy vì tin vào một giả định. Bản sửa thứ hai bắt đầu bằng việc đọc một file. Mình kể lại vì nó cho thấy cách làm trong bài không chỉ dành cho người mới.

Thử thêm một người dùng thứ hai

Script offline xanh cả năm dòng, và mình đã định dừng ở đó. Nhưng một bot Telegram thì ai có link cũng nhắn được, nên trước khi tạo bot thật, mình thử thêm một tình huống: hai người khác nhau cùng nhắn. File hai-nguoi-dung.ts dùng lại đúng cách thử ở trên, với hai id người dùng khác nhau và một cuốn sổ tạo theo cách của chay-bot.ts:

hai-nguoi-dung.ts
// Hai người khác nhau nhắn cho cùng một bot. Dùng lại cách thử offline: update giả + transformer.
import type { Update, UserFromGetMe } from "grammy/types";

import { taoBot } from "./src/bot.ts";
import type { Entry } from "./src/so-chi-tieu.ts";

const botInfo = {
  id: 1, is_bot: true, first_name: "Sổ chi tiêu", username: "so_chi_tieu_bot",
  can_join_groups: false, can_read_all_group_messages: false, supports_inline_queries: false,
  can_connect_to_business: false, has_main_web_app: false, has_topics_enabled: false,
  allows_users_to_create_topics: false, can_manage_bots: false, supports_join_request_queries: false,
} satisfies UserFromGetMe;

const so: Entry[] = []; // giống src/chay-bot.ts: cả bot có đúng một cuốn sổ
const bot = taoBot("token-gia", so, { botInfo });
let traLoi = "";
bot.api.config.use(async (_prev, _method, payload) => {
  traLoi = (payload as { text?: string }).text ?? "";
  return { ok: true, result: { message_id: 0, date: 0, chat: { id: 0, type: "private", first_name: "" }, text: "" } } as never;
});

const luc = Math.floor(new Date("2026-12-01T06:30+07:00").getTime() / 1000);
let updateId = 0;
function nhan(id: number, ten: string, text: string): Update {
  const nguoi = { id, is_bot: false, first_name: ten };
  return { update_id: ++updateId, message: { message_id: updateId, date: luc, chat: { id, type: "private", first_name: ten }, from: nguoi, text } };
}

for (const [id, ten, text] of [[42, "Mình", "cà phê 35k"], [77, "Bạn mình", "trà sữa 32k"], [42, "Mình", "/tong"]] as const) {
  await bot.handleUpdate(nhan(id, ten, text));
  console.log(`${ten} (id ${id}) nhắn "${text}" → ${traLoi}`);
}
TZ=UTC node hai-nguoi-dung.ts
Mình (id 42) nhắn "cà phê 35k" → Đã ghi: cà phê, 35.000đ
Bạn mình (id 77) nhắn "trà sữa 32k" → Đã ghi: trà sữa, 32.000đ
Mình (id 42) nhắn "/tong" → Tháng 12/2026: 2 khoản, tổng 67.000đ

Bạn nhìn dòng cuối. Mình chỉ uống một ly cà phê 35.000đ, mà tổng tháng của mình là 67.000đ với 2 khoản. Ly trà sữa của người kia đã vào sổ của mình.

Nguyên nhân nằm ở một dòng mà mình đã đọc lướt qua: const so: Entry[] = []. Cả bot chỉ có một cuốn sổ, và mọi người nhắn cho bot đều ghi chung vào đó. Spec từ bài đầu tiên ghi "một người dùng (mình)", nên handleMessage chưa bao giờ cần biết ai đang nhắn. Điều đó đúng cho tới lúc app được nối với một nơi mà ai cũng nhắn được.

Đọc thư viện kỹ tới đâu cũng không bắt được lỗi này, vì grammY không sai. Lỗi nằm ở chỗ code của mình gặp thế giới thật. Với những lỗi chỉ lộ ra khi chạy, câu hỏi không còn là "viết code thế nào" mà là "đưa cho AI những gì để nó tìm đúng nguyên nhân". Đó là chủ đề của bài viết tiếp theo. Bạn theo dõi cả series ở Làm side project cùng AI, và nếu đang ôn phỏng vấn, câu hỏi về cách dùng AI khi lập trình có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.