Pomodoro Telegram Bot v3.1 là bot Telegram hỗ trợ hẹn giờ học theo chu kỳ Focus ↔ Nghỉ (phương pháp Pomodoro), vận hành hoàn toàn miễn phí trên nền tảng Google Apps Script, tự động lưu lịch sử học tập vào Google Sheets và tổng hợp thống kê số phút học theo môn trong ngày hoặc 7 ngày gần nhất.


📌 Tóm tắt trong 30 giây

  • Là gì: Một "trợ lý" trong Telegram đếm giờ học Pomodoro cho bạn và tự ghi lại bạn đã học bao nhiêu phút, môn nào.
  • Dùng thế nào: Gõ /menu → bấm chuỗi (ví dụ 25/5) → chọn môn → học. Hoặc chỉ cần gõ số phút (ví dụ 30) để học 30 phút.
  • Cần gì: 1 tài khoản Google + 1 tài khoản Telegram. Không cần biết lập trình, không tốn chi phí máy chủ.
  • Mất bao lâu: Cài đặt khoảng 10–15 phút, chỉ làm một lần duy nhất.

Bốn thành phần của hệ thống

Thành phầnVai tròMô hình hóa
TelegramNơi bạn nhắn tin, bấm nút tương tác và nhận thông báoMàn hình điều khiển
Google Apps ScriptChứa chương trình của bot, xử lý tính toán và điều phốiBộ não xử lý
Google Sheet (tab Logs)Lưu trữ mỗi phiên học đã hoàn thànhSổ ghi chép dữ liệu
Trigger mỗi phútĐánh thức bot định kỳ 60 giây một lần để kiểm tra "hết giờ chưa?"Chuông báo thức tự động

📖 Từ điển thuật ngữ

Thuật ngữKhái niệm & Vai trò thực tế
BotTài khoản Telegram do mã nguồn chương trình điều khiển tự động, không phải người thật.
BotFatherBot chính thức của Telegram, được dùng làm công cụ tạo và quản trị các bot mới.
Token (BOT_TOKEN)Chuỗi ký tự bảo mật đóng vai trò "chìa khóa" điều khiển bot. Cần giữ bí mật như mật khẩu tài khoản.
Google Apps ScriptMôi trường lập trình JavaScript đám mây miễn phí trên máy chủ Google, liên kết trực tiếp với Google Sheets.
Web App / DeployThao tác xuất bản đoạn mã thành một địa chỉ web (URL) công khai để các dịch vụ bên ngoài kết nối tới.
WebhookĐịa chỉ web mà Telegram dùng để chuyển tiếp tin nhắn và thao tác bấm nút của bạn tới bot ngay lập tức.
TriggerTrình kích hoạt định thời của Google Apps Script, cho phép tự động chạy hàm chỉ định mỗi phút một lần.
chatIdMã số định danh duy nhất cho cuộc trò chuyện giữa bạn và bot, giúp bot nhận biết phiên học thuộc về ai.
Sheet ID (SS_ID)Chuỗi ký tự định danh file Google Sheet nằm giữa /d/ và /edit trên thanh URL trình duyệt.
Session / StateTrạng thái phiên học hiện thời: môn học, bước chuỗi, thời điểm bắt đầu, thời điểm kết thúc.
Focus / NghỉFocus là khoảng thời gian học tập trung cao độ; Nghỉ là khoảng thời gian thư giãn ngắn giữa hai lần Focus.

🧰 Chuẩn bị trước khi cài đặt

  • 1 tài khoản Google (Gmail) đang đăng nhập trên máy tính.
  • Ứng dụng Telegram trên điện thoại (hoặc bản Telegram Web/Desktop).
  • Trình duyệt web trên máy tính (thao tác trên máy tính giúp sao chép mã nguồn và cấu hình nhanh chóng).
  • Khoảng 15 phút để thực hiện các bước cấu hình.

1. Bot này là gì?

Đây là một bot Telegram được thiết kế để hỗ trợ học tập theo chu kỳ Focus ↔ Nghỉ (Pomodoro). Bạn chỉ cần chọn chuỗi thời gian, chỉ định môn học, bot sẽ tự động đếm ngược, gửi lời nhắc khi sắp hết giờ, tự chuyển sang chế độ giải lao và ghi lại tổng số phút thực học vào Google Sheets.

Toàn bộ hệ thống chạy trên Google Apps Script (nền tảng JavaScript trên hạ tầng máy chủ của Google), mang lại các ưu điểm thực tế:

  • Không cần thuê máy chủ riêng (VPS), không cần cắm máy tính 24/7.
  • Hoàn toàn miễn phí trong hạn mức sử dụng cá nhân của Google.
  • Sử dụng trực tiếp từ bất kỳ thiết bị nào có ứng dụng Telegram.

2. Tại sao nên dùng?

Lợi íchGiá trị thực tế
Miễn phí, chạy 24/7Hoạt động hoàn toàn trên hạ tầng của Google và Telegram, không phát sinh chi phí duy trì.
Tiện dụng trên TelegramKhông cần cài thêm ứng dụng đếm giờ chuyên biệt; quản lý học tập ngay trong ứng dụng liên lạc hàng ngày.
Tự nhắc nhở, tự chuyển bướcGửi tin nhắn "còn 5 phút", báo chuông hết giờ và tự chuyển vào giai đoạn nghỉ ngơi mà không cần canh giờ.
Tự động lưu và thống kêMỗi phiên học kết thúc được lưu thành một dòng log trên Google Sheets; xem báo cáo nhanh theo ngày hoặc tuần.
Tùy biến thời gian linh hoạtHỗ trợ 4 chuỗi học định sẵn hoặc chỉ cần gõ một con số (ví dụ 10) để thiết lập bộ đếm tức thì.
Bảo toàn số phút khi dừng sớmỞ bản v3.1, nếu bạn bấm Stop giữa chừng (đã học từ 1 phút trở lên), bot vẫn ghi nhận đúng số phút thực tế.
Dễ chỉnh sửa môn họcDanh mục môn học và thời lượng chuỗi được quản lý bằng các mảng cấu hình đơn giản trong code.

3. Tính năng chính

  • 4 chuỗi học dựng sẵn: 25/5 (1 Pomodoro tiêu chuẩn), 50/10 (1 phiên học sâu Deep Work), 4x25 (chuỗi marathon 120 phút), 3x50 (chuỗi marathon 180 phút).
  • Bộ đếm thời gian tự do: Nhập một số nguyên dương bất kỳ (ví dụ 10 hoặc /10) để kích hoạt bộ đếm số phút tương ứng mà không kèm giờ nghỉ.
  • Chọn môn học trước khi bắt đầu: Hỗ trợ bàn phím chọn nhanh các môn (Nội, Ngoại, Sản, Nhi, YHCS, Me) hoặc tự gõ tên môn bất kỳ.
  • Điều phối chu trình tự động: Tự động chuyển đổi giữa Focus và Nghỉ; gửi thông báo nhắc nhở khi còn 5 phút (đối với các bước Focus dài hơn 5 phút).
  • Kiểm soát linh hoạt: Hỗ trợ tạm dừng (Pause), tiếp tục (Resume) và hủy phiên (Stop).
  • Báo cáo thống kê: Tổng kết thời gian học trong ngày hôm nay và tổng quan 7 ngày gần nhất, gom nhóm chi tiết theo từng môn học.
  • Hỗ trợ đa người dùng: Dữ liệu trạng thái của mỗi người dùng được lưu trữ riêng theo chatId.

4. Điểm mới ở phiên bản v3.1

Hạng mục cải tiếnPhiên bản v3.0Phiên bản v3.1
Nhập số phút trực tiếpKhông hỗ trợ, chỉ chạy được các chuỗi cố định.Gõ trực tiếp 10 hoặc /10 để tạo phiên đếm nhanh 10 phút.
Bấm Stop giữa chừngHủy toàn bộ phiên hiện tại, không lưu dữ liệu.Tự động tính số phút đã học (nếu ≥ 1 phút) và lưu với nhãn (Dừng sớm).
Lệnh trợ giúp /helpChỉ hiển thị hướng dẫn cơ bản.Bổ sung hướng dẫn nhập số nhanh và cơ chế lưu dữ liệu khi dừng sớm.
Xử lý URL Telegram APINối chuỗi thủ công ('...' + BOT_TOKEN + '...').Sử dụng cú pháp template literal với dấu backtick chuẩn mực.

5. Cơ chế hoạt động của hệ thống

Sơ đồ luồng xử lý Pomodoro Bot v3.1
ENTRY 1User gõ /menu hoặc /startENTRY 2 (CUSTOM TIMER)User gõ số phút (vd: 10, /10)INLINE KEYBOARDKEYBOARD_MAIN4 chuỗi • Thống kê • Pause/Resume/StopBấm chuỗiLưu CUSTOM_10PROPERTIES SERVICE • CỜ CHỜLưu WAIT_TOPIC_<chatId> & Hỏi mônGửi bàn phím: Nội • Ngoại • Sản • Nhi • YHCS • MeGửi tên mônSESSION INITIALIZATIONstartChain(chatId, chain, topic)Khởi tạo state, tính endTime, lưu SESSION_<chatId>Bấm Stop sớmCANCEL / EARLY STOPstopTimer(chatId)Nếu ≥ 1p → logToSheet("Dừng sớm")CRON TRIGGER • CHẠY MỖI PHÚTTrigger: checkTimers()Quét toàn bộ SESSION_* đang ở trạng thái RUNNINGCòn ≤ 5 phútNOTIFICATION⏳ Nhắc còn 5 phútChỉ nhắc 1 lần trong hiệp FocusHết giờ & là FocusPERSISTENCE • GOOGLE SHEETlogToSheet()Ghi 1 dòng: Thời gian, ChatID, Môn, Phút, ChuỗiCòn bướctiếp theo?CóNEXT STEP • VÒNG LẶP CHUỖIChuyển Focus / NghỉTăng currentIndex, tính endTime mớiQuay lại đếm thời gianHếtCOMPLETION🏆 Báo HOÀN THÀNHXóa session khỏi PropertiesService

Giải thích luồng dữ liệu

  1. Tiếp nhận tương tác (Webhook): Mọi tin nhắn hoặc thao tác bấm nút trên Telegram được gửi về địa chỉ Webhook của Google Apps Script. Hàm doPost(e) phân loại sự kiện: bấm nút inline (callback_query) hoặc gửi tin nhắn văn bản (message).
  2. Thiết lập cờ chờ chọn môn: Khi bạn bấm chọn chuỗi (hoặc gõ số phút), bot ghi một cờ trạng thái tạm thời WAIT_TOPIC_<chatId> vào PropertiesService rồi gửi câu hỏi chọn môn. Tin nhắn phản hồi kế tiếp sẽ được ghi nhận làm tên môn học.
  3. Khởi tạo phiên học (startChain): Bot tạo đối tượng trạng thái (state) chứa tên chuỗi, mảng các bước thời gian, vị trí bước hiện tại, thời điểm kết thúc và tên môn học, lưu vào khóa SESSION_<chatId>.
  4. Bộ đếm thời gian nền (checkTimers): Trình kích hoạt (Trigger) định thời chạy mỗi phút một lần để duyệt qua các phiên đang chạy:
    • Nếu còn ≤ 5 phút trong bước Focus: gửi tin nhắn nhắc nhở.
    • Nếu đã chạm hoặc vượt mốc thời gian kết thúc: ghi nhận lịch sử vào Google Sheets (nếu là bước Focus), sau đó chuyển sang bước tiếp theo hoặc kết thúc chuỗi.
  5. Lưu trữ dữ liệu: Mỗi bước Focus hoàn thành (hoặc bị dừng sớm khi đã học trên 1 phút) được ghi thành một dòng mới trong tab Logs. Chức năng thống kê đọc dữ liệu trực tiếp từ tab này.

Quy ước vị trí bước trong chuỗi: Mảng steps luôn được thiết kế xen kẽ: vị trí chẵn (0, 2, 4...) là Focus, vị trí lẻ (1, 3, 5...) là Nghỉ. Ví dụ, mảng [25, 5, 25, 5, 25, 5, 25] biểu diễn 4 phiên học 25 phút xen kẽ 3 lần nghỉ 5 phút.


6. Hướng dẫn cài đặt từng bước

Toàn bộ quy trình thiết lập chỉ mất khoảng 10–15 phút và thực hiện hoàn toàn trên trình duyệt máy tính.

Bước 1: Tạo bot Telegram và lấy token

Mục đích: Khởi tạo tài khoản bot mới và nhận mã định danh bảo mật (BOT_TOKEN) để cấp quyền điều khiển cho mã nguồn.

  1. Mở ứng dụng Telegram, tìm kiếm tài khoản @BotFather (có dấu tích xanh xác thực).
  2. Gửi lệnh /newbot.
  3. Nhập tên hiển thị cho bot (ví dụ: Pomodoro Y Khoa Bot).
  4. Nhập username cho bot (bắt buộc phải kết thúc bằng chữ bot, ví dụ: ykhoa_pomodoro_bot).
  5. BotFather sẽ gửi lại một đoạn mã token có định dạng dạng 123456789:AAxxxx.... Sao chép và lưu đoạn mã này ở nơi an toàn.
IMPORTANT

BOT_TOKEN là chìa khóa bảo mật tối cao của bot. Không chia sẻ token này lên diễn đàn công khai hoặc kho mã nguồn mở.

Bước 2: Tạo Google Sheet lưu log học tập

Mục đích: Tạo cơ sở dữ liệu dạng bảng để lưu trữ từng phiên học phục vụ cho việc tính toán thống kê.

  1. Truy cập sheets.google.com và tạo một bảng tính mới (đặt tên tùy ý, ví dụ: Pomodoro Study Logs).
  2. Quan sát thanh địa chỉ trên trình duyệt web, URL sẽ có dạng: https://docs.google.com/spreadsheets/d/<CHUỖI_ID_CỦA_SHEET>/edit
  3. Sao chép chuỗi ký tự dài nằm giữa /d/ và /edit. Đây chính là giá trị SS_ID.
  4. Bạn không cần tự tạo trang tính con tên Logs — hàm setupBot() ở bước sau sẽ tự động khởi tạo bảng này cùng hàng tiêu đề chuẩn.

Bước 3: Dán mã nguồn vào Google Apps Script

Mục đích: Đưa mã nguồn điều khiển vào hệ thống Apps Script gắn liền với bảng tính Google Sheet vừa tạo.

  1. Tại giao diện Google Sheet, vào menu Tiện ích mở rộng (Extensions) → chọn Apps Script.
  2. Xóa toàn bộ đoạn mã mẫu mặc định trong tệp Mã.gs (hoặc Code.gs).
  3. Sao chép và dán toàn bộ đoạn mã nguồn ở Mục 9: Toàn bộ mã nguồn vào trình soạn thảo.
  4. Chỉnh sửa 2 dòng cấu hình ở đầu file:
    • Thay 'YOUR_BOT_TOKEN' bằng chuỗi token lấy từ Bước 1.
    • Thay 'YOUR_SHEET_ID' bằng chuỗi ID lấy từ Bước 2.
    • Giữ nguyên dòng WEBHOOK_URL (sẽ cập nhật ở bước kế tiếp).
  5. Bấm biểu tượng đĩa mềm Lưu dự án (Save).

Bước 4: Triển khai Web App để lấy Webhook URL

Mục đích: Xuất bản dự án thành một ứng dụng web công khai để tiếp nhận tín hiệu từ Telegram.

  1. Ở góc trên bên phải màn hình Apps Script, bấm nút Triển khai (Deploy) → chọn Tùy chọn triển khai mới (New deployment).
  2. Bấm vào biểu tượng bánh răng ⚙ bên cạnh mục "Chọn loại" (Select type) → chọn Ứng dụng web (Web app).
  3. Cấu hình thông số triển khai:
    • Mô tả: Pomodoro Bot v3.1
    • Thực thi dưới dạng (Execute as): Tôi (Me)
    • Ai có quyền truy cập (Who has access): Bất kỳ ai (Anyone) (Bắt buộc phải chọn Anyone để máy chủ Telegram có thể gửi dữ liệu Webhook).
  4. Bấm nút Triển khai (Deploy).
  5. Khi hệ thống yêu cầu ủy quyền, bấm Xem lại quyền (Review permissions) → chọn tài khoản Google của bạn → bấm Nâng cao (Advanced) → chọn Đi tới dự án (không an toàn) → bấm Cho phép (Allow).
  6. Sao chép đường dẫn URL ứng dụng web (đường dẫn có đuôi kết thúc bằng /exec).
  7. Quay lại trình soạn thảo Apps Script, dán URL vừa sao chép vào hằng số WEBHOOK_URL ở đầu mã nguồn.
  8. Bấm Lưu (Save).
WARNING

Mỗi lần chỉnh sửa mã nguồn sau này, bạn phải vào Triển khai (Deploy) → Quản lý các bản triển khai (Manage deployments) → bấm biểu tượng cây bút chì ✏️ để chỉnh sửa → chọn Phiên bản mới (New version) → bấm Triển khai thì thay đổi mới có hiệu lực trên Web App.

Bước 5: Chạy hàm setupBot() một lần

Mục đích: Đăng ký địa chỉ Webhook với Telegram API và tự động tạo trang tính Logs với cấu trúc cột chuẩn.

  1. Trên thanh công cụ Apps Script, chọn hàm setupBot trong danh sách dropdown các hàm.
  2. Bấm nút Chạy (Run).
  3. Kiểm tra kết quả: Mở một tab mới trên trình duyệt và truy cập đường dẫn: https://api.telegram.org/bot<BOT_TOKEN>/getWebhookInfo (Thay <BOT_TOKEN> bằng token thực tế của bạn).
  4. Nếu kết quả trả về JSON có trường "url" trùng khớp với địa chỉ Web App /exec của bạn và không có lỗi, quá trình đăng ký webhook đã thành công.

Bước 6: Cài đặt Trigger kiểm tra mỗi phút

Mục đích: Thiết lập chuông báo thức định thời để hệ thống tự động kiểm tra thời gian mỗi phút một lần ngay cả khi bạn không tương tác với điện thoại.

  1. Ở thanh menu bên trái của Apps Script, bấm vào biểu tượng chiếc đồng hồ ⏰ Trình kích hoạt (Triggers).
  2. Bấm nút Thêm trình kích hoạt (Add Trigger) ở góc dưới bên phải.
  3. Thiết lập các thông số chính xác như sau:
    • Chọn hàm để chạy: checkTimers
    • Chọn lượt triển khai sẽ chạy: Head
    • Chọn nguồn sự kiện: Dựa trên thời gian (Time-driven)
    • Chọn loại bộ kích hoạt theo thời gian: Bộ hẹn giờ theo phút (Minutes timer)
    • Chọn khoảng thời gian tính bằng phút: Mỗi phút (Every minute)
  4. Bấm Lưu (Save).
CRITICAL

Nếu bỏ qua Bước 6, bot vẫn tiếp nhận được lệnh bấm nút nhưng hoàn toàn không thể tự nhắc 5 phút, không thể tự chuyển bước và không thể tự ghi log khi hết giờ.

Bước 7: Cài đặt múi giờ Việt Nam

Mục đích: Đồng bộ múi giờ hệ thống của dự án về GMT+07 để báo cáo "Hôm nay" tính đúng theo ngày địa phương.

  1. Bấm vào biểu tượng bánh răng ⚙ Cài đặt dự án (Project Settings) ở menu bên trái.
  2. Kiểm tra mục Múi giờ (Time zone). Nếu chưa phải giờ Việt Nam, hãy chọn (GMT+07:00) Giờ Việt Nam hoặc Asia/Ho_Chi_Minh / Asia/Saigon.

Bước 8: Kiểm thử hoạt động thực tế

  1. Mở ứng dụng Telegram, tìm kiếm bot của bạn theo username và bấm nút Start hoặc gõ lệnh /menu. Bảng nút bấm điều khiển sẽ xuất hiện ngay lập tức.
  2. Kiểm tra nhanh trong 2 phút: Gõ số 2 vào khung chat → chọn một môn học bất kỳ. Sau khoảng 2 phút (chênh lệch tối đa 60 giây do chu kỳ trigger), bot sẽ thông báo hoàn thành phiên và trong tab Logs của Google Sheet sẽ xuất hiện một dòng dữ liệu mới.

7. Hướng dẫn sử dụng chi tiết

7.0 Kịch bản một buổi học điển hình

Giả sử bạn muốn học môn Nhi theo chu kỳ Pomodoro 25 phút học, 5 phút nghỉ:

Bạn : /menu
Bot : 👇 Bảng điều khiển Y Khoa:  [Bảng nút bấm inline]
Bạn : (Bấm nút 🍅 25/5)
Bot : 📝 Quy trình Pomo (25/5) — Bác sĩ chọn môn học nào?  [Nội | Ngoại | Sản | Nhi | YHCS | Me]
Bạn : (Bấm nút Nhi)
Bot : ✅ Bắt đầu: Nhi (Pomo (25/5)) — ⏱ Focus: 25 phút. Tập trung cao độ nhé!
...   (Sau 20 phút học tập trung)
Bot : ⏳ Còn 5 phút: Nhi — Tăng tốc chốt vấn đề nào.
...   (Hết 25 phút Focus)
Bot : 🚨 Xong Pomodoro! Nghỉ ngơi tự động 5p.  [Hệ thống tự ghi 25 phút môn Nhi vào Sheet]
...   (Hết 5 phút nghỉ ngơi)
Bot : 🏆 HOÀN THÀNH: Đã xong Pomo (25/5)!

Đối với chuỗi ngắn 25/5, chu kỳ kết thúc sau khi hết 5 phút nghỉ. Nếu muốn học nhiều chu kỳ liên tiếp liên tục, hãy chọn chuỗi marathon 4x25 hoặc 3x50.

7.1 Bắt đầu một phiên học

Cách 1: Sử dụng chuỗi cài đặt sẵn

  1. Gõ /menu (hoặc /start) để mở bảng điều khiển chính.
  2. Lựa chọn một trong các chuỗi học phù hợp với mục tiêu:
Nút bấmĐịnh dạng chu kỳTổng thời gian của chuỗi
🍅 25/5 (1 Pomo)1 hiệp Pomodoro: học 25 phút → nghỉ 5 phút30 phút
🧠 50/10 (1 Deep)1 phiên học sâu: học 50 phút → nghỉ 10 phút60 phút
🔗 4x25 (120p)Chuỗi dài 4 hiệp: 25p học – 5p nghỉ (hiệp 4 kết thúc ở Focus)120 phút học thực tế
🔗 3x50 (180p)Chuỗi dài 3 hiệp: 50p học – 10p nghỉ (hiệp 3 kết thúc ở Focus)150 phút học + 20 phút nghỉ
  1. Khi bot hỏi "Bác sĩ chọn môn học nào?", bấm chọn môn trên bàn phím nhanh hoặc tự gõ tên môn bất kỳ vào khung chat.
  2. Bot phản hồi thông báo xác nhận và bắt đầu đếm thời gian.

Cách 2: Thiết lập số phút tùy ý (Tính năng mới ở v3.1)

  1. Gõ trực tiếp một con số nguyên dương (ví dụ 15 hoặc /15).
  2. Bot xác nhận "Học 15 phút. Bác sĩ chọn môn học nào?".
  3. Chọn môn học để khởi động bộ đếm đúng số phút đã chọn (không có giai đoạn nghỉ tự động).

7.2 Cơ chế vận hành trong phiên học

  • Nhắc nhở chặng cuối: Khi thời gian Focus còn lại ≤ 5 phút, bot sẽ gửi tin nhắn nhắc nhở để bạn tăng tốc hoàn thiện nội dung.
  • Tự động chuyển bước: Khi hết giờ Focus, bot gửi thông báo và tự động tính giờ nghỉ ngơi; khi hết giờ nghỉ, bot gửi thông báo chuyển lại giờ Focus.
  • Tự động kết thúc chuỗi: Khi hoàn thành bước cuối cùng trong chuỗi, bot gửi thông báo chiến thắng và tự động dọn dẹp phiên học khỏi bộ nhớ.
  • Ghi đè phiên: Nếu bạn bấm chọn chuỗi mới trong khi một phiên cũ đang chạy, bot sẽ tự động hủy phiên cũ và bắt đầu phiên mới.

7.3 Bảng lệnh điều khiển

Lệnh / NútThao tác tương ứngHành vi chi tiết
⏸ Pause/pauseTạm dừng bộ đếm, bot lưu lại số phút còn lại chính xác.
▶️ Resume/resumeTiếp tục bộ đếm từ mốc thời gian đã tạm dừng.
🛑 Stop/stopHủy phiên học. Nếu đang trong bước Focus và đã học ≥ 1 phút, bot sẽ tự động lưu số phút thực học vào Sheet kèm ghi chú (Dừng sớm).
Trạng thái/statusXem phiên hiện tại đang ở trạng thái nào (Focus hay Nghỉ) và còn lại bao nhiêu phút.
📊 Hôm nay/todayXuất bảng tổng hợp số phút đã học trong ngày hôm nay theo từng môn.
📅 7 ngày/weekXuất bảng tổng hợp số phút học 7 ngày qua, kèm mức trung bình phút/ngày.
Trợ giúp/helpHiển thị hướng dẫn sử dụng nhanh các lệnh cơ bản.

7.4 Ví dụ thống kê và cấu trúc dữ liệu Google Sheet

Mẫu tin nhắn thống kê xuất về Telegram

📊 Thống kê hôm nay:
────────────────
• Nhi: 75p
• Nội: 50p
────────────────
🔥 Tổng cộng: 2h 5p

Dữ liệu thực tế được ghi nhận trong tab Logs

Mỗi khi một phiên Focus hoàn thành hoặc bị dừng sớm hợp lệ, một dòng mới được tự động thêm vào cuối bảng:

TimestampChatIDTopicDurationMinChainName
2026-09-29 08:25:10123456789Nhi25Pomo (25/5)
2026-09-29 09:40:22123456789Nội18Deep (50/10) (Dừng sớm)
2026-09-29 10:30:05123456789Sản10Tùy chọn 10p

Bạn có thể tận dụng dữ liệu trong tab Logs để vẽ biểu đồ hình tròn (phân bố môn học) hoặc biểu đồ cột (tiến độ học qua các tuần) bằng các công cụ tích hợp sẵn của Google Sheets.

7.5 Tùy chỉnh danh sách môn học và chuỗi học

  • Thay đổi danh sách môn học: Mở mã nguồn, tìm đến biến TOPIC_KEYBOARD. Bạn có thể thay đổi hoặc thêm bớt các nút bấm { text: 'Tên môn mới' }.
  • Thêm hoặc sửa chuỗi học: Tìm đến biến CHAINS. Định nghĩa thêm khóa mới với mảng steps xen kẽ thời gian Focus và Nghỉ, sau đó thêm nút tương ứng vào KEYBOARD_MAIN.
  • Sau khi chỉnh sửa mã nguồn, thực hiện thao tác Deploy → New version để cập nhật cho bot.

8. Cấu trúc hàm trong mã nguồn

Bảng tổng hợp vai trò các hàm

Tên hàmTrách nhiệm chính trong hệ thống
doGet(e)Điểm cuối (endpoint) HTTP GET dùng để kiểm tra tình trạng sống của Web App trên trình duyệt.
doPost(e)Điểm cuối HTTP POST tiếp nhận cập nhật (webhook update) từ Telegram và định tuyến xử lý.
handleMessage(msg)Phân tích và xử lý tin nhắn văn bản: chọn môn học, nhập số phút tùy chọn, xử lý các lệnh /....
handleCallback(cb)Bắt sự kiện bấm nút trên bàn phím inline keyboard (chọn chuỗi, pause, resume, stop, xem thống kê).
startChain(...)Khởi tạo đối tượng phiên học, tính toán mốc thời gian kết thúc và lưu vào PropertiesService.
checkTimers()Hàm chạy nền định kỳ mỗi phút: quét các phiên đang hoạt động, gửi nhắc 5 phút, ghi log và chuyển bước.
pauseTimer(chatId)Tạm dừng đồng hồ và lưu lại số mili-giây còn lại chưa hoàn thành.
resumeTimer(chatId)Tái kích hoạt đồng hồ từ mốc thời gian còn lại đã lưu trước đó.
stopTimer(chatId)Hủy phiên học; tự động tính và ghi nhận số phút thực học nếu thời gian đã trôi qua đạt từ 1 phút trở lên.
checkStatus(chatId)Phản hồi thông tin về bước hiện tại và số phút còn lại của phiên học.
logToSheet(...)Ghi thêm một dòng dữ liệu mới vào tab Logs trên Google Sheets.
getDailyStats(chatId)Lọc dữ liệu trong bảng tính của ngày hiện tại theo chatId và gửi báo cáo tổng kết theo môn.
getWeeklyStats(chatId)Lọc dữ liệu 7 ngày gần nhất, tổng hợp thời lượng từng môn và tính trung bình số phút học mỗi ngày.
sendText(...)Gọi Telegram Bot API (sendMessage) để gửi tin nhắn kèm bàn phím tùy chọn.
answerCallback(...)Phản hồi xác nhận cho Telegram khi người dùng bấm nút inline để tắt trạng thái tải trên nút.
md(text)Xử lý escape các ký tự đặc biệt trong văn bản để tránh lỗi cú pháp Markdown của Telegram.
setupBot()Khởi tạo bảng tính và thiết lập đăng ký Webhook URL với Telegram API (chạy 1 lần duy nhất).
ensureSheets()Kiểm tra và tự động khởi tạo tab Logs kèm tiêu đề 5 cột nếu bảng tính chưa có.

Đọc hiểu code từng khối

Khối mã nguồnVai trò cốt lõi
BOT_TOKEN, SS_ID, WEBHOOK_URLBa thông số xác thực cốt lõi của người dùng: mã token của bot, mã ID Google Sheet và địa chỉ Web App.
KEYBOARD_MAINCấu hình bàn phím inline hiển thị dưới các tin nhắn điều khiển chính.
TOPIC_KEYBOARDCấu hình bàn phím dạng nút bấm nhanh dưới khung chat để chọn môn học thuận tiện trên điện thoại.
CHAINSĐịnh nghĩa công thức các chuỗi học. Quy tắc: steps: [25, 5] tương ứng 25 phút Focus và 5 phút Nghỉ.
doPostCửa ngõ duy nhất tiếp nhận mọi dữ liệu từ Telegram gửi về máy chủ Google.
checkTimersNgười kiểm soát thời gian độc lập: định kỳ mỗi phút duyệt qua các phiên đang chạy để đưa ra hành động tương ứng.
stopTimerCơ chế ghi nhận thời gian linh hoạt: ngăn chặn việc mất dữ liệu học tập khi người dùng có việc đột xuất phải dừng sớm.

Ví dụ dòng thời gian của chuỗi Marathon 4x25 (bắt đầu lúc 08:00)

Thời điểm thực tếDiễn biến thao tác và hệ thốngChỉ số bước (currentIndex)
08:00Người dùng chọn môn → startChain() kích hoạt, mốc kết thúc tính là 08:25.Bước 0 (Focus 25 phút)
~08:20Trigger chạy quét thấy còn ≤ 5 phút → gửi tin nhắn nhắc nhở.Bước 0
~08:25 – 08:26Trigger phát hiện chạm mốc kết thúc → ghi 25 phút vào Sheet, chuyển sang giờ nghỉ 5 phút.Bước 1 (Nghỉ 5 phút)
~08:31Hết thời gian nghỉ → gửi thông báo quay lại giờ học Focus hiệp 2.Bước 2 (Focus 25 phút)
...Chu trình lặp lại cho các hiệp tiếp theo.Bước 3, 4, 5, 6
Sau bước 6Ghi log hiệp cuối, phát hiện hết chuỗi → gửi tin nhắn chiến thắng HOÀN THÀNH và xóa phiên.—

Do trình kích hoạt của Google Apps Script có chu kỳ quét mỗi 60 giây, thời điểm kích hoạt bước kế tiếp có thể chênh lệch tối đa khoảng 1 phút so với đồng hồ thực. Sai số này không ảnh hưởng đến độ chuẩn xác của số phút học được lưu vào bảng tính.


9. Toàn bộ mã nguồn (v3.1)

Sao chép toàn bộ mã nguồn bên dưới và dán vào tệp script trên Google Apps Script của bạn:

// =========================
// POMODORO TELEGRAM BOT v3.1 - Tinh Giản & Fix Lỗi Lưu Giờ
// =========================

const BOT_TOKEN = 'YOUR_BOT_TOKEN';
const SS_ID = 'YOUR_SHEET_ID';
const WEBHOOK_URL = 'YOUR_WEBAPP_URL'; // kết thúc bằng /exec

// --- MENU & CẤU HÌNH CHUỖI ---
const KEYBOARD_MAIN = {
  inline_keyboard: [
    [
      { text: '🍅 25/5 (1 Pomo)', callback_data: 'chain_25_5' },
      { text: '🧠 50/10 (1 Deep)', callback_data: 'chain_50_10' },
    ],
    [
      { text: '🔗 4x25 (120p)', callback_data: 'chain_4x25' },
      { text: '🔗 3x50 (180p)', callback_data: 'chain_3x50' },
    ],
    [
      { text: '📊 Hôm nay', callback_data: 'stats_today' },
      { text: '📅 7 ngày', callback_data: 'stats_week' },
    ],
    [
      { text: '⏸ Pause', callback_data: 'pause_timer' },
      { text: '▶️ Resume', callback_data: 'resume_timer' },
      { text: '🛑 Stop', callback_data: 'stop_timer' },
    ],
  ],
};

const TOPIC_KEYBOARD = {
  keyboard: [
    [{ text: 'Nội' }, { text: 'Ngoại' }],
    [{ text: 'Sản' }, { text: 'Nhi' }],
    [{ text: 'YHCS' }, { text: 'Me' }],
  ],
  one_time_keyboard: true,
  resize_keyboard: true,
};

const CHAINS = {
  chain_25_5: { name: 'Pomo (25/5)', steps: [25, 5] },
  chain_50_10: { name: 'Deep (50/10)', steps: [50, 10] },
  chain_4x25: { name: 'Marathon 4x25', steps: [25, 5, 25, 5, 25, 5, 25] },
  chain_3x50: { name: 'Marathon 3x50', steps: [50, 10, 50, 10, 50] },
};

// =========================
// INIT & CORE
// =========================

function getConfig() {
  return { BOT_TOKEN, SS_ID, WEBHOOK_URL };
}

function ensureSheets() {
  const ss = SpreadsheetApp.openById(SS_ID);
  let logs = ss.getSheetByName('Logs');
  if (!logs) {
    logs = ss.insertSheet('Logs');
    logs.appendRow(['Timestamp', 'ChatID', 'Topic', 'DurationMin', 'ChainName']);
  }
}

function doGet() {
  return ContentService.createTextOutput('Bot v3.1 đang hoạt động.');
}

function doPost(e) {
  try {
    if (!e || !e.postData || !e.postData.contents) return;
    const data = JSON.parse(e.postData.contents);

    if (data.callback_query) handleCallback(data.callback_query);
    else if (data.message) handleMessage(data.message);
  } catch (err) {
    Logger.log('Error doPost: ' + err);
  }
}

// =========================
// HANDLERS
// =========================

function handleMessage(msg) {
  const chatId = msg.chat.id;
  const text = (msg.text || '').trim();
  if (!text) return;

  const props = PropertiesService.getScriptProperties();
  const waitKey = `WAIT_TOPIC_${chatId}`;
  const waitingChain = props.getProperty(waitKey);

  // 1. Xử lý khi đang chờ chọn môn học
  if (waitingChain) {
    props.deleteProperty(waitKey);
    startChain(chatId, waitingChain, text);
    return;
  }

  // 2. Xử lý khi nhập số phút trực tiếp (Ví dụ: 10 hoặc /10)
  const cleanedText = text.replace('/', '');
  const customTime = parseInt(cleanedText, 10);
  if (!isNaN(customTime) && customTime > 0) {
    props.setProperty(waitKey, 'CUSTOM_' + customTime);
    return sendText(
      chatId,
      `📝 *Học ${customTime} phút.*\nBác sĩ chọn môn học nào?`,
      TOPIC_KEYBOARD,
    );
  }

  // 3. Các lệnh cơ bản
  switch (text) {
    case '/start':
    case '/menu':
      return sendText(chatId, '👇 *Bảng điều khiển Y Khoa:*', KEYBOARD_MAIN);
    case '/stop':
      return stopTimer(chatId);
    case '/pause':
      return pauseTimer(chatId);
    case '/resume':
      return resumeTimer(chatId);
    case '/status':
      return checkStatus(chatId);
    case '/today':
      return getDailyStats(chatId);
    case '/week':
      return getWeeklyStats(chatId);
    case '/help':
      return sendText(
        chatId,
        '🛠 *Hướng dẫn Bot v3.1:*\n• Bấm nút để chạy hoặc gõ số (vd: `10`) để chạy nhanh 10 phút.\n• Dừng giữa chừng vẫn được lưu thời gian đã học.\n• Bấm chuỗi mới tự ghi đè chuỗi cũ.',
      );
  }
}

function handleCallback(cb) {
  const chatId = cb.message.chat.id;
  const data = cb.data;

  try {
    if (CHAINS[data]) {
      const props = PropertiesService.getScriptProperties();
      props.setProperty(`WAIT_TOPIC_${chatId}`, data);
      sendText(
        chatId,
        `📝 *Quy trình ${CHAINS[data].name}*\nBác sĩ chọn môn học nào?`,
        TOPIC_KEYBOARD,
      );
    } else if (data === 'stop_timer') stopTimer(chatId);
    else if (data === 'pause_timer') pauseTimer(chatId);
    else if (data === 'resume_timer') resumeTimer(chatId);
    else if (data === 'stats_today') getDailyStats(chatId);
    else if (data === 'stats_week') getWeeklyStats(chatId);

    answerCallback(cb.id);
  } catch (err) {
    answerCallback(cb.id);
  }
}

// =========================
// TIMER & CHAIN LOGIC
// =========================

function startChain(chatId, chainId, topic) {
  const props = PropertiesService.getScriptProperties();
  let chainConfig;

  // Xử lý nếu là timer tùy chọn (nhập số)
  if (chainId.startsWith('CUSTOM_')) {
    const mins = parseInt(chainId.split('_')[1], 10);
    chainConfig = { name: `Tùy chọn ${mins}p`, steps: [mins] };
  } else {
    chainConfig = CHAINS[chainId];
  }

  const state = {
    chainName: chainConfig.name,
    steps: chainConfig.steps,
    currentIndex: 0,
    topic: topic,
    startTime: new Date().getTime(),
    status: 'RUNNING',
    reminded5: false,
  };

  state.endTime = state.startTime + state.steps[0] * 60 * 1000;
  props.setProperty('SESSION_' + chatId, JSON.stringify(state));

  sendText(
    chatId,
    `✅ *Bắt đầu:* ${md(topic)} (${state.chainName})\n` +
      `⏱ Focus: *${state.steps[0]} phút*\n_Tập trung cao độ nhé!_`,
    { remove_keyboard: true },
  );
  sendText(chatId, '👇 *Điều khiển:*', KEYBOARD_MAIN);
}

function checkTimers() {
  const props = PropertiesService.getScriptProperties();
  const keys = props.getKeys();
  const now = new Date().getTime();

  keys.forEach((key) => {
    if (!key.startsWith('SESSION_')) return;

    const stateJson = props.getProperty(key);
    if (!stateJson) return;
    const state = JSON.parse(stateJson);
    const chatId = key.split('_')[1];

    if (state.status !== 'RUNNING') return;

    const isFocus = state.currentIndex % 2 === 0;
    const remainMs = state.endTime - now;

    if (
      isFocus &&
      state.steps[state.currentIndex] > 5 &&
      !state.reminded5 &&
      remainMs > 0 &&
      remainMs <= 5 * 60 * 1000
    ) {
      state.reminded5 = true;
      props.setProperty(key, JSON.stringify(state));
      sendText(chatId, `⏳ *Còn 5 phút:* ${md(state.topic)}\nTăng tốc chốt vấn đề nào.`);
      return;
    }

    if (remainMs <= 0) {
      if (isFocus) {
        logToSheet(chatId, state.topic, state.steps[state.currentIndex], state.chainName);
      }

      state.currentIndex++;

      if (state.currentIndex < state.steps.length) {
        const nextIsFocus = state.currentIndex % 2 === 0;
        const nextDuration = state.steps[state.currentIndex];
        state.endTime = now + nextDuration * 60 * 1000;
        state.reminded5 = false;

        props.setProperty(key, JSON.stringify(state));

        const msg = nextIsFocus
          ? `🔔 *Hết giờ nghỉ!* Quay lại Focus ${nextDuration}p môn ${md(state.topic)}.`
          : `🚨 *Xong Pomodoro!* Nghỉ ngơi tự động ${nextDuration}p.`;

        sendText(chatId, msg);
      } else {
        props.deleteProperty(key);
        sendText(chatId, `🏆 *HOÀN THÀNH:* Đã xong ${md(state.chainName)}!`, KEYBOARD_MAIN);
      }
    }
  });
}

// --- PAUSE / RESUME / STOP ---

function pauseTimer(chatId) {
  const props = PropertiesService.getScriptProperties();
  const stateJson = props.getProperty('SESSION_' + chatId);
  if (!stateJson) return sendText(chatId, '💤 Không có timer đang chạy.', KEYBOARD_MAIN);

  const state = JSON.parse(stateJson);
  if (state.status === 'PAUSED') return sendText(chatId, '⏸ Đã tạm dừng trước đó.');

  state.status = 'PAUSED';
  state.remainingMs = Math.max(0, state.endTime - new Date().getTime());
  props.setProperty('SESSION_' + chatId, JSON.stringify(state));

  sendText(
    chatId,
    `⏸ *Đã tạm dừng*\nCòn lại khoảng *${Math.ceil(state.remainingMs / 60000)} phút*.`,
    KEYBOARD_MAIN,
  );
}

function resumeTimer(chatId) {
  const props = PropertiesService.getScriptProperties();
  const stateJson = props.getProperty('SESSION_' + chatId);
  if (!stateJson) return sendText(chatId, '💤 Không có timer.', KEYBOARD_MAIN);

  const state = JSON.parse(stateJson);
  if (state.status !== 'PAUSED') return sendText(chatId, '▶️ Timer đang chạy rồi.');

  state.status = 'RUNNING';
  state.endTime = new Date().getTime() + state.remainingMs;
  delete state.remainingMs;
  props.setProperty('SESSION_' + chatId, JSON.stringify(state));

  sendText(
    chatId,
    `▶️ *Tiếp tục chạy*\nCòn lại: *${Math.ceil((state.endTime - new Date().getTime()) / 60000)} phút*.`,
    KEYBOARD_MAIN,
  );
}

function stopTimer(chatId) {
  const props = PropertiesService.getScriptProperties();
  const stateJson = props.getProperty('SESSION_' + chatId);

  if (stateJson) {
    const state = JSON.parse(stateJson);
    const isFocus = state.currentIndex % 2 === 0;
    if (isFocus) {
      const totalDurationMs = state.steps[state.currentIndex] * 60 * 1000;
      let elapsedMs = 0;

      // Tính số phút đã trôi qua
      if (state.status === 'RUNNING') {
        elapsedMs = totalDurationMs - (state.endTime - new Date().getTime());
      } else if (state.status === 'PAUSED') {
        elapsedMs = totalDurationMs - state.remainingMs;
      }
      const elapsedMins = Math.floor(elapsedMs / 60000);

      // Nếu học trên 1 phút thì lưu lại
      if (elapsedMins > 0) {
        logToSheet(chatId, state.topic, elapsedMins, state.chainName + ' (Dừng sớm)');
        sendText(
          chatId,
          `🛑 *Đã dừng phiên sớm.*\n📌 Ghi nhận: *${elapsedMins} phút* cho môn ${md(state.topic)}.`,
        );
      } else {
        sendText(chatId, '🛑 *Đã hủy phiên.* (Chưa đủ 1 phút nên không lưu).');
      }
    } else {
      sendText(chatId, '🛑 *Đã hủy giờ nghỉ.*');
    }
  } else {
    sendText(chatId, '🛑 Không có phiên nào đang chạy.');
  }

  props.deleteProperty('SESSION_' + chatId);
  props.deleteProperty('WAIT_TOPIC_' + chatId);
  sendText(chatId, '👇 *Bảng điều khiển:*', KEYBOARD_MAIN);
}

function checkStatus(chatId) {
  const props = PropertiesService.getScriptProperties();
  const stateJson = props.getProperty('SESSION_' + chatId);
  if (!stateJson) return sendText(chatId, '💤 Bot rảnh.', KEYBOARD_MAIN);

  const state = JSON.parse(stateJson);
  const isFocus = state.currentIndex % 2 === 0;
  const typeStr = isFocus ? 'Focus' : 'Nghỉ ngơi';

  if (state.status === 'PAUSED') {
    return sendText(
      chatId,
      `⏸ *Đang tạm dừng (${typeStr}):* ${md(state.topic)}\nCòn lại: *${Math.ceil(state.remainingMs / 60000)} phút*`,
    );
  }

  const timeLeft = Math.max(0, Math.ceil((state.endTime - new Date().getTime()) / 60000));
  sendText(chatId, `⏳ *Đang chạy (${typeStr}):* ${md(state.topic)}\nCòn lại: *${timeLeft} phút*`);
}

// =========================
// TRACKING & LOGS
// =========================

function logToSheet(chatId, topic, duration, chainName) {
  const ss = SpreadsheetApp.openById(SS_ID);
  const sheet = ss.getSheetByName('Logs');
  sheet.appendRow([new Date(), chatId, topic, duration, chainName]);
}

function getDailyStats(chatId) {
  const sheet = SpreadsheetApp.openById(SS_ID).getSheetByName('Logs');
  const data = sheet.getDataRange().getValues();
  const today = new Date().toDateString();

  let stats = {};
  let totalMins = 0;

  for (let i = 1; i < data.length; i++) {
    if (new Date(data[i][0]).toDateString() === today && String(data[i][1]) === String(chatId)) {
      const topic = String(data[i][2] || 'Không rõ');
      const dur = parseInt(data[i][3], 10) || 0;
      if (!stats[topic]) stats[topic] = 0;
      stats[topic] += dur;
      totalMins += dur;
    }
  }

  if (totalMins === 0) return sendText(chatId, '📉 Hôm nay chưa cày ca nào.', KEYBOARD_MAIN);

  let msg = `📊 *Thống kê hôm nay:*\n────────────────\n`;
  Object.keys(stats)
    .sort()
    .forEach((t) => (msg += `• ${md(t)}: *${stats[t]}p*\n`));
  msg += `────────────────\n🔥 Tổng cộng: *${Math.floor(totalMins / 60)}h ${totalMins % 60}p*`;

  sendText(chatId, msg, KEYBOARD_MAIN);
}

function getWeeklyStats(chatId) {
  const sheet = SpreadsheetApp.openById(SS_ID).getSheetByName('Logs');
  const data = sheet.getDataRange().getValues();
  const start = new Date(new Date().setDate(new Date().getDate() - 6)).setHours(0, 0, 0, 0);

  let totalMins = 0,
    topicStats = {};

  for (let i = 1; i < data.length; i++) {
    const rowDate = new Date(data[i][0]);
    if (String(data[i][1]) === String(chatId) && rowDate >= start) {
      const topic = String(data[i][2] || 'Không rõ');
      const dur = parseInt(data[i][3], 10) || 0;
      totalMins += dur;
      if (!topicStats[topic]) topicStats[topic] = 0;
      topicStats[topic] += dur;
    }
  }

  if (totalMins === 0) return sendText(chatId, '📭 Chưa có dữ liệu 7 ngày qua.');

  let msg = '📅 *Thống kê 7 ngày:*\n────────────────\n';
  Object.keys(topicStats)
    .sort()
    .forEach((t) => (msg += `• ${md(t)}: *${topicStats[t]}p*\n`));
  msg += `────────────────\n🔥 Tổng thời gian: *${Math.floor(totalMins / 60)}h ${totalMins % 60}p*\n`;
  msg += `📈 Trung bình: *${Math.round(totalMins / 7)} phút/ngày*`;

  sendText(chatId, msg, KEYBOARD_MAIN);
}

// =========================
// UTILS & SETUP
// =========================

function sendText(chatId, text, keyboard) {
  const payload = { chat_id: chatId, text: text, parse_mode: 'Markdown' };
  if (keyboard) payload.reply_markup = keyboard;
  UrlFetchApp.fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });
}

function answerCallback(callback_query_id) {
  UrlFetchApp.fetch(`https://api.telegram.org/bot${BOT_TOKEN}/answerCallbackQuery`, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ callback_query_id }),
    muteHttpExceptions: true,
  });
}

function md(text) {
  if (!text) return '';
  return String(text).replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
}

function setupBot() {
  ensureSheets();
  UrlFetchApp.fetch(`https://api.telegram.org/bot${BOT_TOKEN}/setWebhook?url=${WEBHOOK_URL}`, {
    muteHttpExceptions: true,
  });
}

10. Giới hạn hệ thống & Xử lý sự cố

WARNING

Bảo mật token: BOT_TOKEN cho phép toàn quyền chỉ huy bot của bạn. Nếu nghi ngờ bị lộ, hãy mở ngay @BotFather, gõ lệnh /revoke để hủy bỏ và tạo mã token mới, sau đó cập nhật lại vào Apps Script và chạy lại hàm setupBot().

Giới hạn kỹ thuật cần biết

  • Độ trễ tối đa khoảng 1 phút: Do checkTimers được gọi qua trigger định thời mỗi phút một lần, các thông báo nhắc nhở hoặc hoàn thành có thể đến chậm hơn tối đa 60 giây so với mốc thời gian thực tế.
  • Không giới hạn người dùng: Bất kỳ ai có username của bot đều có thể sử dụng. Mỗi người dùng có phiên chạy và thống kê riêng biệt dựa trên chatId, tuy nhiên tất cả dữ liệu lịch sử đều được lưu chung vào một bảng tính Google Sheets.
  • Hàng đợi nhập tên môn: Khi bot đang ở trạng thái chờ chọn môn học, bất kỳ tin nhắn văn bản nào bạn gửi tiếp theo đều được hiểu là tên môn học. Nếu muốn thoát trạng thái này, hãy chọn tạm một môn rồi bấm nút Stop.
  • Ghi đè phiên học đang chạy: Nếu bạn gửi số phút mới hoặc bấm chọn chuỗi mới khi đang có phiên chạy dở, phiên cũ sẽ bị ghi đè và phần thời gian chưa hoàn thành sẽ không được lưu. Hãy bấm nút Stop trước nếu muốn lưu lại số phút đã học.
  • Dừng giữa chuỗi marathon: Khi bấm Stop ở giữa một chuỗi dài (như 4x25), hệ thống chỉ lưu phần thời gian Focus đang dở dang; các hiệp Focus đã hoàn thành trước đó đã được ghi vào bảng tính tự động.
  • Hàm getConfig(): Hiện tại chỉ mang tính tham khảo cấu hình tĩnh, không ảnh hưởng đến luồng hoạt động chính.
  • Cơ chế trích xuất chatId: Hàm checkTimers lấy chatId bằng cú pháp key.split('_')[1]. Nếu sau này bạn có ý định đổi tiền tố khóa phiên, nên dùng key.replace('SESSION_', '').

Bảng xử lý sự cố thường gặp

Triệu chứngNguyên nhân tiềm ẩnHướng khắc phục
Bot hoàn toàn không phản hồiWebhook chưa đăng ký thành công, sai WEBHOOK_URL, hoặc quyền truy cập Web App chưa chọn "Anyone".Kiểm tra hằng số WEBHOOK_URL, thực hiện Deploy lại với quyền "Anyone", chạy lại hàm setupBot(), và kiểm tra qua link getWebhookInfo.
Bot nhận lệnh nhưng không tự nhắc giờ hoặc không chuyển bướcChưa tạo trình kích hoạt (Trigger) định thời cho hàm checkTimers.Thực hiện chính xác theo hướng dẫn tại Bước 6.
Đã sửa code nhưng bot vẫn chạy theo hành vi cũChưa xuất bản phiên bản mới sau khi chỉnh sửa mã nguồn.Vào Deploy → Manage deployments → bấm biểu tượng cây bút chì ✏️ → chọn New version → bấm Deploy.
Báo cáo thống kê lệch ngàyMúi giờ trong cài đặt dự án Apps Script không khớp với múi giờ Việt Nam.Vào Project Settings ⚙ → chuyển múi giờ về (GMT+07:00) Giờ Việt Nam hoặc Asia/Saigon.
Lỗi runtime: Cannot call method "appendRow" of nullBảng tính chưa có trang tính con tên là Logs.Chạy hàm setupBot() trong Apps Script hoặc tự tạo thủ công một tab mới có tên chính xác là Logs.
Tin nhắn phản hồi bị lỗi định dạng MarkdownTên môn học tự gõ chứa các ký tự đặc biệt của cú pháp Markdown (*, _, [...).Hàm tiện ích md() đã tự động escape; nếu vẫn xảy ra lỗi, hãy đặt tên môn học bằng chữ thông thường.

11. Các câu hỏi thường gặp (FAQ)

Hệ thống này có tốn phí duy trì không? Có bắt buộc bật máy tính không?

Hoàn toàn không mất chi phí và không cần bật máy tính. Bot chạy hoàn toàn trên đám mây của Google trong hạn mức miễn phí dành cho tài khoản cá nhân. Trình kích hoạt chạy mỗi phút tiêu tốn rất ít hạn mức hàng ngày (quota) của Google Apps Script.

Nếu đang học mà quên không bấm Stop thì sao?

Phiên học vẫn sẽ tiếp tục chạy theo đúng lịch trình cho đến khi hoàn thành hết các bước trong chuỗi rồi tự động kết thúc. Mọi hiệp Focus đã trôi qua đều được ghi lại trọn vẹn vào Google Sheets. Nếu bạn kết thúc việc học sớm hơn dự kiến, chỉ cần bấm nút Stop để hệ thống ghi nhận đúng số phút thực tế.

Tôi muốn thay đổi danh mục môn học hoặc thêm môn mới thì làm thế nào?

Mở tệp script trong Google Apps Script, tìm đến mảng TOPIC_KEYBOARD. Bạn có thể sửa tên các môn hoặc thêm hàng nút mới. Sau khi sửa xong, bấm Lưu và chọn Deploy → Manage deployments → New version → Deploy. Ngoài ra, bạn luôn có thể tự gõ bất kỳ tên môn nào vào khung chat khi bot hỏi.

Tôi muốn đổi thời lượng chuỗi (ví dụ muốn học 30 phút, nghỉ 5 phút)?

Trong đối tượng CHAINS, thay đổi giá trị mảng steps, ví dụ: steps: [30, 5]. Sau đó, cập nhật lại dòng chữ hiển thị trên nút bấm tương ứng trong KEYBOARD_MAIN. Hãy luôn giữ quy tắc: thời gian Focus và Nghỉ phải luôn xen kẽ nhau và chuỗi nên bắt đầu bằng một bước Focus.

Nhiều người có thể cùng sử dụng chung một bot này không?

Được. Mỗi người dùng kết nối sẽ có trạng thái phiên học và thống kê riêng biệt dựa trên mã chatId. Tuy nhiên, tất cả dữ liệu sẽ được lưu chung vào cùng một Google Sheet của bạn. Nếu các bạn học muốn giữ dữ liệu học tập hoàn toàn riêng tư, mỗi người nên tự tạo một bản sao bot theo hướng dẫn này.

Xem lại toàn bộ lịch sử học tập ở đâu? Có thể vẽ biểu đồ tiến độ không?

Mở Google Sheet đã liên kết, chọn tab Logs. Tại đây bạn có thể sử dụng các tính năng có sẵn như Bảng tổng hợp (Pivot Table) hoặc Biểu đồ (Chart) với trục là Tên môn học và giá trị là Tổng số phút (DurationMin). Lưu ý không đổi tên hoặc thứ tự 5 cột mặc định để tránh làm sai lệch việc đọc dữ liệu của code.

Khi không muốn dùng nữa, làm sao để tắt hoặc gỡ bỏ bot?

Vào mục Trình kích hoạt (Triggers) trong Apps Script và xóa trigger của hàm checkTimers để bot dừng việc quét thời gian. Để hủy nhận tin nhắn từ Telegram, mở trình duyệt và truy cập liên kết: https://api.telegram.org/bot<BOT_TOKEN>/deleteWebhook. Nếu muốn xóa vĩnh viễn bot, mở @BotFather và gửi lệnh /deletebot.

Tôi gõ số phút nhưng bot không phản hồi bộ đếm?

Số phút gửi vào bot phải là số nguyên dương hợp lệ (ví dụ 10, /10, 45). Các giá trị số 0, số âm hoặc chữ cái sẽ không được nhận dạng là lệnh hẹn giờ. Ngoài ra, nếu bot đang trong trạng thái chờ nhận tên môn học, mọi nội dung bạn gửi vào (kể cả số) đều sẽ bị gán làm tên môn của phiên trước đó.