buildDISCORD · Nhật ký kỹ thuật
Nhật ký kỹ thuật ghi lại cách buildDISCORD đi từ một ý tưởng tới một con bot chạy trên Raspberry Pi, kèm một trang web song ngữ đứng phía trước. Nó được viết cho những ai muốn đánh giá phần kỹ thuật: tôi đã làm gì, vì sao chọn như vậy, cái gì đã hỏng, và cái gì tôi vẫn còn muốn sửa.
Tóm tắt
buildDISCORD là một bot Discord (biệt danh "Thầu Xây Dựng") dựng cả một server chỉ bằng một lệnh, rồi trông coi nó sau đó (chống raid và khoá server, kiểm duyệt có lưu hồ sơ vụ việc, cấp độ hoạt động, giveaway, báo cáo hằng tuần, phòng voice tạm, hộp góp ý). Quá trình dựng tạo role, category, kênh chat và kênh voice, permission overwrite, nội quy, lời chào và bảng tự chọn role. Bạn có thể trộn tối đa bốn theme có sẵn, hoặc mô tả nhóm của mình trong một câu để Google Gemini thiết kế server. Dù chọn cách nào, bạn cũng được xem và chỉnh bản thiết kế trước khi bất cứ thứ gì được tạo ra. Bot bán theo từng server (một gói miễn phí và hai gói trả phí, trả bằng thẻ, QR ngân hàng hoặc mã kích hoạt), lưu dữ liệu trong SQLite, đóng gói thành Docker image, cài lên Raspberry Pi bằng một lệnh và tự cập nhật, có health check và rollback.
Công nghệ: Node.js 22 (ESM), discord.js 14, module SQLite có sẵn trong Node, Docker, systemd, GitHub Actions, Next.js 15 với React 19, GSAP và Lenis cho trang web, deploy trên Vercel.
Liên kết: trang web ở https://builddiscord.vercel.app và mã nguồn là repository này.
Con số
Các số này được đo trên repository, không phải ước lượng.
| Hạng mục | Giá trị |
|---|---|
Mã nguồn bot (src/, JavaScript) | khoảng 16.400 dòng trong 197 file, cộng khoảng 1.500 dòng front end của dashboard (phần khung và 18 file view) |
| Test | 801 test trong 50 file (khoảng 19.000 dòng, tính cả fake gateway), chạy bằng node --test |
Script (scripts/) | khoảng 370 dòng (CLI license, bộ cài cho Pi, updater, xuất dữ liệu cho web, công cụ tạo mẫu) |
Mã nguồn web (web/, TypeScript, TSX, CSS) | khoảng 5.000 dòng |
| Slash command | 40 (39 cho khách hàng, 1 chỉ dành cho chủ bot) |
| Bảng cơ sở dữ liệu | 24 (mười một bảng ở trên cộng events_log, xp, giveaways, giveaway_entries, polls, poll_votes, mod_cases, role_menus, temp_voice, scheduled_messages, reminders, suggestions, suggestion_votes) |
| Job chạy nền | 11 (kiểm tra thanh toán, sự kiện định kỳ, ticket không hoạt động, kiểm tra sức khoẻ hằng tuần, báo cáo hằng tuần, hết hạn khoá server, giveaway và bình chọn, nhắc gói sắp hết hạn, nhắc việc cá nhân, tin nhắn hẹn giờ, kênh thống kê cùng việc dọn phòng tạm) |
| Theme có sẵn | 11, cho ra 561 cách trộn tối đa bốn theme |
| Một theme dựng ra | 8 đến 10 role, 4 đến 7 category, 19 đến 26 kênh, 10 điều nội quy |
| Tổ hợp bốn theme lớn nhất | 22 role, 13 category, 54 kênh (đã gộp trùng) |
| Gateway intent | 5, không cái nào là privileged (Guilds, GuildMessages, GuildVoiceStates, GuildModeration, AutoModerationExecution) |
| Dependency lúc chạy của bot | 2 (discord.js, dotenv) |
| Docker image trên Pi (arm64) | 186 MB ở lần deploy đầu, 187 MB khi có đủ mọi tính năng |
| CI | 3 job (test và kiểm tra cú pháp, build web, build image); mọi lần chạy tới giờ đều pass |
| Thời gian chạy bộ test | khoảng 25 s cho 801 test trong khi máy đang chạy việc khác (từng là khoảng 38 s cho 29 test, cho tới khi khoảng nghỉ của builder được đưa vào cấu hình, xem phần Lỗi) |
| JS tải lần đầu của web | 165 kB cho trang chủ, 155 kB cho các trang devlog, trạng thái và theme (theo output build của Next.js) |
Dự án này cho thấy điều gì
| Kỹ năng | Xem ở đâu |
|---|---|
| Thiết kế xoay quanh giới hạn của nền tảng (quyền, intent, rate limit, bot không mời được bot) | src/builder.js, src/utils/guards.js, src/themes/base.js |
| Thao tác idempotent, chạy tiếp được, và hoàn tác an toàn | src/builder.js (ensure*, nukeServer), src/store.js |
| Mô hình dữ liệu và chuyển đổi kiểu lưu trữ không mất dữ liệu | src/db.js, test/license.test.js |
| Logic license và hạn mức với đồng hồ giả | src/license.js, src/utils/gate.js, test/license.test.js |
| Tích hợp API LLM một cách phòng thủ (output ràng buộc theo schema, kiểm tra hợp lệ, hạn mức, thử lại, phân loại lỗi) | src/ai/gemini.js, src/ai/validate.js, src/ai/designer.js, test/ai.test.js |
| Giao diện Discord tương tác (select menu, modal, nút) với phần lõi thuần, dễ test | src/ui/editor.js, src/blueprints.js |
| Vận hành: health check, giới hạn tần suất cảnh báo, sao lưu, tắt máy êm, rollback | src/events/ready.js, src/alerts.js, src/backup.js, src/healthcheck.js, scripts/update.sh |
| Đóng gói và phân phối: Docker, systemd, CI, repository công khai không lộ secret | Dockerfile, docker-compose.yml, scripts/install-pi.sh, .github/workflows/ci.yml |
| Front end: định tuyến đa ngôn ngữ, server component và client component, animation theo cuộn và theo con trỏ có phương án dự phòng cho trợ năng | web/app/[lang]/, web/components/Motion.tsx, web/components/Plotter.tsx, web/components/EditorDemo.tsx |
| Một nguồn dữ liệu duy nhất giữa bot và web, có test bảo đảm | scripts/export-web-data.js, test/webdata.test.js |
| Tích hợp thanh toán không cần endpoint công khai, ghi nhận đúng một lần | src/pay/payos.js, src/pay/orders.js, src/jobs/payments.js, test/payments.test.js |
| Xử lý file không đáng tin và ranh giới quyền hạn (nhập và khôi phục bản sao lưu) | src/backups/restore.js, src/backups/attachment.js, test/backup.test.js |
| Lập lịch có múi giờ mà không dùng thư viện | src/games/schedule.js, src/jobs/events.js, test/events.test.js |
| Test không cần mạng | guild giả trong test/builder.test.js, fetch giả trong test/ai.test.js |
| Thao tác bảo mật hoàn tác được (ghi lại trước, chỉ khôi phục những gì vẫn là của mình) | src/security/lockdown.js, src/security/guard.js, test/security.test.js |
| Xác định ai làm gì qua audit log mà không đọc nội dung | src/security/nukeaction.js, src/modlog/ |
| Hiệu năng ở đường nóng trên máy yếu (cache, cooldown trong bộ nhớ, ghi theo lô) | src/activity/xp.js, test/activity.test.js |
| Job chạy đúng một lần và handler nút idempotent (giveaway, bình chọn, báo cáo, nhắc nhở) | src/activity/giveaways.js, src/jobs/expiry.js, src/digest/ |
| Gửi đúng một lần nhờ update có điều kiện (tin nhắn hẹn giờ, nhắc việc, quyết định góp ý) | src/jobs/scheduled.js, src/jobs/reminders.js, src/suggest/store.js |
| Ở trong giới hạn của nền tảng (tần suất đổi tên, rule từ khoá gốc, không đọc nội dung tin nhắn) | src/jobs/stats.js, src/automod/words.js |
| Phân tích phễu không có dữ liệu cá nhân | src/analytics.js, /admin thongke |
| Route JSON công khai, cho phép cross-origin, có rate limit, và một trang trạng thái xuống cấp êm khi lỗi | src/web/server.js, web/app/[lang]/status |
Dòng thời gian
Commit đầu tiên có ngày 2/10/2026 và công việc kéo dài tới 3/10/2026. Các giai đoạn dưới đây theo đúng thứ tự tôi đã làm.
Giai đoạn 0: ý tưởng và các ràng buộc
Server Discord mới nào cũng bắt đầu với danh sách kênh trống trơn và một kênh tên general. Thiết lập role, kênh, quyền, nội quy và lời chào mất cả tiếng, và đa số người làm được nửa chừng thì bỏ. Tôi muốn có một lệnh cho server một cá tính riêng, và tôi muốn nó hài hước, vì những điều nội quy kiểu "cấm quảng cáo, nếu làm giàu trong ba ngày là thật thì bạn đã không ngồi đây" mới là thứ người ta chịu đọc.
Trước khi viết code, tôi ghi lại những ràng buộc mà nền tảng đặt ra, vì chúng định hình thiết kế:
- Bot không thể thêm bot khác. Tôi muốn server mới có sẵn một bot nhạc và một bot đọc chữ thành giọng nói. Discord chỉ cho phép con người mời bot qua trang uỷ quyền OAuth. Vì vậy builder tạo quầy DJ, phòng karaoke và kênh đọc chữ, rồi đăng một nút liên kết cho các bot tôi cấu hình. Admin chỉ cần bấm một lần. Nó kém thần kỳ hơn tôi mong, nhưng đúng bằng mức nền tảng cho phép.
- Quyền quyết định cái gì làm được. Tạo kênh cần Manage Channels, tạo role cần Manage Roles, và bot không thể tạo role cao hơn role cao nhất của chính nó. Lựa chọn đơn giản và chắc chắn nhất là mời bot với quyền Administrator, nhưng vẫn kiểm tra từng quyền cụ thể để thông báo lỗi gọi đúng tên quyền còn thiếu (
missingBotPermissionstrongsrc/utils/guards.js). - Không dùng privileged intent. Bot chỉ cần intent
Guilds. Nó không bao giờ đọc nội dung tin nhắn, nên sau này không phải giải trình intent Message Content với Discord, và câu chuyện quyền riêng tư cũng đơn giản. - Rate limit. Một server ba mươi kênh tốn khoảng một trăm lời gọi API. Việc tạo phải được giãn nhịp.
Giai đoạn 0b: đọc lại các bot cũ của chính mình
Tôi đang chạy sẵn hai dự án Discord, một bot nhạc (musiDISCORD) và một bot giữ cho kênh luôn sôi động (companionsDISCORD). Trước khi bắt đầu, tôi đọc lại cả hai để dùng lại quy ước thay vì phát minh lại:
- ESM (
"type": "module"), Node 20 trở lên. - Một
index.jstự nạp mọi file trongsrc/commandsvàsrc/events, với command dạngexport default { data: SlashCommandBuilder, execute }và event dạng{ name, once, execute(client, ...args) }. - Một
deploy-commands.jsriêng để đăng ký slash command lên một guild khi cóGUILD_ID(có hiệu lực ngay) và đăng ký toàn cục khi không có. - Một
config.jskiểm tra các biến môi trường bắt buộc lúc khởi động và thoát với thông báo rõ ràng. - Một Dockerfile và file compose với log JSON có giới hạn dung lượng, và với Pi thì có bộ cài một lệnh cùng một systemd timer để cập nhật từ GitHub.
Dùng lại khuôn này giúp dự án mới trông quen thuộc trên Pi, nơi giờ có ba dự án nằm cạnh nhau dưới dạng các thư mục, compose project và container riêng.
Giai đoạn 1: dựng khung
src/index.js tạo một client chỉ với intent Guilds, nạp command và event động, ghi log các unhandled rejection, và xử lý SIGTERM cùng SIGINT bằng cách huỷ client trước khi thoát (để docker stop diễn ra sạch sẽ). src/config.js kiểm tra DISCORD_TOKEN và CLIENT_ID.
Giai đoạn 2: mô hình dữ liệu cho theme và plan
Theme là dữ liệu thuần. Plan là thứ builder thực thi:
{
id, label, welcome,
roles: [{ key, name, color, hoist?, perms?, pick? }],
rules: [string],
categories: [{ name, staff?, channels: [{ name, type, topic?, kind?, readonly? }] }]
}Hai trường gánh phần lớn thiết kế:
kindđánh dấu những kênh mà builder sẽ điền nội dung sau khi tạo (rules,welcome,roles,dj,tts). Theme không bao giờ chứa logic đăng nội quy, chúng chỉ cho biết kênh nào là kênh nào.staffđánh dấu category chỉ moderator và role chủ server mới thấy.
src/themes/base.js chứa những phần server nào cũng có (khu admin, phòng DJ và phòng đọc chữ, khu chỉ dành cho mod, bốn role cơ bản, tám điều nội quy cơ bản). Mỗi file theme thêm role, category riêng và hai điều nội quy bổ sung. Giữ nội dung ở dạng dữ liệu giúp test rất rẻ: một test nạp mọi theme và kiểm tra giới hạn của Discord (tối đa 250 role và 500 kênh, tên tối đa 100 ký tự), tên không trùng, và mỗi plan có đủ năm kênh kind.
Giai đoạn 3: builder
src/builder.js là phần đụng tới Discord nhiều nhất. Những quyết định quan trọng:
Idempotency. ensureRole, ensureCategory và ensureChannel tìm trong cache của guild một thứ cùng tên (với kênh thì cùng cả category cha) và dùng lại nó. ensureChannel trả về { channel, created }. Chỉ những kênh có created: true mới được điền nội dung, nên chạy /build lần thứ hai không tạo thêm gì và không đăng gì hai lần. Phiên bản đầu không làm vậy: lần chạy thứ hai tạo thêm một bản sao của mọi kênh và đăng nội quy hai lần. Chính lỗi đó sinh ra cờ created.
Một chi tiết dễ dính: Discord chuyển tên kênh chat thành chữ thường và thay dấu cách bằng gạch ngang, nhưng giữ nguyên tên kênh voice. Phép tìm kiếm phải chuẩn hoá đúng cách đó, nếu không nó sẽ không bao giờ tìm thấy kênh đã tạo lần trước.
Permission overwrite. Category chỉ dành cho mod chặn ViewChannel với @everyone và cho phép role mod và role chủ server. Kênh chỉ đọc (nội quy, chào mừng, chọn role, thông báo) chặn SendMessages với @everyone. Bot tự đăng vào những kênh đó, và việc này chỉ chạy được vì nó có Administrator hoặc được cho phép rõ ràng. Tôi ghi chú điều này trong README vì đây là loại lỗi hỏng mà không báo gì.
Giãn nhịp và phản hồi. Sau mỗi lần tạo có một khoảng nghỉ 350 ms (BUILD_STEP_DELAY_MS, các test đặt nó bằng không). Tiến độ là một tin nhắn Discord duy nhất được sửa tại chỗ sau mỗi ba bước, với thanh tiến độ mười ô, thay vì mỗi bước một tin nhắn mới.
Khoá theo guild. lock.tryAcquire(guildId) là một Set trong bộ nhớ, nên hai admin không thể chạy hai lần dựng cùng lúc trong cùng một server.
Bản ghi những gì đã tạo. ID của mọi role, category và kênh được tạo đều được đẩy vào bản ghi theo guild trong cơ sở dữ liệu. /nuke xoá theo các ID đó, không bao giờ theo tên. Nó xoá kênh trước, rồi category, rồi role, và cố tình bỏ qua kênh mà lệnh được chạy trong đó, vì xoá kênh đó sẽ cắt mất câu trả lời. Sau khi bỏ qua, nó ghi lại bản ghi để chỉ còn kênh bị bỏ qua. Bản ghi được lưu trong khối finally, nên một lần dựng chết giữa chừng vẫn để lại bản ghi mà /nuke dùng được; một test cho lần tạo kênh thứ sáu thất bại rồi nuke những gì đang có.
Bảo vệ nút chọn role. Bảng chọn role đăng các nút có customId là pickrole:<roleId>. Ai cũng có thể làm giả customId trong một interaction tự chế, nên handler chỉ cấp role nếu ID của nó nằm trong danh sách pickRoles đã ghi lại của guild. Nút giả không thể phát role admin.
Giai đoạn 4: tôi đã test thế nào khi không có Discord
Tôi không muốn bộ test cần token hay mạng. Ba kỹ thuật gánh phần lớn việc:
- Guild giả (
test/builder.test.js). Một object nhỏ vớiroles,channels,create,fetch,sendvàsetSystemChannel, dựa trên cácCollection. Nó chuyển tên kênh chat thành chữ thường giống Discord, nên phép tìm kiếm idempotent được test với tên giống thật. Test dựng một theme, dựng lại lần nữa, rồi đếm role, kênh và số tin nhắn đã đăng. Test thứ hai dựng rồi nuke. - Thời gian bơm vào được.
getPlan(guildId, now),redeem(code, guildId, now),createLicense(plan, days, now), các bộ đếm lượt dùng và tên file sao lưu đều nhận tham sốnow, mặc định làDate.now(). Một test license có thể nói "29 ngày sau gói vẫn là Pro, 31 ngày sau thành free" mà không phải ngủ. fetchgiả (test/ai.test.js). Client Gemini gọifetchtoàn cục ngay lúc gọi, nên test thayglobalThis.fetchbằng một hàm trả về danh sách model, câu trả lời, lỗi 429 và 403 soạn sẵn, đồng thời ghi lại header của request.
Mọi thứ chạy dưới node --test và xong trong khoảng 2,5 giây. Bộ test dài khoảng 510 dòng cho khoảng 1.850 dòng code bot. Giới hạn thật thà: các handler đụng tới Discord (các hàm execute của command và ui/editor.js) chỉ được kiểm tra qua những module thuần mà chúng gọi, chứ không chạy end to end với gateway thật trong CI. Tôi đã chạy tay các luồng đó trên một server thật trước khi chuyển bot lên Pi.
Giai đoạn 5: trang web đầu tiên
Tôi làm trang giới thiệu từ sớm, vì dự án cần một bộ mặt và tôi muốn luyện làm giao diện điều khiển bằng cuộn trang với phương án dự phòng trợ năng thực sự.
- Next.js 15 (App Router), React 19, TypeScript. Ngôn ngữ là một segment động (
app/[lang]), vớigenerateStaticParamschovivàenvà một rewrite từ/sang/vi, nên tiếng Việt nằm ở gốc và tiếng Anh ở/en. Nội dung trang là các từ điển có kiểu (content/vi.ts,content/en.ts) cùng hiện thực một kiểuDict, nên thiếu một khoá ở một ngôn ngữ là lỗi biên dịch. - Theme trước khi vẽ. Một script inline nhỏ trong
<head>đọclocalStoragehoặcprefers-color-schemevà đặtdata-themetrước lần vẽ đầu tiên để tránh nháy. Màu là CSS custom property, được định nghĩa lại cho theme sáng. - Một cảnh đặc trưng. Một section được ghim, nơi một server tự lắp ráp khi bạn cuộn: role xuất hiện, rồi category và kênh, rồi thẻ nội quy và con dấu "đã duyệt". Đó là một timeline GSAP với
scrub, được ScrollTrigger ghim trong 2.600 px, chạy bằng Lenis nối vào ticker của GSAP. - Giảm chuyển động là một nhánh code khác, không phải animation rút gọn. Phần thiết lập chạy trong
gsap.matchMedia()dưới(prefers-reduced-motion: no-preference). Khi bật giảm chuyển động, không có gì bị ghim, Lenis không bao giờ được tạo, và chữ của mọi bước đều hiển thị. Class.pinnedlàm thay đổi bố cục chỉ được thêm bởi nhánh có chuyển động, nên bố cục khi không có JavaScript và khi giảm chuyển động chính là luồng tài liệu bình thường. - Tách chữ mà vẫn trợ năng. Tiêu đề được tách thành từ hoặc chữ cái cho animation xuất hiện, nhưng mỗi tiêu đề giữ một
aria-labelvới nguyên văn và ẩn các mảnh khỏi công nghệ hỗ trợ. Mỗi từ nằm trong một maskoverflow: hidden; chữ hoa tiếng Việt có dấu chồng bị cắt mất cho tới khi tôi thêm padding dọc và line height rộng hơn cho mask. - Điện thoại. Điện thoại không đủ chỗ để ghim tiêu đề phía trên cây server, nên trên màn hình hẹp, tiêu đề của cảnh được ghim bị ẩn về mặt hiển thị nhưng vẫn giữ lại cho trình đọc màn hình.
Tôi kiểm tra bố cục bằng một script nhỏ điều khiển Chrome headless qua giao thức DevTools: chụp màn hình ở kích thước desktop và điện thoại với cả hai theme, scrollWidth - innerWidth để tìm tràn ngang, lỗi console, anchor trỏ tới id không tồn tại, id trùng, và nhánh giảm chuyển động. Script này là công cụ chạy ở máy, không nằm trong repository, và đó là một trong những thiếu sót được liệt kê ở phần Giới hạn.
Giai đoạn 6: trộn theme
Trộn "học tập", "gaming" và "chill" lúc đầu cho ra ba kênh tên meme và ba kênh tên fighting. composePlan trong src/themes/index.js sửa chuyện đó:
- Bắt đầu với category admin dùng chung.
- Với mỗi theme được chọn, thêm các category của nó.
- Bỏ qua category có tên đã được thêm, và bỏ mọi kênh có tên đã dùng ở bất kỳ đâu. Bỏ những category còn trống.
- Kết thúc bằng category DJ và category chỉ dành cho mod.
- Nối role và nội quy bổ sung của mọi theme.
buildPlan(ids) nhận một mảng hoặc một chuỗi nối bằng + (gaming+hoc-tap), điều này quan trọng vì chuỗi đó đi qua customId của nút và một dòng trong cơ sở dữ liệu. /build nhận theme cùng theme2 tới theme4 không bắt buộc. Một test kiểm tra tổ hợp ba theme xem có tên trùng và có vượt giới hạn Discord không, và các test sau kiểm tra mọi tổ hợp tối đa bốn theme (561 tổ hợp) theo giới hạn đó và so với bản port phép gộp của trang web.
Giai đoạn 7: từ file JSON sang SQLite
File JSON theo từng server ổn khi chỉ có một server test. License, bộ đếm lượt dùng và hạn mức AI hằng tháng cần cập nhật nguyên tử và truy vấn, nên tôi chuyển sang SQLite qua node:sqlite (DatabaseSync), có sẵn trong Node 22.13 trở lên. Việc này bỏ được một dependency native (không phải build better-sqlite3 trên arm64) và giữ danh sách dependency lúc chạy ở mức hai package.
Schema (src/db.js):
| Bảng | Cột |
|---|---|
guilds | id (khoá chính), record (chuỗi JSON), updated_at |
licenses | code (khoá chính), plan, days, created_at, guild_id, redeemed_at, expires_at, index trên guild_id |
usage | guild_id, month, feature, count, khoá chính trên ba cột đầu |
Những lựa chọn đáng ghi lại:
- Chế độ WAL (
PRAGMA journal_mode = WAL) để bản sao lưu hằng ngày đọc được trong khi bot đang ghi. - Bản ghi là một khối JSON trong
guilds. Nó được đọc và ghi nguyên khối và không bao giờ truy vấn theo trường, nên một cột dạng tài liệu đơn giản hơn ba bảng nữa. usagedùng upsert (ON CONFLICT ... DO UPDATE SET count = count + excluded.count), với khoá tháng"all"cho bộ đếm trọn đời.- Chuyển đổi ở lần mở đầu tiên.
migrateJsonnhập từng<guildId>.jsonbằngINSERT OR IGNORErồi đổi tên file thành.migrated, nên không có gì bị xoá và chạy hai lần cũng vô hại. Một test ghi một file kiểu cũ, mở cơ sở dữ liệu và kiểm tra dữ liệu đã vào. - Một
store.jsmỏng (loadRecord,saveRecord,clearRecord) giúpbuilder.jskhông phải đổi gì trong lúc chuyển. node:sqlitevẫn in raExperimentalWarning. Tôi truyền--disable-warning=ExperimentalWarningtrongnpm start,npm testvàCMDcủa Docker, và đặtengines.nodelà>=22.13. Một lệnh chạy lẻ không truyền cờ này (ví dụdocker compose run bot node src/deploy-commands.js) vẫn in ra cảnh báo, tôi đã thấy nó trên Pi và để yên.
Giai đoạn 8: gói và license
Mô hình kinh doanh là một bot dùng chung, tính tiền theo từng server, với mã kích hoạt tạo bằng tay. Thanh toán thủ công là có chủ ý: một cổng thanh toán cần webhook và một endpoint công khai chạy liên tục, tức là rất nhiều bề mặt cho một sản phẩm chưa có khách nào.
- Mã.
THAU-XXXX-XXXX-XXXX, tạo từ 12 byte ngẫu nhiên trên bảng chữ cái 32 ký tự không có0,O,1hayI, nên gõ mã từ ảnh chụp màn hình khó mà sai. Vì 32 chia hết 256,byte % 32không bị lệch modulo, và 12 ký tự mang 60 bit entropy. Đầu vào được chuẩn hoá (cắt khoảng trắng hai đầu, bỏ khoảng trắng, viết hoa) trước khi tra cứu. - Đổi mã. Mỗi mã chỉ dùng một lần. Đổi mã thứ hai cùng gói sẽ cộng số ngày của nó vào sau ngày hết hạn hiện tại thay vì chồng lên nhau. Câu
UPDATEcóAND guild_id IS NULLlàm chốt chặn; tôi không kiểm tra số dòng bị thay đổi, vì drivernode:sqliteđồng bộ trong một tiến trình duy nhất không thể đan xen hai lần đổi mã. Nếu sau này chạy nhiều tiến trình thì phải thêm bước kiểm tra đó. - Gói được suy ra, không lưu.
getPlantrả về license có hạng cao nhất màexpires_atcòn ở tương lai, nếu không thì làfree. Hết hạn không cần cron job vì không có gì phải lật trạng thái. Giới hạn nằm trong một bảng duy nhất ởsrc/license.js: có được trộn theme không, tổng số lần dựng, số lần thiết kế bằng AI mỗi tháng. - Cổng chặn (
src/utils/gate.js) biến giới hạn thành lời từ chối với thông báo dễ hiểu.gateBuildđược kiểm tra khi mở bản thiết kế và lần nữa khi bấm nút dựng, vì gói có thể thay đổi ở giữa. Một lần dựng thành công tăng bộ đếm trọn đời. - Một CLI cho tôi, chủ bot:
npm run license -- new pro 30d,grant,revoke,list./admincung cấp các thao tác tương tự ngay trong Discord cho những owner ID trongOWNER_IDS.
Các giới hạn của gói miễn phí và bảng gói đều được test với đồng hồ giả: kích hoạt, hết hạn, dùng hai lần, gia hạn, thứ tự ưu tiên giữa các gói, thu hồi, đầu vào không hợp lệ, bộ đếm theo tháng và trọn đời, và các cổng chặn.
Giai đoạn 9: vận hành
Tôi muốn có thể để nó tự chạy.
- Cảnh báo không thể gây lụt.
alert(text)đăng lên một Discord webhook, giới hạn 1.900 ký tự, và cùng một tin (nhận diện theo 120 ký tự đầu) chỉ được gửi tối đa một lần mỗi năm phút. Một vòng crash lặp lại chỉ tạo ra một tin, không phải hàng trăm. - Health check có ý nghĩa. Bot ghi
data/heartbeatmỗi 30 giây.src/healthcheck.jschỉ thoát với mã 0 nếu file mới hơn 90 giây.HEALTHCHECKtrong Dockerfile chạy nó mỗi 30 giây với khoảng khởi động 60 giây. "Đang chạy" và "khoẻ" là hai chuyện khác nhau, và updater chờ chuyện thứ hai. - Bản sao cơ sở dữ liệu hằng ngày.
backupDbdùngVACUUM INTOđể ghi một bản sao nhất quán ngay khi cơ sở dữ liệu đang được dùng, mỗi ngày UTC một file, giữ bảy bản gần nhất. Nó kiểm tra mỗi sáu giờ để một lần khởi động lại không làm sót ngày nào. Một test ghi bản sao của mười ngày và kiểm tra còn lại bảy. - Tắt máy êm khi nhận
SIGTERMvàSIGINT. - Đường lỗi. Lỗi interaction được ghi log, gửi cảnh báo và trả lời bằng một tin nhắn ephemeral thân thiện thay vì một unhandled promise rejection.
Giai đoạn 10: nhà thiết kế AI
/thietke nhận một đoạn mô tả và nhờ Google Gemini thiết kế phần theo chủ đề của server. Phần thú vị không phải lời gọi, mà là mọi thứ xung quanh nó.
Client (src/ai/gemini.js). Nó dùng REST API qua fetch mà không cần SDK, nên không phải cài gì và test có thể thay fetch. API key nằm trong header x-goog-api-key, không bao giờ nằm trong URL, nên nó không thể lọt vào access log hay thông báo lỗi. Mỗi lời gọi có timeout 30 giây qua AbortSignal.timeout.
Tự chọn model. Tên và giới hạn của các model miễn phí thay đổi thường xuyên, nên tôi không hard-code model. resolveModel liệt kê những model mà key dùng được và giữ lại những model hỗ trợ generateContent, có tên chứa flash, và không khớp /lite|preview|exp|thinking|tts|image|live|audio|latest|\d{3,}/. Nó sắp xếp theo phiên bản đọc từ tên rồi chọn bản mới nhất, cache kết quả, và xoá cache rồi chọn lại một lần nếu lời gọi trả về 404. GEMINI_MODEL ghi đè toàn bộ cơ chế này. Lời gọi thật đầu tiên đã chọn một tên model tôi chưa từng nghe, và đó chính là lý lẽ cho việc không hard-code.
Output có cấu trúc. Request đặt responseMimeType: "application/json" và một responseSchema (label, welcome, roles, rules, categories kèm channels, mỗi channel có enum type là text hoặc voice). Model không thể trả về văn xuôi tuỳ ý.
Bộ giới hạn toàn cục. Một key miễn phí phục vụ mọi khách hàng, nên takeSlot giới hạn toàn bộ bot ở mười request mỗi phút bằng cửa sổ trượt, trước khi Google kịp từ chối. Bên trên đó là hạn mức hằng tháng theo từng server (20 cho Pro, 100 cho Plus) trong bảng usage.
Phân loại lỗi. AiError.kind là một trong off, key, quota, busy, unavailable, model hoặc bad. Mỗi loại ứng với một thông báo tiếng Việt khác nhau trong /thietke, và tất cả đều nói rằng lần thử này không bị tính. HTTP 400, 401 và 403 thành key khi nội dung hoặc mã trạng thái nói vậy và thành bad trong các trường hợp khác, 429 thành quota, mọi lỗi 5xx thành unavailable, và 404 thành model.
Chính sách thử lại. Thử lại một lần cho câu trả lời không dùng được (bad), chọn lại model một lần khi model không còn, và với unavailable thì thử lại tối đa hai lần với thời gian chờ 1,5 s và 4 s. Thời gian chờ được export dưới tên retry.delays để test đặt bằng không. designServer thêm một lần thử nữa khi câu trả lời sau khi làm sạch vẫn không dùng được. Bộ đếm lượt dùng chỉ tăng sau một lần thiết kế thành công.
Không bao giờ tin câu trả lời. sanitizeDesign trong src/ai/validate.js biến bất cứ thứ gì trả về thành một theme mà builder chạy được:
- bỏ
@everyone,@herevà cú pháp mention của Discord, gộp khoảng trắng, giới hạn độ dài; - bỏ mọi thứ khớp với một danh sách từ cấm ngắn, áp dụng bằng lookaround có hiểu Unicode;
- giới hạn số lượng (6 category, 8 kênh mỗi category, 8 role, 6 điều nội quy);
- chuẩn hoá tên kênh chat giống cách Discord làm và giữ nguyên tên kênh voice;
- đọc màu role từ
#RRGGBBvới bảng màu dự phòng và không bao giờ nhận màu đen; - bắt buộc lời chào phải chứa
{user}; - ném
DesignErrorkhi không còn category nào dùng được.
Kết quả được gộp với các phần dùng chung qua composePlan, nên server do AI thiết kế lúc nào cũng có nội quy, lời chào, bảng chọn role, quầy DJ và kênh đọc chữ, và được hiển thị như một bản thiết kế chỉnh sửa được. Không có gì được tạo ra từ output của model nếu chưa có người bấm dựng.
Lập trường với prompt injection. Đoạn mô tả bị giới hạn ở 400 ký tự (cũng được áp ở tuỳ chọn của slash command), được bọc trong ba dấu nháy trong tin nhắn người dùng và không bao giờ được nối vào system prompt. System prompt dặn coi nó là dữ liệu và bỏ qua mọi chỉ dẫn bên trong. Đây là biện pháp giảm thiểu, không phải bảo đảm, nên output còn bị ràng buộc theo schema, được làm sạch và cho xem trước.
Test. Mười ba test trong test/ai.test.js, phần lớn dùng fetch giả: thiết kế tốt và xấu, từ cấm và mention, gộp trong giới hạn Discord, chọn model, key nằm trong header chứ không trong URL, lỗi hạn mức và lỗi key, giới hạn toàn bot (mười lời gọi qua, lời gọi thứ mười một là busy), thử lại một lần rồi bỏ, đoạn mô tả nằm ngoài system prompt, chỉnh sửa và hết hạn bản thiết kế, lỗi 5xx thử lại rồi thành unavailable, và bộ lọc từ với cả từ cấm lẫn những từ vô hại trông giống.
Một lời gọi thật. Với một key miễn phí thật, một lần chạy cho "tám đứa bạn đại học chơi Valorant tối thứ bảy và nói chuyện rất nhiều" cho ra sáu category trong plan đã gộp (ba category theo chủ đề, cộng khu admin, DJ và khu chỉ dành cho mod dùng chung), 24 kênh và 9 role, ở mức hài troll. Mỗi lần chạy mỗi khác, nên ví dụ trên trang web được ghi chú là một câu trả lời thật.
Giai đoạn 11: trình chỉnh bản thiết kế
Phiên bản đầu của phần xem trước là một embed với hai nút. Người ta muốn bỏ một category hoặc đổi tên một category khác trước khi bất cứ thứ gì tồn tại, nên phần xem trước trở thành trình chỉnh sửa.
- Lõi thuần.
src/blueprints.jsgiữ mộtMapcác bản thiết kế trong bộ nhớ (id ngẫu nhiên 8 ký tự hex, guild, người dùng, một bản deep copy của plan, hạn 15 phút được dọn khi truy cập) và ba hàm chỉnh sửa thuần:removeCategory,renameCategory,addChannel. Chúng được unit test mà không cần Discord. - Quy tắc mà các thao tác chỉnh sửa áp dụng. Category đầu tiên (khu admin) không thể bị bỏ, vì kênh nội quy, chào mừng và chọn role nằm ở đó. Đổi tên không được trùng với category khác. Kênh mới bị từ chối khi tên đã có ở bất kỳ đâu trong plan, và được đưa vào category theo chủ đề đầu tiên hoặc vào một category mới "Khu Mới" đặt trước khu DJ.
- Định tuyến theo tiền tố
customId. Mọi thứ trình chỉnh sửa gửi đi đều dùng id kiểubp:remove:<id>,bp:renamem:<id>:<index>vàbp:go:<id>.interactionCreatetách theo:và gửi phạm vibpcho trình chỉnh sửa,pickrolecho handler role, và mọi thứ khác cho command có tên trùng (nên/nukevà/xoadulieutự quản nút xác nhận của mình qua mộthandleButtonkhông bắt buộc). Select menu và modal submit đi qua cùng bộ định tuyến với nút. - Select menu, modal, nút. Hai select menu (bỏ, đổi tên), một modal cho tên mới, một modal cho kênh mới, và ba nút (thêm, dựng, huỷ). Menu "bỏ" được lược đi khi không có gì để bỏ, vì Discord từ chối select rỗng.
- Update hay reply. Một modal gửi từ component có thể update tin nhắn gốc, nhưng một modal mở từ slash command thì không.
respond()kiểm trainteraction.isFromMessage()rồi gọiupdatehoặcreplytương ứng. Làm sai chỗ này sẽ ra "interaction failed" mà không có lỗi nào hữu ích. - Phân quyền ở mỗi lần bấm. Handler kiểm tra bản thiết kế vẫn còn cho guild này, người bấm chính là người đã mở nó, và họ là admin. Admin của guild còn có thể đổi ai được chạy lệnh trong phần cài đặt tích hợp của server, nên các command đặt
setDefaultMemberPermissionsvà vẫn kiểm traisAdminlúc chạy. - Giới hạn. Bản thiết kế sống trong bộ nhớ và mất khi tiến trình khởi động lại. Một bản thiết kế đang mở qua lần khởi động lại sẽ trả lời "đã hết hạn, chạy lại lệnh". Đó là cái giá chấp nhận được cho một object nháp 15 phút, và là lựa chọn có chủ ý thay vì đưa bản nháp sửa dở vào cơ sở dữ liệu.
Giai đoạn 12: đóng gói và phân phối
- Docker.
node:22-alpine,npm install --omit=dev, copysrc, mộtHEALTHCHECK, vàCMDcó cờ tắt cảnh báo. Thư mục dữ liệu là một volume bind mount. Image nặng 186 MB trên Pi. - Compose. Container có tên, log JSON có giới hạn (
max-size: 5m,max-file: 2) vàrestart: unless-stopped. scripts/install-pi.sh. Phỏng theo bộ cài trong bot kia của tôi, một script POSIXsh: cài Docker và git nếu chưa có, clone dự án, hỏi token và application ID với token được đọc ở chế độ ẩn, không bao giờ ghi đè.envđã có, build và khởi động container, tuỳ chọn cài timer cập nhật, và hỗ trợ--yescùng--dry-run. Chạy hai lần vẫn an toàn.scripts/update.sh. Lấy khoá bằngflockđể hai lần cập nhật không chồng nhau, từ chối chạy khi có thay đổi chưa commit, fetch và chỉ chấp nhận fast-forward, build lại, rồi hỏidocker inspectliên tục xem córunning, không lần restart nào vàhealthytrong tối đa 150 giây. Nếu bản mới không khoẻ lên, nó chạygit reset --hardvề commit cũ, build lại và kiểm tra lần nữa, và trong cả hai trường hợp đều thoát với mã khác không để lỗi hiện ra trong journal.- systemd. Một service oneshot và một timer chạy hằng ngày với độ trễ ngẫu nhiên và
Persistent=true. - CI (
.github/workflows/ci.yml). Ba job:npm ci, test và một lượt kiểm tra cú phápnode --checktrên mọi file nguồn; một job web vớinpm civànpm run build; và một job image build Docker image. Mọi lần chạy tới giờ đều pass. - Giữ sạch repository công khai. Trước lần push đầu, tôi liệt kê các file đã stage và quét tìm chuỗi trông giống token.
.gitignoreloại.envvà cả thư mụcdata/..gitattributesbắt buộc LF cho*.sh,*.servicevà*.timer, vì Git trên Windows cảnh báo chuyển đổi CRLF và một shell script có kết thúc dòng CRLF sẽ hỏng trên Linux theo những cách rất khó hiểu. Tôi cũng chạysh -ntrên cả hai shell script.
Giai đoạn 13: lần deploy đầu tiên
Vercel: vercel link và vercel deploy --prod từ thư mục web/, nên chỉ trang web được tải lên. Tên miền là builddiscord.vercel.app miễn phí, và metadata canonical của trang cùng homepage của repository đều trỏ về đó.
Raspberry Pi 5 chạy Kali Linux (arm64), bên cạnh bot nhạc, server âm thanh của nó, một trình theo dõi trạng thái và các bot companion:
git clonerepository công khai.- File
.envđược copy bằngscpvà đặt quyền 600. Repository là công khai, nên token và API key không bao giờ đi qua Git, và các bước kiểm tra của tôi chỉ liệt kê những khoá nào đã được đặt, không bao giờ in giá trị. docker compose up -d --build. Lần build đầu mất vài phút trên Pi. Container báo healthy chỉ vài giây sau khi khởi động và log cho thấy bot đã online.docker compose run --rm bot node src/deploy-commands.jsđăng ký 8 slash command toàn cục.
Tên mDNS .local của Pi không phân giải được từ Windows, nên tôi dùng địa chỉ LAN của nó. Container chạy bằng root, nên data/ trên máy chủ thuộc sở hữu của root; nó bị Git bỏ qua, nên bước kiểm tra "không có thay đổi chưa commit" của updater không bị ảnh hưởng. Cài systemd timer cần sudo, và nó hỏi mật khẩu, nên bước cuối đó để chủ bot tự chạy tay (các lệnh có trong README).
Giai đoạn 14: giá và pháp lý
- Gói. Free (một theme, một lần dựng, không AI), Pro (trộn theme, dựng không giới hạn, 20 lần thiết kế mỗi tháng) và Plus (100 lần thiết kế mỗi tháng). Giá tính bằng đô la Mỹ cho mỗi server mỗi 30 ngày, được chọn nằm trong khoảng giá mà các bot Discord trả phí ở Mỹ đang thu, khách Việt Nam trả số tiền VND tương đương theo tỷ giá trong ngày. Mã được tạo bằng tay bằng CLI license với số ngày đã bán.
- Trang. Trang quyền riêng tư và điều khoản viết bằng ngôn ngữ dễ hiểu ở cả hai thứ tiếng. Trang quyền riêng tư liệt kê chính xác những gì được lưu (guild ID, theme, ID của những thứ bot đã tạo, license, bộ đếm lượt dùng) và nói rằng nội dung tin nhắn không bao giờ bị đọc. Nó cũng nói rằng đoạn mô tả của
/thietkeđược gửi tới Google Gemini và gói miễn phí cho phép Google dùng nó để cải thiện sản phẩm. - Quy định hoàn tiền (7 ngày cho mã chưa dùng, không hoàn cho mã đã kích hoạt trừ khi có sự cố kéo dài) là một quyết định kinh doanh tôi đưa ra và viết vào điều khoản.
- Thật thà về sản phẩm. Một phiên bản đầu của lệnh
/goighi "sao lưu" cho các gói trả phí, trong khi tính năng đó chưa tồn tại. Tôi đã bỏ nó trước khi công bố. Bảng gói chỉ nên liệt kê những gì code thực sự làm.
Giai đoạn 15: trang web, lượt thứ hai
Trang đầu tiên là một tờ quảng cáo đẹp. Với một dự án tôi muốn người khác đánh giá, nó còn nên cho họ dùng thử sản phẩm, nên tôi làm lại nó xoay quanh những thứ có thể chạm vào. Định hướng vẫn là một tờ bản vẽ kỹ thuật, nhưng dưới ánh sáng ban ngày: giấy trắng lạnh, nét mực xanh, và cam quýt, xanh bạc hà, vàng nắng là những màu duy nhất, với một biến thể tối sau nút chuyển. Font là Big Shoulders cho chữ trang trí (trông như chữ kẻ bản vẽ) và Be Vietnam Pro cho văn bản.
Một nguồn dữ liệu duy nhất. Trang web không bao giờ được khẳng định điều gì mà bot không làm, nên các con số trên đó lấy từ bot. scripts/export-web-data.js ghi dữ liệu theme của bot (tên role, category, kênh và số điều nội quy, cộng các phần dùng chung) vào web/content/themes.data.json, và trang web gộp theme bằng web/lib/compose.mjs, một bản port phép gộp của bot (xem Giai đoạn 17 về cách giữ cho bản port trung thực). scripts/sample-designs.js thực hiện ba lời gọi Gemini thật, mỗi mức hài một lời gọi, và lưu câu trả lời kèm ngày và tên model. test/webdata.test.js thất bại nếu dữ liệu đã commit khác với những gì bot dựng ra lúc này, và một kiểm tra thứ hai thất bại nếu bản sao devlog này trên trang web đã cũ. Trang web deploy chỉ từ thư mục web/, nên nó không thể import từ src/ của bot; các file được sinh ra, cộng với test, là cách hai bên giữ đồng bộ.
Bạn làm được gì trên trang.
- Bàn vẽ ở phần hero (
Plotter.tsx): bật tắt tối đa bốn trong mười một theme theo bất kỳ tổ hợp nào và các bộ đếm sẽ chạy tới số thật của tổ hợp đó (ví dụ, riêng booking cho 7 category, 26 kênh và 10 role) trong khi cây được vẽ lại. "Thử dựng" phát lại một lượt chạy tiến độ và đóng dấu cái rầm. - Cảnh được ghim (
BuildScene.tsx): một server tự lắp ráp khi bạn cuộn. Nó chỉ ghim trên màn hình rộng khi không bật giảm chuyển động; ở những nơi khác nó là một tờ tĩnh hiện đủ mọi bước. - Phần AI (
AiDemo.tsx): ba câu trả lời thật, mỗi mức hài một câu, được gõ ra khi bạn chuyển. - Một bản thu nhỏ chạy được của trình chỉnh bản thiết kế (
EditorDemo.tsx): bỏ một category, đổi tên một category tại chỗ, thêm một kênh, bấm dựng. Nó theo đúng các quy tắc của bot trongsrc/blueprints.js: khu admin không thể bị bỏ, đổi tên không được trùng, tên kênh trùng bị từ chối, tên kênh chat được chuẩn hoá giống cách Discord làm. - Thẻ gói nghiêng về phía con chuột, tên lệnh tự copy, một chữ ở footer có các ký tự nhấc lên khi con trỏ tới gần, và một tâm ngắm kiểu bản vẽ với toạ độ thời gian thực cộng một thước cuộn ở mép trái trên màn hình rộng.
Chuyển động được tổ chức thế nào.
- Trang chủ là một server component. Phần tương tác nằm trong các client component nhỏ ở lá, và
Motion.tsxlà nơi duy nhất khởi động Lenis (bộ cuộn mượt duy nhất) và phần việc GSAP toàn trang: hiện tiêu đề, vẽ đường kích thước, hiện thẻ, tâm ngắm và nút hút nam châm. - Mọi thứ chạy trong
gsap.matchMedia(). Dướiprefers-reduced-motion: reduce, Lenis không bao giờ được tạo, không có gì bị ghim, không có tâm ngắm, và mọi phần tử hiển thị ở trạng thái cuối. Các tính năng theo con trỏ còn bị chặn thêm bởi(pointer: fine) and (hover: hover). - Không nháy và không có nội dung bị ẩn khi thiếu script. CSS chỉ ẩn từ đã tách, thẻ và đường kích thước dưới một class
jsmà một script inline đặt trước lần vẽ đầu tiên, và chính các quy tắc đó mang theo một animation CSS 3 giây để hiện tất cả nếu script không bao giờ chạy. Khi script có chạy, nó xoá animation đó và tiếp quản bằng trạng thái bắt đầu riêng của GSAP. CSS dùng các thuộc tínhtranslatevàscaleriêng lẻ để không bao giờ đánh nhau vớitransformcủa GSAP. - Lưới trôi là một lớp cố định duy nhất được dịch chuyển bằng transform có chu kỳ 120 px, đúng bằng chu kỳ của lưới, nên vòng lặp không bao giờ lộ ra và trang không bao giờ phải vẽ lại một nền lớn.
Trang devlog mà có thể bạn đang đọc. Nó hiển thị chính file này. Một trình đọc Markdown nhỏ (web/lib/markdown.tsx, khoảng 170 dòng) biến tiêu đề, danh sách, bảng, code fence và định dạng inline thành phần tử, dựng mục lục với scroll-spy, thêm nút copy cho code, và biến bảng "Con số" thành các thẻ số liệu, nên số liệu trên trang không thể lệch khỏi văn bản.
Kiểm tra. Một script Chrome headless (điều khiển qua giao thức DevTools, để ngoài repository) đi qua trang: bật tắt theme, đổi mức AI, chỉnh bản thiết kế qua các ô nhập thật, copy một lệnh, rồi kiểm tra lỗi console, tràn ngang ở bề rộng desktop và điện thoại, anchor hỏng, id trùng và nhánh giảm chuyển động. Nó bắt được hai lỗi thật: một con dấu phóng to bên trong cảnh được ghim làm trang cuộn ngang, và một animation toàn cục cố nhắm vào những phần tử không tồn tại trên trang devlog.
Giai đoạn 16: lượt dọn dẹp
Khi các tính năng đã xong, tôi làm một lượt dọn dẹp trên repository, máy tính của tôi và Raspberry Pi, vì những gì bạn để lại cũng là một phần của công việc.
- Repository. Tôi kiểm tra cây thư mục và lịch sử xem có secret, file cơ sở dữ liệu, output build và file cấu hình công cụ không. Không có cái nào được track, và commit message là những mô tả thay đổi bình thường.
- Máy tính của tôi. Tôi xoá output build của Next.js (94 MB), file token Vercel tạm mà
vercel linktạo ra, thư mục dữ liệu test ở máy, và 73 thư mục tạm còn sót lại từ các lần chạy test và phiên trình duyệt headless. Tôi giữnode_modules(cài lại là có) và.env. - Một bản ghi tôi không muốn mất. Dữ liệu test ở máy chứa bản ghi những gì phiên bản đầu đã dựng trên server test của tôi. Tôi copy nó sang Pi, nơi bộ nhập từ Giai đoạn 7 đã nhận nó ở lần khởi động tiếp theo và đổi tên file, nên
/nuketrên Pi vẫn biết phải xoá gì ở đó. - Pi. Tôi build lại container từ commit mới và chờ nó báo healthy, rồi xoá 24 image lơ lửng và build cache bằng
docker image prunevàdocker builder prune(số image giảm từ 29 xuống 5, và giải phóng 161 MB cache). Bot nhạc, server âm thanh của nó, trình theo dõi và các bot companion vẫn chạy suốt thời gian đó.
Giai đoạn 17: thêm bảy theme, và một server để thuê bạn chơi cùng
Bốn theme đủ để chứng minh ý tưởng nhưng chưa đủ để hữu ích. Tôi thêm bảy theme: thuê bạn chơi cùng (booking), fan anime, lập trình viên, nhà sáng tạo nội dung, phim ảnh và âm nhạc, đội văn phòng, và người yêu thú cưng. Mỗi theme là dữ liệu thuần trong src/themes/ (role, category, kênh, hai điều nội quy bổ sung, một lời chào), nên thêm một theme chỉ là một file cộng một dòng trong index.js.
Theme booking. Có những cộng đồng được xây quanh việc thuê thời gian của một người: bạn thuê ai đó chơi game cùng, hoặc để nói chuyện. Tôi đã tìm xem những server như vậy được bố trí ra sao và thấy rất ít thông tin hữu ích từ nguồn công khai, nên tôi thiết kế nó từ cấu trúc chung của các cộng đồng này và từ những gì khiến chúng an toàn hay không an toàn. Kết quả có một khu lễ tân (hướng dẫn đặt lịch, bảng giá, một kênh chống lừa đảo, một nơi đăng bằng chứng giao dịch), một danh sách người chơi (hồ sơ, khung giờ trống hôm nay, khách đang tìm người chơi), các phòng voice để chơi cùng, chờ và nói chuyện, và một khu uy tín (phản hồi sau mỗi buổi, bảng vinh danh, một kênh khiếu nại). Các lựa chọn về an toàn nằm trong dữ liệu, không chỉ trong câu chữ:
- Role uy tín do con người trao, không bao giờ tự chọn. "Người chơi đã xác minh", "người chơi được yêu thích" và "khách quen" không thể tự gán. Bảng chọn role chỉ đưa ra những role có
pick: true, và một test kiểm tra rằng ba role đó đã tắt cờ này. - Hai điều nội quy viết riêng cho theme này: mọi giao dịch đi qua kênh chính thức với xác nhận của staff, và không ai được hỏi hay đưa thông tin cá nhân (địa chỉ, số điện thoại, giấy tờ); và người chơi lẫn khách phải từ 18 tuổi trở lên, đến đúng giờ và huỷ trước.
- Không hứa hẹn quyền riêng tư mà nó không giữ được. Ban đầu tôi đặt tên một phòng voice là "phòng nói chuyện riêng", rồi đổi tên nó, vì bot không đặt quyền nào khiến phòng đó thực sự riêng tư.
Những quy tắc đúng với mọi theme, dưới dạng test. Với mười một theme, việc trộn an toàn trở nên quan trọng hơn, nên bộ test giờ kiểm tra rằng không có key role hay tên role nào được hai theme cùng dùng (phép gộp sẽ âm thầm dùng chung một role), không role tự gán nào mang quyền, và mọi tổ hợp tối đa bốn theme (11 + 55 + 165 + 330 = 561) đều nằm trong giới hạn 250 role và 500 kênh của Discord. Tổ hợp bốn theme lớn nhất dựng ra 22 role, 13 category và 54 kênh.
Giữ trang web trung thực với mười một theme. Với bốn theme, bộ xuất dữ liệu có thể tính trước mọi tổ hợp. Với mười một, 561 plan tính trước là quá nhiều cho JavaScript của một trang. Vì vậy giờ trang web nhận dữ liệu theme thô (cỡ bằng chính các theme) và gộp ngay trong trình duyệt bằng web/lib/compose.mjs, một bản port 40 dòng của composePlan. Nó là JavaScript thuần với một file .d.mts cho kiểu, nên bộ test của bot import được nó. test/webdata.test.js so bản port với buildPlan của chính bot cho cả 561 tổ hợp (nhãn, tên role, số điều nội quy, tên category và kênh, cờ staff và số lượng), và dữ liệu đã xuất được đối chiếu với các file theme. Nếu tôi đổi phép gộp của bot mà quên bản port, bộ test sẽ thất bại.
Giai đoạn 18: các mức hài
Ba mức hài (nhẹ nhàng, troll, lầy lội) nằm trong kế hoạch rất lâu, và câu hỏi thiết kế là một "mức" là gì. Câu trả lời giữ cho hệ thống đơn giản: một mức thay đổi câu chữ, không bao giờ thay đổi cấu trúc. src/themes/humor.js giữ ba phiên bản của tám điều nội quy dùng chung và, với mỗi theme có sẵn, một lời chào nhẹ nhàng và một lời chào lầy lội (lời chào troll vẫn nằm trong file theme). composePlan(picked, { humor }) thay những thứ đó vào và ghi lại mức trên plan; role, category và kênh không bị đụng tới. Điều đó quan trọng vì builder idempotent theo tên: dựng lại server ở mức khác thì nó nhận ra mọi thứ đã có, nên chỉ những câu chữ chưa từng được đăng mới khác.
Các test chốt điều đó lại: mức nào cũng có cùng số điều nội quy, theme nào cũng có lời chào cho mọi mức và mỗi lời chào đều chứa {user}, một mức không xác định sẽ ném lỗi, và với nhiều tổ hợp, cấu trúc (role và category) bằng nhau hoàn toàn giữa các mức trong khi lời chào và tám điều nội quy đầu thì khác. Server miễn phí giữ mức troll, và chọn mức khác là tính năng Pro được kiểm tra bởi gateFeature. Theme đã lưu và theme AI không có lời chào theo từng mức, nên chúng giữ lời chào lúc được viết ra và chỉ nội quy dùng chung thay đổi.
Một lỗi mà không test nào bắt được cho tới khi có một test như thế. Trong /build tôi đặt tên mức được chọn là humor, và nó che mất module câu thoại của bot đã import mà chính hàm đó dùng vài dòng phía trên. Kết quả là lỗi temporal dead zone ngay khi có người chạy lệnh. Các unit test không nhận ra vì chúng chưa bao giờ chạy handler. Tôi đổi tên biến và thêm test/commands.test.js, chạy /build với một interaction giả tối giản (server miễn phí, từ chối trộn theme, từ chối mức hài, server Pro được phép, người không phải admin bị từ chối). Đó là khởi đầu của việc chạy handler trực tiếp trong test.
Giai đoạn 19: thanh toán tự động
Mã thủ công là bước đầu đúng đắn, nhưng bán hàng nghĩa là phải có người thức. Mục tiêu là /mua goi:pro, một mã QR, và một gói tự bật lên. Tôi chọn payOS vì nó nhận chuyển khoản ngân hàng Việt Nam (QR qua VietQR), không có phí hằng tháng, và API nhỏ gọn. Các ràng buộc định hình thiết kế:
- Không có địa chỉ công khai. Bot chạy trên Raspberry Pi sau mạng gia đình, và cách thông thường để payOS báo thanh toán là webhook. Thay vì đặt một endpoint công khai (và một secret dùng chung) trên serverless function, bot tự hỏi: một job hỏi payOS về các đơn đang mở của chính nó mỗi 30 giây. Mọi secret ở lại trên một máy, và không có endpoint nào để tấn công hay phải giữ cho luôn sẵn sàng. Cái giá là độ trễ tối đa 30 giây và giới hạn số đơn mở cùng lúc, cả hai đều ổn ở quy mô này.
- Đúng một lần. Một dòng đơn hàng đi từ
PENDINGsangPAIDbằngUPDATE ... WHERE status = 'PENDING'; chỉ lời gọi thực sự thay đổi một dòng mới cấp license. Hỏi cùng một đơn đã trả hai lần, hay hai lượt chạy chồng nhau, cũng không thể cấp hai lần. Một test hỏi một đơn đã trả hai lần và kiểm tra ngày hết hạn không bị dịch. - Ký. payOS ký một yêu cầu thanh toán bằng HMAC-SHA256 trên năm trường theo thứ tự bảng chữ cái (
amount,cancelUrl,description,orderCode,returnUrl) với checksum key của kênh. Một test tự tính lại chuỗi theo tài liệu một cách độc lập rồi so sánh. - Một giới hạn mô tả mà tôi suýt bỏ sót. Với tài khoản không liên kết qua payOS, phần mô tả dài tối đa 9 ký tự, nên nó là
THAUcộng năm chữ số cuối của mã đơn, và một test kiểm tra độ dài. - Đô la vào, đồng ra. Giá tính bằng đô la; số tiền thu là giá đô la nhân số ngày chia 30 nhân
USD_VND_RATE, làm tròn tới hàng nghìn với tối thiểu 2.000. Tôi cập nhật tỷ giá bằng tay, và số tiền được hiển thị trước khi ai đó trả. - Đường lỗi. Lỗi cổng thanh toán đánh dấu đơn thất bại và báo cho khách một cách rõ ràng; một đơn không đọc được không bao giờ chặn các đơn khác trong cùng lượt; một đơn vẫn chờ sau 35 phút thì hết hạn; câu trả lời đã huỷ và đã hết hạn đóng đơn mà không cấp gì.
Tôi không thể test với một tài khoản thật, và devlog nói rõ điều đó ở phần giới hạn: nó được viết theo tài liệu và một stub, và cần một lần thanh toán nhỏ thật trước khi tôi tin nó.
Giai đoạn 20: giữ cho server sống
Dựng server là ngày đầu tiên. Giai đoạn này thêm những thứ giữ một nhóm ở lại. Để các tính năng có thể được viết mà không đụng vào cùng những file, trước tiên tôi đặt nền móng dùng chung: mọi bảng mới trong một schema, các giới hạn gói mới trong bảng gói, các helper gateFeature và gateLimit, một bộ định tuyến cho phép một command sở hữu mọi component có id bắt đầu bằng tên của nó (nút, menu, modal và autocomplete), và một registry job (src/jobs.js) chạy mọi file trong src/jobs theo timer riêng mà không tự chồng lên chính nó. Sau đó mỗi tính năng chỉ là vài file mới.
- Theme đã lưu. Trình chỉnh sửa có thêm nút "lưu thành theme của tôi".
extractCustomThemechỉ giữ phần tuỳ biến của bản thiết kế (category không phải ba category cơ bản, role không phải role cơ bản, nội quy bổ sung, lời chào, nhãn), vàsanitizeCustomThemedựng lại mọi trường từ đầu khi một theme được nạp hay nhập, nên theme đã lưu không bao giờ mang quyền và file nhập vào không thể lén mang theo gì. Một test khứ hồi dựng một tổ hợp, chỉnh sửa nó, trích xuất, gộp, rồi so sánh. - Sao lưu và khôi phục. Một bản snapshot chứa role, category, kênh chat và kênh voice cùng role overwrite (tham chiếu theo tên role). Khôi phục lập kế hoạch trước (một hàm thuần liệt kê những gì còn thiếu theo tên và category cha), hiển thị chính xác những gì nó sẽ tạo, rồi chỉ tạo, không bao giờ xoá hay sửa. Administrator không bao giờ được khôi phục, và một file có server nguồn khác server đích còn mất thêm ManageGuild, ManageRoles, ManageChannels, ManageWebhooks, BanMembers, KickMembers và MentionEveryone. File nhập chỉ đến từ các host HTTPS của chính Discord với redirect bị từ chối, số byte thật được đo bên cạnh kích thước được báo, và JSON được dựng lại từng trường với giới hạn cứng. Mọi thứ khôi phục tạo ra đều được ghi lại, trong một
finally, để/nukecó thể hoàn tác. - Điểm danh, cấp độ và mini-game. Điểm danh hằng ngày theo ngày lịch ở múi giờ đã cấu hình (một hàm thuần được test qua nửa đêm, một ngày bị lỡ và một ranh giới múi giờ), thưởng chuỗi ngày, role cấp độ được tạo khi cần và ghi lại cho
/nuke(không bao giờ tự gán được), và ba trò chơi với nguồn ngẫu nhiên và đồng hồ bơm vào được: đoán số, đấu oẳn tù tì với lựa chọn bí mật, và đố vui với ngân hàng câu hỏi đã kiểm tra. Giới hạn điểm game mỗi ngày cho mỗi người ngăn việc cày điểm. - Sự kiện định kỳ. Một định nghĩa hằng tuần lưu thứ trong tuần và giờ;
nextOccurrencetính thời điểm bắt đầu kế tiếp ở múi giờ đã cấu hình bằngIntlmà không cần thư viện, và một job chạy mỗi năm phút tạo scheduled event của Discord khi lần bắt đầu kế tiếp còn dưới 24 giờ, thông báo một lần và không bao giờ tạo bản trùng.
Giai đoạn 21: deploy trang web từ Git, và chăm chút repository
Trang web trước giờ được deploy từ dòng lệnh. Để mỗi lần push là deploy, tôi phải nối dự án với GitHub, và điều đầu tiên tôi phát hiện là nó đã được nối sẵn (việc tạo dự án đã làm điều đó) và đã thất bại âm thầm từ lâu: mỗi lần push kích hoạt một bản build production lấy gốc repository làm gốc dự án, nơi không có app Next.js nào, nên mọi lần deploy từ Git đều kết thúc bằng lỗi trong khi các lần deploy từ dòng lệnh vẫn thành công. Tôi sửa cài đặt dự án qua Vercel API (root directory là web, và một lệnh ignore-build-step bỏ qua bản build khi không có gì trong web/ thay đổi) và xác nhận lần push tiếp theo đã deploy. Có một hệ quả đáng ghi lại: khi đã đặt root directory, một lần deploy từ dòng lệnh phải chạy từ gốc repository, không phải từ web/.
Chăm chút cho những người tìm thấy repository: ảnh chụp màn hình trang web thật trong README, một ảnh xem trước cho mạng xã hội, một bản release có tag kèm ghi chú, và phần mô tả cùng topic của repository. Tôi cũng nhận ra một file cache build TypeScript đã bị commit nhầm, nên bỏ track nó và thêm mẫu đó vào ignore.
Giai đoạn 22: trông coi một server đang chạy
Một server đã dựng xong vẫn cần được trông coi, và đó là phần việc mà một bot hoặc xứng đáng với quyền của mình, hoặc đánh mất chúng. Ràng buộc tôi đặt đầu tiên vẫn như trước: không dùng privileged intent, nên không có gì đọc những gì mọi người viết. Điều đó quyết định phần lớn các thiết kế.
- Luồng chào mừng. Chỉ với intent
GuildsvàGuildMessages, bot không nhận được sự kiện thành viên tham gia. Nhưng Discord có đăng một tin hệ thống "đã tham gia" trong kênh hệ thống, và một tin nhắn kiểuUserJoinmang ID của thành viên mới mà không mang đoạn chữ nào do ai đó gõ. Bot phản ứng với tin đó, lấy riêng thành viên đó qua REST, rồi đăng lời chào, cấp role khởi đầu và đưa ra nút xác minh. Mẫu câu thay{user}và{server}trong một lượt duy nhất, nên một tên server có chứa{user}không bao giờ bị thay hai lần, và chỉ thành viên mới mới có thể bị ping. Role chỉ được cấp khi an toàn: không phải role được quản lý, nằm dưới bot, và không có quyền nào trong danh sách quyền nguy hiểm. - Kiểm tra sức khoẻ. Một bộ quy tắc thuần chạy trên snapshot của guild (quyền của
@everyone, kênh nội quy và kênh thông báo có cho viết không, mức xác minh, vị trí role của chính bot) và cho ra một điểm số cùng các phát hiện có mức độ nghiêm trọng. Cách sửa là các object riêng mô tả chính xác chúng sẽ thay đổi gì, nên phần xem trước chính là diff và nút bấm chỉ áp dụng đúng điều đó. Điểm số được lưu lại để/khamsuckhoe lichsuhiển thị được xu hướng. - AutoMod. Bot tạo các rule AutoMod gốc của Discord (spam, spam mention, link mời, và các bộ từ khoá dựng sẵn ở mức cao hơn) thay vì tự theo dõi tin nhắn. Discord làm việc chặn, nên không có nội dung tin nhắn nào tới được bot. ID của mọi rule bot tạo đều được lưu theo mục đích, nên tắt AutoMod sẽ xoá đúng những rule đó và để nguyên các rule do admin tự tạo. Mỗi mức ứng với một bộ rule cố định, và gói miễn phí có mức nhẹ nhàng kèm chặn link mời.
- Ticket. Một tin nhắn bảng điều khiển có nút mở một kênh riêng cho thành viên và role staff. Thời gian không hoạt động được tính từ timestamp nằm trong snowflake ID của tin nhắn cuối, nên bot không bao giờ đọc tin nhắn để biết kênh đang yên. Khi đóng, nó ghi lại ai mở, ai đóng và lúc nào, chứ không ghi những gì đã nói, và không lưu bản ghi hội thoại nào.
- Cài đặt theo từng server. Mỗi server một tài liệu JSON với mỗi công cụ một section. Mỗi section có giá trị mặc định và một hàm chuẩn hoá dựng lại mọi trường từ đầu, nên cùng một cách làm sạch được áp dụng dù giá trị đến từ command, modal hay dashboard.
Giai đoạn 23: dashboard web
Mọi thứ ở trên đều chỉnh được bằng lệnh, nhưng admin vẫn mong có một trang. Dashboard chạy bên trong tiến trình bot, nên nó thấy cùng cơ sở dữ liệu và cùng Discord client và không cần service thứ hai. Đăng nhập bằng Discord OAuth2: access token được dùng một lần để biết người đó là ai rồi bị thu hồi, và phiên đăng nhập là một cookie ký bằng HMAC. Một server chỉ hiện ra cho người đang là admin của nó ngay lúc này, điều mà dashboard kiểm tra lại với Discord (members.fetch với force) ở mọi request, nên gỡ một role là có hiệu lực ngay. Thao tác ghi cần một header CSRF và kiểm tra cùng nguồn gốc, request bị rate limit, trang chạy dưới Content Security Policy nghiêm ngặt, và front end không bao giờ dùng innerHTML.
Pi nằm sau router gia đình, nên trang được đưa ra ngoài qua Tailscale Funnel tới cổng loopback mà Docker publish, không mở cổng nào trên router. Dashboard tắt cho tới khi ba biến được đặt, nên một bot không có chúng hoạt động y như trước. Tôi chạy nó trong trình duyệt headless với một Discord giả (một vòng OAuth thật với các stub, rồi tới các màn hình) để kiểm tra đăng nhập, phần chọn server, từng tab và bố cục điện thoại.
Cùng giai đoạn này thêm hai tính năng tăng trưởng: một lượt dùng thử Pro bảy ngày duy nhất (/dungthu, được ghi nhớ trong bộ đếm lượt dùng trọn đời, thứ mà /xoadulieu không xoá, nên không thể cày) và một dòng ghi công nhỏ dưới lời chào của gói miễn phí. Các server liệt kê trong UNLOCKED_GUILD_IDS được mọi tính năng không giới hạn, đó là cách tôi test tính năng mới trên server của chính mình.
Giai đoạn 24: thiết lập có hướng dẫn, để người lạ không cần đọc tài liệu
Tới giai đoạn 23, bot làm được rất nhiều mà khách mới không tìm thấy được gì. Lời mời hoạt động, rồi sau đó là một con bot im lặng và một danh sách ba mươi lệnh. Phễu tôi muốn đo (được mời, đã thiết lập, đã dựng, đã thử Pro, đã trả tiền) bắt đầu bằng một bước mà phần lớn mọi người không bao giờ làm.
/batdau là một màn hình với ba select menu (loại server, mức hài, các tính năng bổ sung muốn bật) và một nút Dựng. Cùng màn hình đó được đưa ra dưới dạng nút Bắt đầu trong tin nhắn mà bot đăng khi vào một server, nên admin mới không cần biết tên lệnh. Các thành phần:
- Nửa đầu của
src/onboarding/wizard.jslà thuần: làm sạch lựa chọn theo gói, một bảng từ khoá gợi ý theme từ vài chữ được gõ (không gọi AI: mỗi từ khoá tìm thấy cộng điểm cho một theme, và một cụm từ tính gấp đôi), và thẻ kết quả.runWizardlà phần duy nhất đụng tới server, và nó chỉ dựng những gì bot dựng hoặc thay đổi những gì admin yêu cầu. - Trước và sau. Trình hướng dẫn chạy kiểm tra sức khoẻ trước, chạy lại ở cuối, và đăng cả hai điểm số trên một thẻ có thể dùng luôn để chia sẻ. Đó là bằng chứng giá trị rẻ nhất mà tôi nghĩ ra được, và nó không cần thêm dữ liệu nào.
- Chạy hai lần vẫn an toàn. Chạy lại cùng một thiết lập sẽ bỏ qua bước dựng và không tiêu tốn một lượt dựng của gói miễn phí. Kênh mà admin đã chọn cho log được giữ lại, một channel id đã chết được thay, và một mức AutoMod nghiêm hơn mức được yêu cầu thì được để yên.
- Cổng chặn của gói vẫn giữ được câu đùa. Một server miễn phí chọn nhiều theme sẽ giữ theme đầu và được báo rằng trộn theme là dành cho Pro, và
/dungthulà một tuần miễn phí. /trogiupđược sinh ra từ một bảng các mục lệnh kèm cờ gói mở khoá từng mục, nên những gì một gói khoá chính là cùng dữ liệu mà bảng gói dùng. Một lệnh chưa ai thêm vào bảng vẫn hiện ra dưới mục "khác", còn lệnh chỉ dành cho chủ bot thì không bao giờ hiện.
Mọi component của trình hướng dẫn đều được phân quyền lại khi dùng (guild, người dùng, quyền admin), một phiên kéo dài 15 phút trong bộ nhớ giống như bản thiết kế, và khoá dựng được nhả trong một finally, kể cả khi chính câu trả lời của nút bị lỗi.
Giai đoạn 25: bảo vệ có thể hoàn tác
Yêu cầu đặt ra là bảo vệ mà không đọc chữ của ai, và không có tính năng nào có thể khoá admin ra khỏi chính server của họ.
- Chống raid. Bot không nhận được sự kiện thành viên tham gia nếu không có privileged intent, nhưng nó có nhận thông báo tham gia của Discord trong kênh hệ thống (cùng mẹo với luồng chào mừng). Một bộ đếm cửa sổ trượt thuần (
src/security/raid.js) lấy id của thông báo đó, nên cùng một thông báo bị đếm hai lần cũng không thay đổi gì, kích hoạt khi có N lượt tham gia trong S giây, rồi im lặng một phút để một đợt raid chỉ là một cảnh báo. Hành động là một tuỳ chọn cài đặt: chỉ cảnh báo, nâng mức xác minh lên một bậc, hoặc khoá các kênh chat. - Khoá server thì ghi lại trước. Trước khi đụng vào bất cứ thứ gì,
startLockdownghi vào cài đặt của server những kênh nó sẽ khoá, trạng thái SendMessages của @everyone ở từng kênh lúc đó (cho phép, chặn hay không đặt), và mức xác minh mà nó nâng lên từ đâu. Khôi phục chỉ đưa một kênh về như cũ nếu kênh đó vẫn y như lúc bị khoá; một kênh ai đó đã sửa tay, hoặc không còn tồn tại, không phải thứ của mình để đụng vào. Trạng thái nằm trong tài liệu cài đặt, không nằm trong bộ nhớ, nên crash hay khởi động lại giữa lúc đang khoá vẫn mở khoá được, và một job chạy mỗi phút gỡ mọi lần khoá đã hết giờ. Bắt đầu hai lần hay dừng hai lần đều vô hại. - Chống nuke (Pro). Một kênh hoặc role bị xoá sinh ra một sự kiện, bot đọc audit log để biết ai làm (nó cần View Audit Log; nếu thiếu, lớp bảo vệ nói rõ điều đó và nằm im), và một bộ đếm theo từng người kích hoạt khi vượt ngưỡng trong một khoảng thời gian. Khi kích hoạt, nó gửi cảnh báo và chỉ gỡ các role nguy hiểm (Administrator, Manage Server, Manage Roles, Manage Channels, Manage Webhooks, Ban Members, Kick Members) mà bot được phép gỡ hợp lệ: không bao giờ của chủ server, không bao giờ là role được quản lý, không bao giờ là role ngang hoặc cao hơn role cao nhất của bot, và cảnh báo nói rõ nó đã giữ lại những role nào và vì sao. Chủ server, chính bot và người thực hiện không xác định không bao giờ bị xử lý.
- Cảnh báo không bao giờ tin một kênh. Cảnh báo được gửi tới kênh đã chọn, nếu không thì tới kênh hệ thống, và chỉ khi bot thực sự xem, gửi và nhúng embed được ở đó. Một lần đăng thất bại được ghi log rồi bỏ qua, nên một kênh hỏng không thể làm hỏng handler.
Giai đoạn 26: kiểm duyệt có trí nhớ
/canhcao, /timeout, /kick, /ban và /hoso là những lệnh kiểm duyệt bình thường với hai đặc điểm tôi quan tâm. Thứ nhất, mọi lời từ chối xảy ra trước khi có gì thay đổi: quyền của người dùng và của bot, rồi một phép kiểm tra thứ bậc thuần (không phải chính mình, không phải bot, không phải chủ server, không ai ngang hoặc cao hơn bạn trừ khi bạn là chủ server, không ai ngang hoặc cao hơn bot, và không timeout một Administrator, điều mà Discord vốn cũng từ chối). Thứ hai, mỗi hành động lưu một vụ việc có đánh số (ID thành viên, ID moderator, hành động, lý do giới hạn 300 ký tự, thời điểm kết thúc timeout) và ghi vào mod log, nên lịch sử vẫn tồn tại dù không ai nhớ ghi chép lại. Với kick hoặc ban, thành viên được báo qua tin nhắn riêng trước, vì sau đó không còn server chung nào để gửi, và một tin nhắn gửi thất bại không thay đổi gì.
Bản thân mod log (/khoakhan nhatky) lắng nghe các sự kiện ban, unban, cập nhật role và thực thi AutoMod. Nó ghi lại một role đã được thêm và mất những quyền nào, ai bị ban, và rule AutoMod nào đã kích hoạt với ai ở kênh nào, chứ không ghi đoạn chữ bị chặn (sự kiện có mang nó nhưng handler không bao giờ đọc trường đó). Một lần ban thực hiện qua /ban được nhớ trong vài giây để sự kiện ban không bị ghi log lần thứ hai. Discord chỉ báo cho bot về những lần timeout do chính nó thực hiện, trừ khi nó có một quyền đặc biệt, nên log nói thẳng rằng nó ghi lại các lần timeout thực hiện qua lệnh của chính nó.
Giai đoạn 27: hoạt động, và những thứ một cộng đồng làm cùng nhau
- Xp không cần nội dung. Một sự kiện tin nhắn cho biết tác giả và kênh, đó là tất cả những gì xp cần. Đường nóng được sắp từ rẻ nhất tới đắt nhất: hình dạng tin nhắn (bot, webhook, tin hệ thống và DM không được gì), rồi cài đặt theo server từ cache 30 giây, rồi một mục theo từng người giữ trong bộ nhớ với cooldown và tổng theo ngày. Cơ sở dữ liệu được ghi trong một transaction mỗi 15 giây, chỉ khi thực sự có xp được cộng, và phần tăng được cộng thêm chứ không thay thế tổng, nên thứ gì bị xoá ở giữa sẽ không bị đưa trở lại. Một tin nhắn không được gì sẽ không bao giờ chạm tới cơ sở dữ liệu sau lần nạp đầu tiên. Đường cong cấp độ (tổng cộng
15 n^2 + 60 n) được cố tình làm thoải và được nghịch đảo chính xác, có chốt chặn cho các trường hợp biên của số thực dấu phẩy động. - Thời gian voice. Bộ theo dõi (
src/activity/voice.js) giữ một mô hình nhỏ của riêng nó về ai đang ở phòng nào và chỉ tính một phút khi người đó không bị tắt nghe, không ở kênh AFK và có ít nhất một người thật khác không bị tắt nghe trong phòng. Chuyển phòng sẽ chốt phiên đầu, và lúc khởi động, mô hình được nạp sẵn từ các voice state mà bot thấy được. - Menu role đăng các nút (hoặc một select menu khi có hơn năm role) để nhận và bỏ role. Một role chỉ được đưa ra khi nó an toàn lúc đó (không phải role được quản lý, nằm dưới bot, không có quyền nguy hiểm) và điều đó được kiểm tra lại ở mỗi lần bấm, nên một role trở nên nguy hiểm sau khi menu đã đăng sẽ không được cấp, và một nút giả cho một role nằm ngoài menu không cấp được gì.
- Giveaway lưu lượt tham gia theo từng người, đóng đúng một lần (việc lật trạng thái từ đang chạy sang đã đóng là chốt chặn, cùng khuôn với thanh toán), bốc thăm không hoàn lại từ một lần xáo trộn một phần với nguồn ngẫu nhiên bơm vào được, và một job đóng mọi giveaway đã tới hạn trong lúc bot tắt. Bốc lại không bao giờ chọn người đã thắng trước đó. Role bắt buộc được kiểm tra khi bấm nút.
- Bình chọn ẩn danh vì không có gì trong tin nhắn nêu tên người bầu: chỉ có số lượng và thanh, mỗi người một phiếu, đổi phiếu sẽ thay phiếu trước, và tin nhắn được cập nhật tại chỗ như câu trả lời cho lần bấm.
Giai đoạn 28: báo cáo, nhắc nhở và trợ lý viết bằng AI
- Báo cáo hằng tuần. Các con số đến từ những bảng bot đã có (lượt tham gia đếm từ thông báo tham gia, ticket, lượt chặn của AutoMod, điểm sức khoẻ so với lần kiểm tra cách đó ít nhất năm ngày) và không bao giờ từ thứ gì một thành viên đã viết. Lịch là thuần (
lastSlottìm thứ và giờ gần nhất trong múi giờ của server bằngIntl, không thư viện), một khung giờ bị lỡ vẫn được gửi trong vòng tối đa một ngày, và thời điểm gửi gần nhất được lưu lại để một lần khởi động lại không thể gửi hai lần. Bản xem trước từ dashboard không làm xê dịch lịch. - Kiểm tra sức khoẻ hằng tuần cho mỗi server đã đăng ký, và một cảnh báo, một lần, khi điểm số giảm từ mười điểm trở lên, kèm cách sửa an toàn dưới dạng nút vẫn yêu cầu chính người đó xác nhận. Lần kiểm tra đầu tiên không có gì để so nên không bao giờ cảnh báo, và một lần kiểm tra thất bại được thử lại vào tuần sau, không phải mỗi giờ.
- Nhắc hết hạn. Một thông báo ba ngày trước khi gói trả phí kết thúc và một thông báo khi nó đã kết thúc (chỉ khi đó vẫn còn là tin mới), được ghi nhớ trong bộ đếm lượt dùng trọn đời dưới một khoá có chứa thời điểm hết hạn, nên một lần gia hạn có lời nhắc riêng của nó và cùng một lần hết hạn không bao giờ được thông báo hai lần. Dấu đánh dấu được ghi trước khi đăng, nên crash không thể làm nó lặp lại. Điều này khép lại giới hạn "không nhắc khi gói sắp hết hạn" của giai đoạn 19.
- Trợ lý viết (
/vietgiup) soạn nháp nội quy, lời chào, thông báo, hoặc một lời giải thích dễ hiểu cho lần kiểm tra sức khoẻ gần nhất. Chữ của admin được làm phẳng (bỏ mention, ký tự điều khiển và fence, giới hạn 300 ký tự) và được rào lại như dữ liệu trong prompt; câu trả lời được làm sạch giống như một server được thiết kế (bỏ mention, từ chối link và từ cấm, giới hạn độ dài theo từng loại) và được làm sạch lại ngay trước khi đăng. Model không bao giờ tự đăng gì: người soạn chọn một kênh rồi bấm. Hạn mức là cùng số đếm hằng tháng với/thietke, trừ một lần cho mỗi bản nháp dùng được và không trừ gì khi AI lỗi, và việc kiểm tra cùng việc trừ diễn ra cùng lúc nên hai bản nháp bắt đầu cùng lúc không thể cùng lọt qua. - Phễu.
track()ghi một server ID, một loại sự kiện ngắn và thời điểm vàoevents_log(được mời, đã thiết lập xong, đã dựng, đã bật tính năng, dùng thử, đã trả tiền, đã rời)./admin thongkecho biết có bao nhiêu server khác nhau đạt tới từng bước trong 30 ngày và chiếm bao nhiêu phần trong số server được mời. Đó là phần phân tích duy nhất trong bot và không chứa gì về một con người.
Giai đoạn 29: giá mới, các tab dashboard và một route trạng thái công khai
Pro giá $9.99 và Plus giá $19.99 là những phỏng đoán đưa ra khi bot chưa làm được bao nhiêu. Với các tính năng mới, tôi chuyển sang $3.99 và $7.99 cho mỗi 30 ngày, một năm với giá mười tháng (hai tháng miễn phí là lý do cam kết mạnh hơn một tỷ lệ phần trăm), một gói Dựng giúp trả một lần giá $4.99 cấp bảy ngày Pro, và hai lượt dựng cho gói miễn phí thay vì một. /dungthu vẫn là một lượt dùng thử bảy ngày cho mỗi server. Gói trả một lần là một sản phẩm, không phải một gói thuê bao: ONE_OFF nói rõ nó cấp những gì, và việc ghi nhận thanh toán dùng số ngày của chính sản phẩm bất kể dòng đơn hàng ghi gì.
Dashboard có thêm các tab bảo mật (có nút mở khoá), điểm hoạt động, báo cáo hằng tuần (có nút gửi thử), mod log và một trang tổng quan mới, tất cả đều dùng cùng các section cài đặt và hàm chuẩn hoá như các command.
Một route công khai GET /status trả về ok (file heartbeat còn mới), thời gian hoạt động, phiên bản, số server làm tròn xuống tới hàng chục, và tuổi của heartbeat. Nó chỉ đọc, cho phép mọi origin vì không mang gì đáng giấu (không ID, không tên, không cài đặt), và bị rate limit theo từng địa chỉ.
Giai đoạn 30: một lượt rà soát
Sau khi các tính năng đã vào, tôi rà soát chúng như thể người khác viết, tìm những chỗ mà một handler có thể làm được một nửa việc. Các phát hiện nằm trong bảng Lỗi bên dưới, và mỗi cái có một test trong test/review-security.test.js, test/review-engage.test.js hoặc test/review-platform.test.js. Khuôn mẫu rất nhất quán: đường đi suôn sẻ thì đúng, còn lỗi nằm ở chuyện xảy ra khi bước thứ hai trong hai bước thất bại.
Giai đoạn 31: trang web bắt kịp, mỗi theme một trang và một trang trạng thái
Trang web đã dừng ở phiên bản 1.3. Tôi cập nhật nội dung của nó cho bằng bot: các thẻ tính năng mới (thiết lập, chống raid, chống nuke, mod log, kiểm duyệt, cấp độ hoạt động, menu role, giveaway và bình chọn, báo cáo hằng tuần, nhắc nhở, trợ lý viết), giá mới với tuỳ chọn theo năm và trả một lần, cả 34 lệnh cho khách hàng, câu trả lời FAQ cho ba câu hỏi người lạ hỏi đầu tiên (chat của tôi có bị đọc không, chống raid làm gì, thiết lập có hướng dẫn là gì), và các trang quyền riêng tư và điều khoản liệt kê mọi loại dữ liệu mới được lưu và nói thẳng /xoadulieu xoá những gì. Viết trang đó mới thấy lệnh này chưa xoá các bảng theo từng thành viên, nên giờ nó xoá chúng trong một transaction (src/purge.js).
Hai trang mới, đều tĩnh:
- Mỗi theme một trang (
/themes/[id],/en/themes/[id]), 22 trang từgenerateStaticParams. Nó hiển thị plan thật của bot cho riêng theme đó từ cùng filethemes.data.jsonmà bàn vẽ dùng, nên cây role, category và kênh không thể lệch khỏi những gì/buildtạo ra. Mỗi trang có nút mời, lệnh để copy, và liên kết trước, sau quay vòng.dynamicParamsbị tắt, nên một id không xác định là 404 ngay từ lúc build. Mộtsitemap.xmlliệt kê mọi trang cùng các bản ngôn ngữ thay thế. - Một trang trạng thái đọc route
/statuscông khai của bot từ trình duyệt. Lấy dữ liệu trong trình duyệt giữ cho trang tĩnh và đặt lỗi vào đúng chỗ của nó: nếu không gọi được route thì thẻ hiện "không kiểm tra được", điều không giống với việc bot bị sập. Câu trả lời được coi là không đáng tin (hình dạng chính xác được kiểm tra, chữ bị cắt ngắn), và thẻ có cùng kích thước ở mọi trạng thái với dấu gạch ở chỗ sẽ hiện giá trị, nên không có gì xê dịch khi câu trả lời tới. Hiệu ứng nhịp đập trên chấm trạng thái chỉ chạy khi người xem không yêu cầu giảm chuyển động.
Các trang mới dùng lại header, tiêu đề section, chip và lớp chuyển động có sẵn; code mới duy nhất là hai component, một cây và một khối stylesheet cho trạng thái. JS tải lần đầu là 155 kB cho mỗi trang, bằng trang devlog.
Giai đoạn 32: phòng tự xuất hiện và tự dọn, và kênh hiện con số
Hai tính năng voice nhỏ mà thành viên thấy lớn, xây quanh một ràng buộc: Discord giới hạn những gì bot được làm với một kênh, nên thiết kế chủ yếu là làm ít hơn, thưa hơn.
- Phòng voice tạm (
/phongtam). Một kênh voice trở thành sảnh "vào để tạo". Sự kiện voice state kích hoạt khi có người vào đó, và bot dựng một phòng đặt tên theo mẫu (Phòng của {name}), cấp cho người đó vài quyền trên riêng kênh đó (thấy, vào, đổi tên, đưa người khác ra) rồi chuyển họ vào. Không có quyền nào trên toàn server được cấp, và một test chốt danh sách quyền chính xác. Một kênh được tạo với overwrite riêng sẽ không còn đi theo category của nó, nên overwrite của category được copy trước rồi mục của người đó được thêm lên trên, nếu không phòng sẽ âm thầm mất các quy tắc của category. - Vì sao phòng cần một bảng. Mỗi phòng là một dòng trong
temp_voice(kênh, chủ phòng, thời điểm). Xoá, quét dọn và dọn dẹp thất bại chỉ bao giờ tác động lên một dòng đã ghi, nên bot không bao giờ có thể xoá một kênh admin tự tạo, kể cả khi tên na ná. Phòng bị xoá khi người cuối cùng rời đi, và khi xoá thất bại thì dòng vẫn ở lại để lượt quét thử lại. - Giới hạn chống lạm dụng. Mỗi người một phòng mỗi mười giây (một map cooldown có giới hạn trong bộ nhớ), tối đa 50 phòng mỗi server, một tập "đang được tạo" để vào hai lần cũng không tạo ra hai phòng, và một thông báo cho admin tối đa một lần mỗi mười phút khi bot thiếu quyền. Nếu người đó rời sảnh trong lúc phòng đang được tạo, họ không nhận được phòng. Nếu chuyển họ vào thất bại, phòng bị xoá lại.
- Gói đếm số sảnh, không đếm số phòng: 1 với Free, 3 với Pro, 5 với Plus. Một sảnh vượt quá gói đơn giản là không làm gì, nên hạ gói không cần dọn dẹp.
- Kênh thống kê (
/kenhthongke). Một kênh voice có tên mang con số trực tiếp (thành viên, boost, kênh, role không tính @everyone) từ một mẫu nhưThành viên: {n}. Discord cho phép đổi tên một kênh hai lần mỗi mười phút rồi bắt lời gọi phải chờ, nên job chạy mỗi mười phút, chỉ đổi tên khi chữ thay đổi, và giữ một đồng hồ theo từng kênh để làm mới thủ công hay khởi động lại không gây dồn dập. Một lần đổi tên thất bại không được thử lại trong mười phút. Cách khác, cập nhật mỗi lần có người vào, sẽ chạm giới hạn trong vòng một phút ở server đông và xếp hàng lời gọi bên trong thư viện. Bot có thể dựng một kênh bị khoá (không ai kết nối được), được ghi nhớ trong bản ghi server để/nukevà thao tác gỡ chỉ xoá những gì nó đã tạo. - Một job cho cả hai. Lượt quét phòng tạm chạy trong cùng job mười phút: phòng bị bỏ trống do khởi động lại được xoá, dòng của phòng bị xoá tay được quên đi, phòng chưa tới 30 giây tuổi không bao giờ bị quét, và các server mà bot không ở trong hoặc đang không khả dụng được bỏ qua.
Giai đoạn 33: tin nhắn hẹn giờ và nhắc việc cá nhân
Cả hai đều là tính năng "làm việc này sau", nên cả hai sống hay chết với một câu hỏi: chuyện gì xảy ra khi bot tắt, khởi động lại hoặc chạy hai lần?
- Tin nhắn hẹn giờ (
/hengio). Một bài đăng hằng ngày hoặc hằng tuần vào một giờ cố định theo múi giờ của server, dùng cùng code lập lịch dựa trênIntlvới sự kiện định kỳ, nên giờ mùa hè không cần thư viện. Nội dung có thể chứa\nđể xuống dòng vì slash command không nhận được dấu xuống dòng thật. Tin nhắn không bao giờ ping ai:allowedMentionsrỗng bất kể nội dung ghi gì. - Vì sao
next_atđược ghi trước khi gửi tin. Job chuyển dòng sang lần kế tiếp bằngUPDATE ... WHERE id = ? AND next_at = ?và chỉ lời gọi đã thay đổi dòng mới được đăng. Một lần crash sau thời điểm đó tệ nhất là mất một lần đăng và không bao giờ đăng hai lần, và hai lượt chạy chồng nhau không thể cùng thắng. Thứ tự ngược lại (gửi rồi mới cập nhật) sẽ lặp lại tin nhắn sau mỗi lần crash giữa hai bước, điều này với một tin nhắn còn tệ hơn là bị lỡ. - Sau thời gian ngừng chạy, một lần bị lỡ trong vòng mười hai giờ được đăng, những lần cũ hơn bị bỏ qua, và lần kế tiếp luôn được tính sau thời điểm hiện tại, nên một bot tắt cả tuần không phát lại bảy bài đăng hằng ngày. Một kênh mà bot không viết được thì được thử lại, một kênh bị xoá thì tạm dừng lịch, và khi gói hết hạn thì không đăng gì và không phát lại gì khi gói quay lại. Mỗi nhịp đăng tối đa 25 tin.
- Nhắc việc cá nhân (
/nhacviec). Miễn phí và mở cho mọi người, nên giới hạn rất quan trọng: 10 lời nhắc đang chờ mỗi người, 300 ký tự, tối đa một năm về sau. Cách gửi là tin nhắn riêng. Khi DM bị đóng, lời nhắc được gửi tới kênh nơi nó được tạo và chỉ mention đúng người đó, và khi cả hai đều thất bại thì nó bị đánh dấu thất bại và không bao giờ thử lại. Dòng được đánh dấu xong trước khi gửi, cùng lý do như trên, và một lời nhắc trễ hơn năm phút sẽ đến kèm một lời xin lỗi. Các dòng đã gửi được dọn sau một tháng. - Nhập thời gian. Hoặc là một lựa chọn (10 phút tới một tuần), hoặc là một giờ cụ thể, hiểu là hôm nay nếu giờ đó còn ở phía trước và ngày mai nếu không, được đọc theo những cách viết quen với người Việt và được kiểm tra theo cả hai chiều bằng
zonedToUtcvàlocalParts.
Giai đoạn 34: hộp góp ý
/gopy cho phép bất kỳ ai gửi một góp ý, bot đăng nó kèm nút tán thành và phản đối, và với staff thì có thêm nút duyệt, từ chối và đã xong. Nó miễn phí vì không đòi hỏi gì ở bot ngoài một tin nhắn.
- Phiếu bầu nằm trong
suggestion_votes, mỗi người một dòng: bấm nút kia sẽ đổi phiếu, bấm lại cùng nút sẽ rút phiếu, và số đếm trên tin nhắn được tính lại từ bảng chứ không cộng dồn, nên khởi động lại hay bấm hai lần cũng không thể làm chúng lệch. - Quyết định dùng cùng chốt chặn như giveaway: việc lật trạng thái từ đang mở là bước đúng một lần, nên hai staff bấm cùng lúc chỉ ghi một quyết định và người thứ hai thấy nó đã được đưa ra. Staff là những người có Manage Server hoặc role staff đã chọn, và điều đó được hỏi lại ở mỗi nút và lần nữa khi modal ghi chú được gửi, vì modal có thể đã được mở bởi một người mà quyền bị tước trong lúc đó.
- Chữ từ thành viên đi qua
sanitizetrước khi vào embed: cú pháp mention, link ẩn và ký tự điều khiển bị bỏ và độ dài bị giới hạn. Tác giả hiện dưới dạng mention bên trong embed nhưngallowedMentionsrỗng, nên không ai bị ping. - Rate limit là một lần mỗi phút và năm lần mỗi ngày cho mỗi người. Gỡ một góp ý là gỡ mềm (dòng vẫn còn và vẫn tính vào giới hạn hằng ngày của tác giả), nên xoá một góp ý không phải là cách lách giới hạn.
- Khởi động lại không ảnh hưởng. Mỗi lần bấm được giải quyết từ cơ sở dữ liệu theo tin nhắn, nên nút trên một tin nhắn cũ cả tháng vẫn hoạt động. Một góp ý mà tin nhắn không đăng được sẽ bị bỏ để nó không tính vào giới hạn của tác giả.
Giai đoạn 35: bộ lọc tài khoản mới và từ cấm riêng của server
- Tài khoản mới (
/khoakhan caidat tuoitaikhoan hanhdongmoi). Nó dùng lại thông báo tham gia mà chống raid vốn đã đọc, nên vẫn không có member intent. Một lượt tham gia sẽ xếp một lượt kiểm tra vào hàng đợi thay vì làm ngay: mỗi server một hàng đợi, mỗi lúc một việc với khoảng cách giữa các việc, cùng một người chỉ được xếp một lần, và một giới hạn mà quá nó thì những người mới nhất bị bỏ, nên một đợt raid không thể khiến bot lấy hàng trăm thành viên cùng lúc. Lượt kiểm tra đọc tuổi tài khoản từ snowflake, cảnh báo kênh staff, và vớikickthì còn đuổi tài khoản đó. Một lần kick mà Discord hay thứ bậc sẽ từ chối thì không bao giờ được thử (chủ server, bot, bất kỳ ai cao hơn bot, một thành viên đang giữ role chào mừng), và thiếu quyền Kick Members sẽ hạ xuống thành một cảnh báo nêu tên quyền còn thiếu. - Từ cấm tuỳ chỉnh (
/automod tukhoa). Cách hiển nhiên để chặn một từ là đọc mọi tin nhắn rồi so sánh. Bot không đọc tin nhắn và không có privileged intent, và tôi không muốn điều đó thay đổi chỉ vì một danh sách từ. AutoMod gốc của Discord có một rule từ khoá nhận một danh sách, hỗ trợ ký tự đại diện và chặn tin nhắn trước khi ai thấy, nên việc của bot chỉ là giữ cho rule đó khớp với danh sách admin đã gõ. Cái giá là các giới hạn của chính Discord (1.000 từ khoá mỗi rule, mỗi từ 60 ký tự, và chỉ sáu rule từ khoá mỗi server dùng chung với mọi bot và admin khác), đó là lý do bot dùng một rule và giới hạn danh sách theo gói (20, 200, 500), không bao giờ quá 500. - Một rule, ghi lại theo id. Danh sách được lưu trong section AutoMod và được ghi vào một rule duy nhất có id được ghi lại.
planSyncchỉ tạo, sửa hoặc xoá đúng rule đã ghi đó, một rule admin tự tạo không bao giờ bị đụng tới, một rule bị xoá tay sẽ được tạo lại ở lần thay đổi kế tiếp, một rule bị admin sửa bằng cách thêm từ sẽ được đặt lại, và xoá từ cuối cùng sẽ xoá rule. Hai thay đổi cùng lúc vẫn để lại đúng một rule. - Những gì bị từ chối. Link (chúng thuộc về rule link), mention, ký tự điều khiển và ký tự vô hình, từ dài quá 60 ký tự, và từ chỉ toàn ký tự đại diện hoặc chỉ một chữ cái, vì chúng sẽ dính gần như mọi tin nhắn. Ký tự thường được giữ nguyên như đã gõ: rule từ khoá của Discord không phải biểu thức chính quy, nên không có gì cần escape.
- Gói hết hạn thì vẫn giữ danh sách nhưng chỉ những từ gói miễn phí cho phép mới tới được rule, và chúng quay lại khi gói quay lại.
/xoadulieuxoá rule và danh sách.
Giai đoạn 36: dashboard và phần trợ giúp bắt kịp, và /xoadulieu học thêm các bảng mới
Dashboard có thêm tab Giveaway và tab Menu role (cả hai tính năng trước đó chỉ có dưới dạng lệnh), tab Phòng voice với một thẻ cho phòng tạm và một thẻ cho kênh thống kê, tab Góp ý, một thẻ từ tuỳ chỉnh trong AutoMod, một thẻ tuổi tài khoản trong Bảo mật, và số góp ý đang mở trên trang tổng quan. Tất cả đều ghi qua cùng các section cài đặt và hàm chuẩn hoá như các command, nên không thể lưu từ trang một giá trị mà command sẽ từ chối.
/trogiup đọc helpItems, nên năm lệnh mới được đưa vào bảng đó với cờ gói của giới hạn tương ứng (tempLobbies, statsChannels, scheduledMessages). Một cờ là số đếm thì đúng khi từ 1 trở lên, nên dấu khoá chỉ hiện khi gói miễn phí bằng không, mà hiện nay chỉ có tin nhắn hẹn giờ; bản thân các số đếm nằm trong bảng gói.
/xoadulieu phải học thêm năm bảng. Module xoá dữ liệu (src/purge.js) liệt kê mọi bảng có guild_id và xoá dòng con trước (phiếu bầu của một góp ý treo dưới góp ý đó), tất cả trong một transaction, nên một lỗi sẽ không để lại thứ gì bị xoá dở. Nhắc việc cũng mang guild_id, nên những lời nhắc ai đó tạo trong server đó sẽ đi cùng nó, còn lời nhắc của họ từ các server khác và từ tin nhắn riêng (không có guild_id) vẫn ở lại.
Lỗi và bài học rút ra
| Triệu chứng | Nguyên nhân gốc | Cách sửa | Chốt chặn hiện tại |
|---|---|---|---|
Chạy /build hai lần tạo ra kênh trùng và đăng nội quy hai lần | Việc tạo không báo nó có tạo gì hay không, nên nội dung lúc nào cũng được đăng | ensureChannel trả về { channel, created } và chỉ kênh mới được điền nội dung | Test builder dựng hai lần và đếm role, kênh và bài đăng |
| Bộ lọc từ không bao giờ chặn được từ tiếng Việt | Trong JavaScript, \b chỉ biết chữ cái ASCII, nên không có ranh giới cạnh đ hay ụ | Lookaround (?<!\p{L})word(?!\p{L}) với cờ u | Test kiểm tra từ cấm và mention bị bỏ |
| Sau lần sửa đầu bộ lọc vẫn không chạy, nhưng một test lại pass | Mẫu được dựng trong một template string thường, nơi \p âm thầm mất dấu gạch chéo ngược, và mẫu test tình cờ vẫn pass | Dùng String.raw cho mẫu | Một test dạng bảng giờ bao cả từ cấm lẫn những từ vô hại trông giống ("bạn đụng xe" và "Essex club" qua, "đụ má" bị chặn) |
next build thất bại ở font tiêu đề | Tôi nhớ tên họ font là "Big Shoulders Display"; next/font biết nó là "Big Shoulders" | Dùng đúng tên họ font | Job web trong CI build trang ở mỗi lần push |
Một .gitignore loại data/*.json lẽ ra đã công bố file SQLite chứa mã license | Định dạng lưu trữ đổi từ JSON sang file .db nhưng quy tắc ignore thì không đổi | Ignore cả thư mục data/ | Tôi liệt kê và quét các file đã stage trước lần push đầu; không có chốt chặn tự động |
| Chữ hoa tiếng Việt bị cắt trong tiêu đề có animation | Mask overflow cắt mất dấu chồng | Padding dọc và line height rộng hơn cho mask | Kiểm tra bằng mắt trong Chrome headless |
| Cảnh được ghim không vừa điện thoại | Không đủ chỗ cho tiêu đề phía trên cây server | Ẩn tiêu đề về mặt hiển thị trên màn hình nhỏ, giữ lại cho trình đọc màn hình | Ảnh chụp ở bề rộng điện thoại và kiểm tra tràn |
| Gửi modal có thể thất bại với "interaction failed" | Modal mở từ slash command không thể update một tin nhắn | respond() chọn update hoặc reply dựa trên isFromMessage() | Giữ trong một helper dùng chung cho cả hai handler modal |
| Git cảnh báo CRLF và shell script có thể hỏng trên Pi | Checkout trên Windows chuyển đổi kết thúc dòng | .gitattributes với eol=lf cho file shell và systemd | sh -n trên cả hai script |
Một ExperimentalWarning in ra mỗi lần khởi động | node:sqlite vẫn bị gắn cờ thử nghiệm | --disable-warning=ExperimentalWarning trong start, test và lệnh Docker | Lệnh chạy lẻ bỏ qua cờ này vẫn in ra |
| Bảng gói trả phí quảng cáo tính năng sao lưu chưa tồn tại | Tôi viết bảng gói trước khi có tính năng | Bỏ khỏi /goi trước khi công bố | README nêu những gì đã có và để phần còn lại trong Roadmap |
| Gemini gói miễn phí đôi khi trả lời bằng lỗi 5xx | Dịch vụ miễn phí có lúc bị quá tải. Tôi gặp nó lần đầu dưới dạng lỗi 503 thô khi tạo câu trả lời mẫu cho trang web, và client lúc đó coi nó là câu trả lời không dùng được | Lỗi 5xx ứng với loại unavailable riêng, được thử lại với thời gian chờ 1,5 s và 4 s, rồi một thông báo "quá tải" rõ ràng không trừ vào hạn mức của khách. Script tạo mẫu cũng chờ hết một câu trả lời hạn mức trong 65 s | Test với thời gian chờ đặt bằng không |
| Bộ test mất khoảng 38 giây | Khoảng nghỉ 350 ms giữa các lần tạo là hằng số hard-code, nên test builder ngủ thật | Khoảng nghỉ lấy từ cấu hình (BUILD_STEP_DELAY_MS) và test đặt nó bằng không | Bộ test giờ chạy trong khoảng 2,5 giây |
Một lần dựng crash giữa chừng để lại những thứ /nuke không tìm thấy | Bản ghi ID đã tạo chỉ được lưu một lần, sau khi mọi lần tạo thành công | Bản ghi được lưu trong khối finally | Test: lần tạo kênh thứ sáu ném lỗi, rồi nuke xoá mọi thứ đã được tạo |
| Trên điện thoại danh sách lệnh cuộn ngang khoảng 27 px | Dấu cách trước mỗi tham số nằm trong một span không xuống dòng, nên /build theme theme2 theme3 theme4 không có chỗ ngắt | Dấu cách được chuyển ra ngoài span, và chữ của lệnh có thể xuống dòng ở bất kỳ đâu | Phép đo tràn trong script trình duyệt headless, chạy cho cả hai ngôn ngữ ở bề rộng điện thoại |
/build crash ngay khi chạy sau khi thêm mức hài | Một biến tên humor che mất module câu thoại đã import và được dùng phía trên trong cùng hàm (lỗi temporal dead zone) | Đổi tên thành level | test/commands.test.js chạy handler với một interaction giả |
| Mỗi lần push sinh ra một lần deploy Vercel thất bại trong khi deploy từ dòng lệnh vẫn chạy | Dự án được nối với Git mà không có root directory, nên Vercel tìm app ở gốc repository | Đặt root directory là web qua API, cộng một quy tắc bỏ qua cho những lần push không đụng tới web/ | Một lần deploy từ Git sau khi sửa đã đạt READY |
| Hai bản sao của bot cùng trả lời lệnh một lúc | Một terminal test trên máy tôi vẫn đang chạy phiên bản cũ với cùng token như trên Pi | Dừng tiến trình ở máy | Bài học: một token, một tiến trình; các bước deploy trong devlog dặn dừng bản chạy ở máy |
| Một file cache build TypeScript nằm trong repository | git add -A đã nhặt tsconfig.tsbuildinfo sau một lần kiểm tra kiểu | Bỏ track nó và ignore *.tsbuildinfo | git status sạch sau khi build |
| Trong menu role chọn một, bấm một role bot không cấp được lại gỡ mất role người đó đang có | Thao tác đổi gỡ role cũ trước khi thêm role mới | Thêm trước, chỉ gỡ các role khác nếu thêm thành công | review-engage.test.js: thêm thất bại thì role cũ vẫn còn |
| Một server gỡ bot để lại trạng thái cũ trong bộ nhớ (phiên voice, cài đặt hoạt động đã cache) và không được tính là đã rời | Không có gì lắng nghe sự kiện guild bị xoá | Một handler guildDelete quên trạng thái đó, đếm lượt rời, và bỏ qua sự cố mất kết nối (available: false) | review-engage.test.js gọi nó theo cả hai cách |
| Một khoản thanh toán không chuyển thành gói được lại bị đánh dấu đã trả mà không cấp gì | Việc lật trạng thái và việc cấp là hai bước | Dấu đã trả và license được ghi trong một transaction, nên cấp thất bại sẽ rollback dấu | review-platform.test.js: đơn vẫn ở trạng thái chờ và không có license nào |
| Gói trả một lần "Dựng giúp" cấp số ngày lưu trên dòng đơn hàng | Ghi nhận thanh toán dùng order.days, thứ không có ý nghĩa với gói trả một lần | Luôn dùng bảy ngày của chính sản phẩm | Test với một dòng đơn hàng ghi 365 |
| Một link payOS vẫn trả được sau khi bot đã ngừng theo dõi đơn của nó | Link không mang hạn nào gắn với khung 35 phút mà bot theo dõi một đơn | Link được tạo với hạn nằm trong khung đó | Test đọc expiredAt trong body của request |
| Hai người mua trong cùng một giây có thể nhận cùng mã đơn payOS | Mã là thời gian tính bằng giây cộng ba chữ số ngẫu nhiên | /mua chọn mã khác khi mã đã bị dùng | Test ép xảy ra trùng |
| Một server có thể chất đống link thanh toán đang mở | Không giới hạn số đơn đang chờ | MAX_PENDING_PER_GUILD và một lời từ chối rõ ràng | Test tạo nhiều hơn giới hạn một đơn |
| Một role mất Administrator và được thêm một quyền khác chỉ ghi log một trong hai | Phép so sánh được tính từ một tập quyền coi Administrator là tất cả | So sánh các bit thô, nên cả hai thay đổi đều được liệt kê | review-security.test.js |
Sau một lần /ban thất bại, lần ban thật tiếp theo với người đó bị thiếu trong log | Dấu "bot đã thực hiện lần ban này" được ghi trước lời gọi và vẫn còn sau khi thất bại | Dấu chỉ được giữ khi ban thành công | Test kiểm tra không còn dấu sau khi thất bại |
| Một lần kick mà Discord sẽ từ chối vẫn gửi thông báo cho thành viên | Thông báo đi trước (sau đó không còn server chung) và lời từ chối chỉ biết được khi gọi | Kiểm tra kickable trước khi gửi bất cứ gì | Test: không tin nhắn, không kick, một câu trả lời |
| Một thao tác kiểm duyệt hay khoá server chậm hiện "interaction failed" | Sửa nhiều kênh, hoặc thao tác qua Discord, có thể lâu hơn 3 giây mà Discord cho câu trả lời đầu tiên | Command defer trước rồi hoàn tất bằng editReply | Test kiểm tra thứ tự defer, thao tác, sửa |
| Khoá server thất bại giữa chừng với bot chỉ có Manage Channels | Sửa permission overwrite của kênh còn cần Manage Roles | startLockdown yêu cầu cả hai và nêu tên quyền còn thiếu | review-security.test.js |
| Xoá role tích hợp của một bot bị tính vào một đợt xoá hàng loạt | Role biến mất cùng bot của nó, và audit log ghi tên người khác | Bộ đếm chống nuke bỏ qua role được quản lý | Test với một role được quản lý |
| Trang cuộn ngang khoảng 50 px trên desktop | Con dấu "đã duyệt" bắt đầu ở mức phóng to 2,4 lần bên trong cảnh được ghim, và hộp đã biến đổi của nó bị tính là phần tràn cuộn được | overflow-x: clip trên cảnh và trên hero | Script trình duyệt headless so scrollWidth với clientWidth trên mọi trang |
Quyết định thiết kế và các phương án khác
| Quyết định | Phương án tôi đã cân nhắc | Vì sao chọn cái này |
|---|---|---|
SQLite qua node:sqlite | Giữ file JSON; Postgres; better-sqlite3 | JSON không làm được bộ đếm nguyên tử hay truy vấn. Postgres là thêm một service phải chạy trên Pi cho ba bảng nhỏ. better-sqlite3 cần build native trên arm64. Module có sẵn không có dependency nào |
fetch gọi REST API | SDK chính thức | Một nhóm endpoint, dễ giả lập trong test, bớt một dependency phải kiểm tra |
| Bản thiết kế trong bộ nhớ với TTL 15 phút | Một bảng blueprints | Chúng là object nháp. Khởi động lại chỉ tốn một lần chạy lại lệnh. Một bảng thêm việc dọn dẹp và chuyển đổi mà không đem lại giá trị lâu dài |
| Mã kích hoạt thủ công | Stripe, PayOS hoặc SePay với webhook | Cổng thanh toán cần endpoint công khai, kiểm tra chữ ký và đối soát. Khi chưa có khách, thanh toán thủ công là cách rẻ hơn để biết có ai chịu trả tiền không |
| Một bot dùng chung tính tiền theo server | Mỗi khách một bot riêng (white label) | Một tiến trình để chạy và cập nhật. Bot riêng cho từng khách nhân lên số token, container và công hỗ trợ |
| Gemini gói miễn phí | Một model hoặc nhà cung cấp trả phí | Ở giai đoạn này nó không tốn gì. Các đánh đổi (hạn mức thấp dùng chung, điều khoản sử dụng dữ liệu) được kiểm soát bằng giới hạn và được công khai trên trang quyền riêng tư |
| Từ điển có kiểu cho ngôn ngữ | Một thư viện i18n | Hai ngôn ngữ, trang tĩnh, kiểm tra khoá lúc biên dịch, không có dependency lúc chạy |
| Gói suy ra từ license | Một cột plan được job cập nhật | Không có gì phải hết hạn, không có gì lệch, một truy vấn |
| Administrator khi mời | Một tập quyền hẹp | Bot đăng vào các kênh chỉ đọc nó tạo ra và tạo role; một tập quyền hẹp có thể hỏng theo những cách khó thấy. Code vẫn kiểm tra và nêu tên quyền còn thiếu |
| Một bản port phép gộp trong trang web, được test đối chiếu với bot | Tính trước cả 561 plan; import code của bot vào trang; gọi một API | Plan tính trước quá lớn cho một trang. Trang chỉ deploy từ web/, nên không thể import từ src/. Một API thêm một server cho một trang tĩnh. Một bản port nhỏ với test tương đương trên mọi tổ hợp tốn khoảng 40 dòng và không thể lệch âm thầm |
| Bot tự hỏi payOS | Một webhook trên serverless function; một tunnel công khai tới Pi | Không cần địa chỉ công khai, không có endpoint để tấn công, và mọi secret ở lại trên một máy. Cái giá là độ trễ tối đa 30 giây |
| Mức hài đổi câu chữ, không đổi cấu trúc | Một bộ theme riêng cho mỗi mức | Builder idempotent theo tên, nên cấu trúc giống hệt nghĩa là dựng lại ở mức khác không nhân đôi thứ gì, và chỉ có một bản của mỗi kênh phải bảo trì |
| Khôi phục chỉ tạo | Khôi phục thành bản sao chính xác, có cả xoá | Xoá dựa trên một file là việc nguy hiểm nhất một bot có thể làm. Tạo những gì còn thiếu thì lặp lại an toàn và dễ hoàn tác bằng /nuke |
| Stripe Checkout do bot tự hỏi, đặt cạnh payOS | Webhook của Stripe; chỉ payOS; PayPal | payOS cần tài khoản ngân hàng Việt Nam, thứ chủ bot không có, nên thẻ qua Stripe trở thành cách trả tiền chính. Tự hỏi giữ được quy tắc không có endpoint công khai. Một session chỉ được tính khi mã tham chiếu đơn, số tiền và loại tiền khớp với đơn, nên một id bị nhầm không thể bật một gói. payOS vẫn dành cho người mua ở Việt Nam |
Component định tuyến theo tiền tố customId | Một thư viện router tổng quát | Khoảng mười dòng, và tiền tố cho thấy rõ ai sở hữu mỗi id |
| Chống raid từ thông báo tham gia của Discord | Privileged intent GuildMembers; hỏi danh sách thành viên định kỳ | Intent đó cần Discord duyệt khi vượt 100 server và cho bot nhiều hơn mức nó cần rất nhiều. Thông báo đã mang ID thành viên và là cùng tín hiệu mà luồng chào mừng dùng. Cái giá là server tắt thông báo đó thì không theo dõi được theo cách này |
| Khoá server lưu những gì nó đã thay đổi vào tài liệu cài đặt, trước khi thay đổi | Giữ trạng thái trong bộ nhớ; snapshot toàn bộ server | Bộ nhớ mất khi crash, mà đó chính là lúc vẫn phải hoàn tác việc khoá. Một snapshot đầy đủ khôi phục nhiều hơn những gì việc khoá đã thay đổi và có thể ghi đè chỉnh sửa của admin trong lúc khoá. Chỉ ghi những kênh bị đụng tới, và chỉ khôi phục những kênh vẫn như lúc bị khoá, là cách nhỏ nhất mà vẫn đảo ngược được |
| Chống nuke gỡ role nguy hiểm, không ban | Ban thủ phạm; kick; chỉ cảnh báo | Một lần ban dựa trên phỏng đoán là không thể cứu vãn khi nó sai (một đợt dọn dẹp hợp lệ). Gỡ role chặn được thiệt hại, admin có thể đảo ngược, và cảnh báo liệt kê chính xác những role nào bị lấy, role nào được giữ và vì sao |
| Xp từ sự kiện tin nhắn không có nội dung | Đọc nội dung để tính trọng số tin nhắn theo độ dài; đếm reaction | Nội dung cần privileged intent, và tính theo độ dài là thưởng cho spam. Cooldown và giới hạn ngày thưởng cho sự hiện diện đều đặn thay vào đó, và chỉ cần tác giả và kênh |
| Xp giữ trong bộ nhớ và ghi xuống theo lô các phần tăng | Ghi ở mỗi tin nhắn; một cache bên ngoài | Mỗi tin nhắn một lần ghi chính là tải khiến Pi vất vả ở một server đông. Cộng phần tăng thay vì thay tổng giúp lần ghi an toàn với các dòng bị xoá ở giữa. Cái giá là mất tối đa vài giây xp khi crash cứng, điều không quan trọng với điểm số |
| Bình chọn ẩn danh ngay từ thiết kế | Hiện ai đã bầu | Bình chọn ẩn danh nhận được câu trả lời thật lòng và tin nhắn không bao giờ cần một danh sách tên có thể ping hoặc làm lộ. Phiếu vẫn được lưu theo từng người để một người không thể bầu hai lần |
| Đóng giveaway và bình chọn bằng một lần lật trạng thái nguyên tử | Một khoá trong bộ nhớ; tin vào timer | Chỉ lời gọi đổi đang chạy thành đã đóng mới được đăng kết quả, nên khởi động lại, bấm đúp và job chạy đua với nút huỷ đều chỉ cho ra một kết quả. Đó là cùng chốt chặn với thanh toán |
| Một bảng từ khoá thuần để gợi ý theme trong phần thiết lập | Hỏi Gemini | Phần thiết lập nên chạy được khi không có key, không có hạn mức và không phải chờ, và vài chục từ khoá là đủ để đúng phần lớn thời gian. Nhà thiết kế AI vẫn chỉ cách một lệnh cho ai muốn dùng |
| Mỗi server một tài liệu cài đặt chia section, dùng chung cho command và dashboard | Mỗi tính năng một bảng | Mỗi section một hàm chuẩn hoá làm sạch giá trị theo cùng một cách dù nó đến từ đâu, và tính năng mới chỉ thêm một section thay vì một lần chuyển đổi schema |
| Giá $3.99 và $7.99 với một năm bằng giá mười tháng, và một tuần trả một lần giá $4.99 | Giữ $9.99 và $19.99; giảm giá theo phần trăm; một gói miễn phí không giới hạn | Chủ một server nhỏ so nó với một ly cà phê, không phải với tiền lương. Hai tháng miễn phí dễ hiểu hơn một tỷ lệ phần trăm. Gói trả một lần phục vụ những người chỉ cần thiết lập server một lần và sẽ không bao giờ đăng ký thuê bao |
| Gói miễn phí có sẵn các phần bảo vệ cơ bản (thiết lập, kiểm tra sức khoẻ, chống raid, mod log, lệnh kiểm duyệt, bình chọn) | Đặt phần bảo vệ sau Pro | Một đợt raid đánh vào server miễn phí cũng mạnh y như vậy, và một bot để server miễn phí bị phơi ra là một quảng cáo tồi. Pro bán những thứ giúp cộng đồng lớn lên (cấp độ, giveaway, ticket, trộn theme, trợ lý AI) và lớp chống nuke |
| Số đếm phễu ẩn danh trong một bảng | Một dịch vụ phân tích bên thứ ba | Câu hỏi chỉ là "server rơi rụng ở đâu". Một server ID, một loại và một thời điểm là trả lời được, không lưu gì cá nhân và không có script nào chạy ở đâu cả |
| Từ cấm tuỳ chỉnh qua rule từ khoá gốc của Discord | Đọc mọi tin nhắn và so khớp trong bot; kiểm tra bằng AI từng tin nhắn | Đọc tin nhắn cần privileged content intent, thứ bot này chưa bao giờ dùng, và nó sẽ đưa mọi tin nhắn của mọi khách đi qua tiến trình của tôi. Rule gốc chặn trước khi ai thấy tin nhắn và không tốn gì để chạy. Cái giá là các giới hạn của chính Discord trên danh sách |
| Kênh thống kê làm mới mỗi 10 phút | Cập nhật mỗi lần có người vào và rời; cập nhật chậm hơn theo giờ | Discord cho phép hai lần đổi tên mỗi kênh mỗi mười phút. Cập nhật theo sự kiện chạm giới hạn đó trong vòng một phút ở server đông. Mỗi giờ thì có cảm giác cũ. Mười phút với đồng hồ theo từng kênh thì không bao giờ phải xếp hàng lời gọi |
| Tin nhắn hẹn giờ và nhắc việc ghi trạng thái kế tiếp trước khi gửi | Gửi trước, rồi cập nhật | Một lần crash giữa gửi và cập nhật sẽ lặp lại tin nhắn ở lần khởi động tiếp theo. Tin nhắn lặp lại tệ hơn tin bị lỡ, nên dòng được giành bằng một update có điều kiện và chỉ bên thắng mới gửi |
| Phòng tạm chỉ bị xoá khi chúng là một dòng đã ghi | Xoá mọi kênh voice trống trong category; so khớp theo tên | Tên thì ai cũng copy được và category còn chứa những thứ khác. Một dòng là bằng chứng bot đã tạo nó |
| Số phiếu góp ý tính lại từ các dòng | Bộ đếm trên tin nhắn hoặc trên dòng góp ý | Bộ đếm sẽ lệch sau một lần crash hay bấm đúp. Đếm dòng rẻ ở quy mô này và không thể sai |
/xoadulieu qua một module xoá dữ liệu duy nhất | Mỗi tính năng một lệnh xoá, gọi từ command | Mỗi bảng mới sẽ là thêm một chỗ dễ quên. Module xoá liệt kê các bảng trong một mảng, xoá dòng con trước, trong một transaction, và một test điền dữ liệu vào các bảng cho hai server rồi kiểm tra chỉ một server bị xoá |
Một route /status công khai, được trang web đọc trong trình duyệt | Một dịch vụ trạng thái; trang web gọi bot từ server của nó | Route không mang gì đáng giấu, nên có thể cho phép mọi origin. Đọc nó trong trình duyệt giữ cho trang web tĩnh, và khi lỗi có thể xuống cấp thành một dòng "không kiểm tra được" đơn giản |
Việc tiếp theo và các giới hạn đã biết
- Công cụ admin và dashboard được kiểm tra với đồ giả, không phải đăng nhập thật. Luồng chào mừng dựa vào việc thông báo tham gia của Discord xuất hiện trong kênh hệ thống; server tắt thông báo đó sẽ không có lời chào tự động.
- Chưa chạy end to end với gateway thật. Các handler command và trình chỉnh sửa được chạy trong test với interaction giả tối giản (ví dụ
/build,/mua, các lệnh sao lưu và theme, và nút lưu của trình chỉnh sửa), và mọi thứ ra quyết định đều là module thuần. Nhưng không có gì trong CI nói chuyện với Discord, nên hình dạng chính xác của một vài lời gọi discord.js (scheduled event, tuỳ chọncolorscủa role, autocomplete, bigint của quyền) chỉ được kiểm tra với đồ giả. Một bộ fake gateway dùng chung sẽ lấp được khoảng trống đó. - Bản thiết kế mất khi khởi động lại, theo thiết kế (xem Giai đoạn 11).
- Phần tích hợp thanh toán được viết theo tài liệu của payOS và test với một stub, không phải tài khoản thật. Nó cần một lần thanh toán nhỏ thật trước khi được tin tưởng. Nó hỏi mỗi 30 giây thay vì nhận webhook, không xác minh chữ ký trên câu trả lời của payOS, và dựa vào tỷ giá đô la sang đồng mà tôi cập nhật bằng tay. Việc gói hết hạn giờ đã được nhắc trong server (giai đoạn 28).
- Giới hạn game nằm trong bộ nhớ. Giới hạn điểm game mỗi ngày và các ván game đang mở sẽ reset khi bot khởi động lại, điều đó ổn với điểm cho vui nhưng sẽ không ổn với tiền.
- Ngân hàng câu đố có 34 câu hỏi. Đủ để bắt đầu, nhưng sẽ lặp lại trong vòng vài tuần nếu chơi hằng ngày.
- Gemini gói miễn phí là hạn mức dùng chung với điều khoản sử dụng dữ liệu. Các giới hạn bảo vệ nó, nhưng một lượng khách thật sẽ cần gói trả phí và một ghi chú về xử lý dữ liệu.
- Một tiến trình, SQLite, không sharding. Một tiến trình bot với cơ sở dữ liệu một bên ghi là thừa đủ cho vài chục server. Quá vài nghìn guild thì Discord yêu cầu sharding, và khi đó một database server sẽ đáng có.
- Timer cập nhật không được cài bởi quá trình deploy. Nó cần
sudocó mật khẩu, nên chủ bot chạy ba lệnh một lần. - Cơ sở dữ liệu test ở máy và cơ sở dữ liệu trên Pi là riêng biệt. License tạo ra khi test trên máy tôi không tồn tại trên Pi.
- Bộ lọc từ là tấm lưới cuối cùng, không phải một hệ thống kiểm duyệt.
- Các kiểm tra bố cục là script làm tạm. Một lượt chạy Playwright được commit vào CI sẽ bắt được tràn và lỗi console ở mỗi lần push.
- Tính năng bảo mật được chạy với đồ giả, không phải một đợt raid thật. Bộ đếm raid, khoá server, khôi phục và chống nuke là thuần hoặc được chạy với guild giả, nhưng chưa ai raid một server thật khi có chúng. Hình dạng của các mục audit log và thời điểm của các sự kiện theo sau một lần xoá được kiểm tra theo cách tôi hiểu tài liệu.
- Chống raid phụ thuộc vào thông báo tham gia. Một server đã tắt "gửi tin nhắn khi có người tham gia" là vô hình với nó, và số lượt tham gia trong báo cáo hằng tuần ở đó cũng thấp hơn. Báo cáo có nói điều này ở phần chân.
- Mod log không thấy được timeout của người khác. Discord chỉ gửi chúng cho bot có thêm một quyền, nên chỉ những lần timeout thực hiện qua
/timeoutmới được ghi lại, và log có nói điều đó. - Lịch sử kiểm duyệt theo từng server và do moderator gõ. Lý do được lưu đúng như đã viết (giới hạn 300 ký tự) và chỉ hiển thị cho staff, nhưng đó là dữ liệu cá nhân của thành viên, và
/xoadulieuxoá chúng cùng phần dữ liệu còn lại của server (xem Giai đoạn 31 và 36). - Rate limit cho phòng và đổi tên nằm trong bộ nhớ. Cooldown phòng mười giây và đồng hồ đổi tên mười phút reset khi khởi động lại. Điều đó an toàn (không có gì bị đổi tên trừ khi chữ thay đổi, và số phòng vẫn bị giới hạn ở 50 mỗi server) nhưng nó không phải giới hạn bền vững.
- Nhắc việc và tin nhắn hẹn giờ được test với đồng hồ giả và object Discord giả. Câu chữ của lỗi tin nhắn riêng mà Discord trả về khi DM bị đóng, và giới hạn chính xác của rule từ khoá gốc, được đọc từ tài liệu và chưa kiểm tra với server thật.
- Danh sách bảng cần xoá được viết bằng tay. Test điền dữ liệu vào những bảng nó biết cho hai server rồi kiểm tra kết quả, nên một bảng thêm sau này mà quên đưa vào danh sách sẽ không bị phát hiện cho tới khi ai đó thêm nó vào test.
- Xp và trạng thái voice được giữ trong bộ nhớ giữa các lần ghi xuống. Crash cứng có thể mất vài giây điểm. Thông báo lên cấp dùng cài đặt đã cache, được làm mới mỗi 30 giây.
- Một tiến trình, một map cooldown. Bộ đếm raid, bộ đếm nuke và cooldown xp nằm trong bộ nhớ của bot, nên chúng reset khi khởi động lại và sẽ cần một kho lưu dùng chung nếu có nhiều hơn một tiến trình.
- Gợi ý theme của trình thiết lập là một bảng từ khoá, nên những mô tả lạ sẽ rơi về một theme mặc định an toàn (và nó nói là mình đoán).
- Phễu đếm server, không đếm người, và không nói gì về lý do một server dừng lại. Nó cho tôi biết phải nhìn vào đâu, không phải sửa gì.
- Route trạng thái không có lịch sử. Nó cho biết bot có đang chạy lúc này không. Một trang hiển thị quá khứ sẽ cần thứ gì đó ghi lại.
- Giá là một thử nghiệm. Chúng được chọn để tìm hiểu xem giá thấp hơn có chuyển đổi tốt hơn không, và phễu là cách tôi sẽ biết. Code thanh toán vẫn chưa gặp một khoản thanh toán thật nào.