Bỏ qua

7. Discord Bot async

lpp# Bài 7: discord.py + Async Patterns trong Bot

Tham khảo trong project: bot/src/scheduler.py, bot/src/main.py, bot/src/notifier.py


1. discord.py thực chất là gì?

discord.py là một thư viện bất đồng bộ (async) sinh ra để giúp bạn làm việc với Discord API. Hãy coi nó như một "người quản gia" mẫn cán: nó âm thầm bao thầu hết những thứ phức tạp bên dưới như duy trì kết nối WebSocket hay gọi HTTP API, rồi gói gọn lại thành các hàm coroutines của Python. Bạn chỉ việc gọi hàm là xong, không cần đau đầu xử lý tầng mạng.

# bot/src/main.py
import discord

# Intents = Khai báo cho Discord biết bot của bạn muốn "hóng" những sự kiện gì
intents = discord.Intents.default()
intents.voice_states = True   # Bật cờ này để hớt lẻo được việc user join/leave kênh thoại

client = discord.Client(intents=intents)

@client.event
async def on_ready():
    """Hàm này sẽ tự chạy khi bot kết nối thành công vào server Discord."""
    print(f"Bot đã online: {client.user}")

client.run("TOKEN")

2. Kiến trúc hướng sự kiện (Event-Driven Architecture)

discord.py vận hành theo kiểu "ngồi rình" sự kiện (event-driven model). Thay vì chạy tuần tự từ trên xuống dưới, bot của bạn sẽ nằm vùng, có biến gì xảy ra từ phía Discord thì nó mới kích hoạt đoạn code tương ứng:

# bot/src/main.py
@client.event
async def on_ready():
    """Khi bot vừa bừng tỉnh (online)."""
    for guild in client.guilds:
        await sync_guild_channels(guild)  # Kéo danh sách kênh về lưu tạm vào SQLite

    # Khởi động bộ đếm nhịp (scheduler loop)
    client.scheduler = Scheduler(client, ...)
    client.scheduler.start()

@client.event
async def on_guild_join(guild: discord.Guild):
    """Khi bot được ai đó bế vào một server mới."""
    await sync_guild_channels(guild)

# Nếu bạn thích chơi hệ lệnh gạch chéo (slash commands v2):
@client.event
async def on_interaction(interaction: discord.Interaction):
    ...

Tất cả các event này đều là async def — chúng chạy mượt mà bên trong event loop (vòng lặp sự kiện) của discord.py mà không giẫm chân lên nhau.


3. discord.ext.tasks — Vòng lặp hẹn giờ chuyên nghiệp

Thay vì viết hàm while True lôm côm, thư viện cung cấp sẵn bùa chú @tasks.loop() để bạn tạo các vòng lặp chạy ngầm:

# bot/src/scheduler.py
from discord.ext import tasks

class Scheduler:
    def __init__(self, client, ...):
        # Tạo một cái máy bơm, cứ 2 giây lại bơm 1 nhịp (gọi hàm tick)
        self._task = tasks.loop(seconds=2)(self.tick)
        self._task.before_loop(self._before_loop)  # Cài cắm chạy 1 lần dọn dẹp trước khi bơm

    def start(self) -> None:
        if self._task.is_running():
            return
        self._task.start()

    async def _before_loop(self) -> None:
        """Đoạn này chỉ chạy đúng 1 lần trước khi vòng lặp đầu tiên bắt đầu."""
        await self.client.wait_until_ready()  # Chờ cho bot thực sự tỉnh táo (connected)
        await self.state_repo.clear()          # Dọn dẹp tàn dư DB lỡ bot bị crash từ hôm qua

    async def tick(self) -> None:
        """Trái tim của hệ thống: Đập mỗi 2 giây một lần."""
        try:
            await self._drain_commands()   # Check xem có lệnh nào admin vừa bấm tay không
            await self._check_schedule()   # Check xem đã tới giờ tự động phát nhạc chưa
        except _TICK_RECOVERABLE as e:
            logger.warning("Lỗi nhẹ ở nhịp đập (sẽ tự hồi phục): %s", e)
            # Nuốt lỗi nhẹ ở đây → vòng lặp không bị vỡ, 2s sau đập tiếp
            # Lưu ý: discord.ext.tasks có khả năng tự khởi động lại loop nếu lỡ văng lỗi ra ngoài

Tại sao không xài asyncio.sleep() trong vòng while cho lẹ?

# ❌ Ngây ngô
async def my_loop():
    while True:
        await do_something()
        await asyncio.sleep(2)  # Bắt cả cái event loop đứng ngáp 2s — nó sẽ bỏ lỡ các event khác!
# ✅ Chuyên nghiệp — để discord.ext.tasks lo việc đếm giờ
@tasks.loop(seconds=2)
async def my_loop():
    await do_something()

Món bảo bối này tích hợp cực sâu với event loop — nó nghỉ 2 giây nhưng KHÔNG chặn đầu chặn đuôi các sự kiện khác của Discord.


4. Bùa chốt cửa (Async Mutex) — Tránh kẹt xe (Race Condition)

Bot của chúng ta có thể nhận lệnh bằng tay (ai đó ấn nút trên web) ĐỒNG THỜI với việc đến giờ phát nhạc tự động. Phải làm sao để chặn việc bot "phân thân" phát 2 bài nhạc cùng một lúc?

Nhìn thử cái bẫy kẹt xe này:

# ❌ Cạm bẫy Race condition
async def check_and_trigger():
    if not currently_playing():  # Vừa liếc thấy "đang rảnh"
        # ... Khổ nỗi 50 mili-giây sau, luồng tự động cũng liếc vào và thấy "đang rảnh"
        await start_playback()   # Xong phim! 2 luồng cùng nhào vô phát nhạc!

Giải pháp: Quăng chốt (SYNC) khóa cửa ngay trước chữ await đầu tiên

# bot/src/scheduler.py
class Scheduler:
    _current_entry_id: int | None = None   # Biến cờ (Mutex trên RAM)

    async def _maybe_start(self, entry: dict) -> None:
        """Kích hoạt phát nhạc nếu đến giờ và loa đang rảnh."""
        if self._current_entry_id is not None:
            return   # Cửa đã khóa (Mutex locked) — có người đang hát rồi

        # TUYỆT CHIÊU: Giật lấy chốt cửa NGAY LẬP TỨC (hành động đồng bộ) trước bất kỳ chữ await nào
        self._current_entry_id = entry["id"]

        # Ổn định rồi mới thả cho chạy ngầm
        self._spawn_playback(entry)

    def _spawn_playback(self, entry: dict) -> None:
        """Đẻ ra một nhánh chạy ngầm và nắm chặt dây diều (strong reference)."""
        task = asyncio.create_task(self._start_playback(entry))

        # QUAN TRỌNG: asyncio vốn rất vô tình, nó chỉ cầm dây diều lỏng (weak reference)
        # → Trình dọn rác (GC) có thể bay ra cắt đứt task giữa chừng!
        self._pending_tasks.add(task)          # Nắm dây diều thật chặt (strong reference)
        task.add_done_callback(self._pending_tasks.discard)  # Dặn nó: "bay xong thì tự thả dây ra nhé"

    async def _start_playback(self, entry: dict) -> None:
        try:
            await self._execute_playback(entry)
        finally:
            self._current_entry_id = None   # Hát xong nhớ mở chốt cửa (Release mutex)

Tại sao lúc khóa cửa lại là hàm đồng bộ (Không xài async)?

Event loop của Python mang bản chất hợp tác trên 1 luồng duy nhất (single-threaded cooperative multitasking). Code đồng bộ (đoạn không có chữ await) sẽ được chạy tuốt tuồn tuột một mạch, không có bố con thằng nào được chen ngang. Thế nên khi bạn gán self._current_entry_id = entry["id"], nó an toàn tuyệt đối.

Chỉ khi code va phải chữ await, event loop mới có cơ hội "lách" sang phục vụ coroutine khác → Đó mới là khe hở tạo ra kẹt xe. Bịt nó lại từ trước là xong!


5. asyncio.create_task() vs await

# Dùng await — Đứng chết trân chờ xong mới đi tiếp
await self._start_playback(entry)   # ❌ Trái tim (tick loop) bị đứng tim chờ hát xong bài 60 phút!

# Dùng create_task — Đẻ ra thằng đệ chạy ngầm, mình đi làm việc khác
asyncio.create_task(self._start_playback(entry))  # ✅ Trái tim vẫn đập tiếp mỗi 2s

Nhớ nhé: create_task là ném việc vào hàng đợi của event loop để chạy đồng thời (concurrently) mà không bắt người gọi nó phải đứng chờ.


6. Voice Client — Kéo loa ra phát nhạc

async def _acquire_voice_client(self, channel: discord.VoiceChannel) -> discord.VoiceClient:
    """Xông vào kênh thoại, nếu đang dở dang ở kênh khác thì ngắt rồi nhảy qua kênh mới."""
    existing_vc = channel.guild.voice_client

    if existing_vc is not None:
        if existing_vc.channel.id == channel.id:
            return existing_vc  # May quá đang ở đúng kênh rồi
        await existing_vc.disconnect(force=True)  # Đá văng khỏi kênh cũ

    return await channel.connect()  # Trịnh trọng bước vào kênh mới

async def _run_playlist(self, vc: discord.VoiceClient, files: list[Path], stop_pred) -> None:
    """Nhai lần lượt từng file âm thanh."""
    for filepath in files:
        if stop_pred():   # Có ai ấn nút Dừng khẩn cấp (_stop_requested) không?
            break

        # Chìa khóa thần kỳ: Đọc file → nhờ FFmpeg giải mã → biến thành PCM ném cho Discord
        source = discord.FFmpegPCMAudio(str(filepath))
        vc.play(source)

        # Chờ ca sĩ hát xong
        while vc.is_playing() and not stop_pred():
            await asyncio.sleep(0.5)

        if stop_pred():
            vc.stop()
            break

        # Nghỉ xả hơi một chút giữa 2 bài hát
        await asyncio.sleep(TRACK_GAP_SECONDS)

7. Bắt lỗi kiểu "nhà giàu" — Narrow Exception

# bot/src/scheduler.py

# Khoanh vùng rõ: Bệnh nào trị được thì cho vào list này (Log lại rồi chạy tiếp)
_TICK_RECOVERABLE = (
    aiosqlite.Error,          # Rớt mạng DB tạm thời
    OSError,                  # Lỗi đọc đĩa, file mất tích
    discord.HTTPException,    # API Discord dỗi, rate limit các kiểu
    discord.ClientException,  # Trục trặc cáp voice
    ValueError,               # Dữ liệu hỏng nhẹ (sai định dạng giờ...)
)

async def tick(self) -> None:
    try:
        await self._drain_commands()
        await self._check_schedule()
    except _TICK_RECOVERABLE as e:
        logger.warning("Bệnh vặt ở Tick, tự lành được: %s", e)
        # Cho vòng lặp chạy tiếp — 2s sau sẽ thử lại
    # LƯU Ý CHẾT NGƯỜI: KeyError, TypeError, AttributeError TUYỆT ĐỐI KHÔNG BẮT
    # → Đây là lỗi do dev code ngu (bug logic) → Phải cho nó nổ banh xác (crash loud) để còn biết mà sửa!

Tại sao tuyệt đối không được hứng lỗi chung chung kiểu Exception?

# ❌ Cách code che giấu tội ác
try:
    await self._drain_commands()
except Exception:
    pass  # Nếu bạn lỡ gõ sai chính tả (AttributeError) nó cũng nuốt luôn! Fix bug mù mắt!

Nguyên tắc: Chỉ "nhân nhượng" bắt những lỗi mà bạn hiểu rõ ngọn ngành. Lỗi logic thì phải để ứng dụng sập, phát hiện càng sớm càng tốt.


8. Tắt máy thanh lịch (Graceful Shutdown)

async def shutdown(self) -> None:
    """Quy trình dọn dẹp đóng cửa tiệm êm ái — được gọi khi nhận lệnh tắt."""
    self._stop_requested = True    # Bấm còi báo động cho toàn bộ vòng lặp dừng lại

    # Rút phích cắm loa
    if self._current_vc and self._current_vc.is_playing():
        self._current_vc.stop()

    # Chờ mấy thằng đệ (background tasks) chạy ráng cho xong (nhưng cho nó tối đa 8s thôi)
    if self._pending_tasks:
        await asyncio.wait_for(
            asyncio.gather(*self._pending_tasks, return_exceptions=True),
            timeout=SHUTDOWN_TIMEOUT_SECONDS,
        )

    # Đóng cầu dao máy bơm (tick loop)
    self._task.cancel()

    # Quét dọn tàn dư trong DB
    await self.state_repo.clear()
# bot/src/main.py — Đón lõng tín hiệu SIGTERM (khi bạn gõ lệnh Docker stop)
import signal

def _setup_signal_handlers(client, loop):
    def _handle_signal():
        loop.create_task(client.close())  # Kích hoạt hàm on_close → dẫn tới scheduler.shutdown()

    try:
        # Unix/Linux: SIGTERM là lệnh tắt từ Docker, SIGINT là khi bạn ấn Ctrl+C
        loop.add_signal_handler(signal.SIGTERM, _handle_signal)
        loop.add_signal_handler(signal.SIGINT, _handle_signal)
    except NotImplementedError:
        # Windows chuối hơn, không hỗ trợ add_signal_handler
        signal.signal(signal.SIGTERM, lambda *_: loop.create_task(client.close()))

9. Biến SQLite thành Trạm trung chuyển (Message Queue)

Admin API (Người dùng bấm web) → INSERT vào bảng bot_commands → SQLite
Bot Tick (Tim đập mỗi 2s) ─── SELECT lôi ra đọc ─── Xử lý ─── DELETE xóa dấu vết

Bẫy kẹt xe (Concurrency gotcha): Kiến trúc này có 2 tay (Admin API + Bot) cùng đè ngửa 1 file SQLite ra để Đọc/Ghi. Mặc định SQLite xài luật rollback journal → Ông Ghi sẽ tát vỡ mặt ông Đọc, ông Đọc sẽ cắn vào tay ông Ghi → Tim của Bot sẽ đập loạn nhịp không đoán trước được. BẮT BUỘC phải bật bùa PRAGMA journal_mode = WAL cho cả 2 bên. Hãy xem lại Bài 4 §3 "Ép SQLite chạy theo ý mình" để biết cách gắn bùa.

# bot/src/scheduler.py
async def _drain_commands(self) -> None:
    """Rút cạn mọi yêu cầu đang nằm chờ trong queue."""
    async with self._db_session() as session:
        commands = await self.commands_repo.list_pending(session)

    for cmd in commands:
        try:
            await self._execute_command(cmd)
        except _TICK_RECOVERABLE as e:
            logger.warning("Thực thi lệnh %s xịt: %s", cmd["command"], e)
        finally:
            # Xóa sổ luôn, dù thành công hay sứt đầu mẻ trán — cấm retry để tránh lặp lệnh
            await self.commands_repo.delete(session, cmd["id"])

async def _execute_command(self, cmd: dict) -> None:
    match cmd["command"]:
        case "play":
            entry = await self.repo.get_by_id(session, cmd["entry_id"])
            await self._maybe_play_entry(entry)
        case "pause":
            if self._current_vc and self._current_vc.is_playing():
                self._current_vc.pause()
        case "resume":
            if self._current_vc and self._current_vc.is_paused():
                self._current_vc.resume()
        case "stop":
            self._stop_requested = True
            if self._current_vc:
                self._current_vc.stop()

10. Người đưa tin (Notifier) — Gửi xong là Quên (Fire and Forget)

# bot/src/notifier.py

async def send_reminder(channel: discord.TextChannel, entry: dict) -> None:
    """Loa làng báo trước khi bắt đầu vô tập."""
    await _send(channel, f"🧘 **{entry['name']}** sẽ bắt đầu sau {entry['remind']} phút...")

async def send_start(channel: discord.TextChannel, entry: dict, vc: discord.VoiceChannel) -> None:
    await _send(channel, f"🔔 **{entry['name']}** bắt đầu! Vào {vc.mention} để tham gia.")

async def send_end(channel: discord.TextChannel) -> None:
    await _send(channel, "✨ Buổi tập đã kết thúc. Hẹn gặp lại!")

async def _send(channel: discord.TextChannel, content: str) -> None:
    """Hàm nội bộ: Cố gửi tin, xịt thì ghi log nhưng cấm làm sập loa nhạc."""
    try:
        await channel.send(content)
    except discord.Forbidden:
        # Cấm ngôn: Bot chưa được cấp quyền ném tin nhắn vào kênh này
        logger.warning("Không có quyền gửi tin vào %s", channel.name)
    except discord.HTTPException as e:
        logger.warning("Gửi tin thất bại: %s", e)
    # Tuyệt kỹ: Bắt lỗi xong nuốt luôn (Không re-raise) → Tiếng nhạc vẫn sẽ cất lên cho dù chat bị lỗi.

Triết lý: Chức năng thông báo chỉ là "phụ gia" (best-effort) — tuyệt đối không được phép làm đứt gãy mạch máu chính (logic phát nhạc). Lỗi gửi tin? Cứ ghi log rồi đi tiếp.


Nguyên lý tổng quát

Kỹ thuật trong bài Nguyên lý (Bài 0) Chuyển giao đi nơi khác
@client.event async def on_ready IoC (Inversion of Control qua event hook) Mô hình kinh điển: EventEmitter của Node.js, hay tokio::select! của Rust.
Khóa chốt SYNC trước chữ await Điều phối hợp tác (Cooperative scheduling) Tái chế ở mọi ngôn ngữ dùng single-threaded event loop (JS, Python asyncio).
Khoanh vùng _TICK_RECOVERABLE La to với Lỗi Logic, Mềm mỏng với I/O Kỷ luật lập trình: Bắt lỗi phải nhắm đúng mục tiêu, cấm bắt Exception bừa bãi.
Giữ chặt dây diều _pending_tasks POLS (Tránh cái chết im lặng từ GC) Ghi nhớ: Task chạy ngầm mà không ai nắm lấy = sẽ bị Trình dọn rác thủ tiêu.
Xài thẳng SQLite làm Queue YAGNI (Đừng làm thừa) Mới có 1 con Bot + Polling 2s thì đẻ thêm Redis ra làm gì cho nhọc?
notifier.py Fire-and-forget SoC (Tách biệt mối quan tâm) Chia ranh giới cực nét giữa Tính năng Sinh tử (Critical) và Tính năng Nỗ lực (Best-effort).

Trọng tâm: Cooperative vs Preemptive Scheduling

Nếu bạn chỉ được nhớ 1 thứ ở bài này, hãy nhớ bảng so sánh dưới đây:

Đa luồng truyền thống (Preemptive) Bất đồng bộ (Cooperative)
Ai là người giật vô lăng? Hệ điều hành (Bất thình lình, lúc nào cũng được) Chính bạn (Chỉ khi nào bạn gõ chữ await)
Kẹt xe (Race condition) xảy ra lúc nào? Lúc nào cũng có thể Cực kỳ an toàn, chỉ hở sườn ngay tại chữ await
Có cần dùng Khóa (Lock)? Gần như mọi lúc đụng vào biến chung Chỉ dùng khi biến đổi trạng thái lướt qua chữ await
Điểm lợi Dev không cần quan tâm nhường đường (OS tự lo) Dễ đoán, dễ debug, kiểm soát hoàn toàn flow
Điểm hại Phải cắm Lock khắp nơi, tốn tài nguyên Phải khắc cốt ghi tâm: await là điểm bị chọc ngoáy

Tuyệt kỹ "Khóa chốt SYNC trước await" (mục 4) sinh ra là để tận dụng triệt để bản chất Cooperative. Mang chiêu này đi code đa luồng OS truyền thống là nát bét ngay!

Mở rộng: Mô hình Service chạy ngầm

Khung xương của con Bot này (vòng lặp tim đập + rút cạn lệnh + gửi thông báo phụ) chính là bộ khuôn mẫu tiêu chuẩn cho hàng loạt hệ thống thực chiến:

  • Worker xử lý nền (Giải pháp nhẹ hơn Celery) — quét hàng đợi, xử lý, báo cáo.
  • Não điều khiển IoT — Quét trạng thái cảm biến, ra lệnh bật/tắt rơ le.
  • Lõi Game Server — Đập nhịp game (tick), cập nhật trạng thái, phát sóng (broadcast) cho người chơi.
  • Bot Trade Coin — Quét biến động giá, phân tích, quăng lệnh mua/bán.

Tất cả đều xài chung 1 bài: Nhịp đập (tick) + Rút cạn lệnh (drain) + Vùng an toàn (critical section) + Tắt thanh lịch (graceful shutdown).

Khi nào thì lôi Async ra là tự rước họa vào thân?

Đừng cuồng Async. Hãy xem lại Bài 10 §5. Tóm gọn lại: Gặp tác vụ nhai CPU (render video, tính toán AI, train model), hay khi team của bạn quá gà mờ về Async → Quay xe về code Đồng bộ truyền thống (Sync) cho lành.


Bài tập áp dụng

Thử sức: Tự viết một con Bot có tính năng "Đồng hồ báo thức":

import discord
from discord.ext import tasks

client = discord.Client(intents=discord.Intents.default())
greeted_today = set()

@tasks.loop(minutes=1)
async def check_greeting():
    """Trở thành người báo thức mẫn cán vào đúng 9:00 sáng mỗi ngày."""
    from datetime import datetime
    now = datetime.now()

    if now.hour == 9 and now.minute == 0:
        today = now.date().isoformat()
        if today not in greeted_today:
            channel = client.get_channel(YOUR_CHANNEL_ID)
            if channel:
                try:
                    await channel.send("☀️ Chào buổi sáng tinh mơ!")
                except discord.HTTPException as e:
                    print(f"Tin nhắn tịt ngòi: {e}")
            greeted_today.add(today)

@client.event
async def on_ready():
    check_greeting.start()

client.run("TOKEN")

Hãy tự nhẩm trong đầu:

  1. Tại sao phải gán cái biến _current_entry_id theo kiểu thường (synchronously) nằm chình ình ngay TRƯỚC chữ await? Việc này cứu chúng ta khỏi thảm họa gì?
  2. Khi nào thì dùng asyncio.create_task() và khi nào thì đắp chữ await vào?
  3. Sẽ ra sao nếu bạn ngứa tay đi bắt mấy lỗi kiểu KeyError, AttributeError ngay bên trong vòng lặp tick loop?
  4. Đẻ task ra bằng asyncio.create_task() thôi là chưa đủ, tại sao phải lấy dây xích trói nó lại bằng cái list self._pending_tasks?