DEV Community

EME GUG
EME GUG

Posted on

IRCv3 in Practice: Build a Modern IRC Bot with Capability Negotiation, SASL and Message Tags in Python

Hôm nay IRCv3 lên top Hacker News với hơn 300 điểm. Nhiều người nghĩ IRC là giao thức của thập niên 90 và đã "chết" từ khi có Slack hay Discord. Thực tế thì cộng đồng open source vẫn dùng IRC rất nhiều: Libera.Chat, OFTC, các kênh của Debian, Arch, Python... Điều ít người biết là IRC đã được "hiện đại hóa" khá kỹ qua bộ spec IRCv3. Nó có message tags, timestamp từ phía server, SASL authentication, chat history, và không làm mất backward compatibility với các client cũ.

Mình từng viết bot IRC kiểu cũ: mở socket, gửi NICK/USER, rồi parse bằng regex. Bot chạy được, nhưng gặp đủ chuyện: identify với NickServ bị race condition, log sai giờ vì dùng giờ local, không phân biệt được tin nhắn do chính mình gửi. IRCv3 giải quyết gọn mấy vấn đề này. Bài này mình sẽ đi từ raw protocol tới một bot Python asyncio khoảng 60 dòng, không dùng thư viện ngoài.

1. IRCv3 là gì, và vì sao đáng học

IRCv3 không phải giao thức mới. Nó là một tập extension gắn thêm lên RFC 1459/2812. Cơ chế cốt lõi là capability negotiation (CAP): client hỏi server hỗ trợ những gì, chỉ bật thứ nó cần, và server cũ không hiểu CAP thì vẫn chạy như bình thường.

Mấy capability mình thấy đáng dùng nhất:

  • sasl: xác thực trước khi đăng ký xong, nên không còn cảnh join kênh trước rồi mới identify.
  • server-time: mỗi message kèm tag time=2026-10-12T03:15:00.000Z, ở chuẩn UTC. Rất cần khi ghi log.
  • message-tags: metadata dạng key-value ở đầu message, là nền cho reply, reaction, msgid.
  • echo-message: server gửi lại message của chính mình, nhờ vậy bạn biết message đã thực sự được gửi đi.
  • batch và chathistory: gom nhiều message thành một nhóm, lấy lại lịch sử chat.

Bài học rộng hơn ở đây là cách thiết kế protocol: thêm tính năng mới qua negotiation, đừng bump version rồi làm hỏng client cũ. Pattern này cũng áp dụng được khi bạn thiết kế API nội bộ hay WebSocket protocol.

2. Xem tận mắt quá trình negotiation

Trước khi viết code, nên nói chuyện trực tiếp với server một lần. Dùng openssl (bản 3.x có sẵn trên macOS/Linux):

# Kết nối TLS tới Libera.Chat, port 6697
openssl s_client -connect irc.libera.chat:6697 -quiet

# Gõ tay từng dòng sau khi kết nối:
CAP LS 302
NICK vn_dev_test
USER vn_dev_test 0 * :Test

# Server trả về dạng:
# :server CAP * LS :account-notify away-notify batch chghost echo-message
#   extended-join invite-notify message-tags multi-prefix sasl=PLAIN,EXTERNAL
#   server-time ...

CAP REQ :server-time message-tags
# :server CAP vn_dev_test ACK :server-time message-tags

CAP END
# Lúc này server mới gửi 001 Welcome
Enter fullscreen mode Exit fullscreen mode

Có vài điểm cần để ý:

  • CAP LS 302 báo cho server biết client hỗ trợ CAP version 302. Với version này, server có thể trả giá trị cho capability (ví dụ sasl=PLAIN,EXTERNAL) và tách danh sách thành nhiều dòng. Dòng nào còn tiếp thì có dấu * trước danh sách.
  • Khi bạn đã gửi CAP LS, server sẽ giữ lại quá trình registration cho tới khi bạn gửi CAP END. Nhờ vậy SASL mới chạy được trước khi vào mạng.
sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: CAP LS 302
    C->>S: NICK / USER
    S-->>C: CAP * LS :sasl server-time ...
    C->>S: CAP REQ :sasl server-time message-tags
    S-->>C: CAP * ACK :sasl server-time message-tags
    C->>S: AUTHENTICATE PLAIN
    S-->>C: AUTHENTICATE +
    C->>S: AUTHENTICATE base64 credentials
    S-->>C: 903 SASL successful
    C->>S: CAP END
    S-->>C: 001 Welcome

3. Parse message có tags, đúng spec

Một message IRCv3 đầy đủ trông như sau:

@time=2026-10-12T03:15:00.000Z;msgid=abc123 :andy!~a@host PRIVMSG #python :xin chào
Enter fullscreen mode Exit fullscreen mode

Cấu trúc gồm: @tags (tùy chọn), :prefix (tùy chọn), command, params, và trailing param bắt đầu bằng :. Chỗ nhiều người làm sai là escaping trong tag value: \: là ;, \s là dấu cách, \\ là backslash, \r và \n là CR/LF. Backslash đứng cuối chuỗi thì bỏ đi. Escape không hợp lệ thì giữ lại ký tự phía sau.

ESC = {':': ';', 's': ' ', '\\': '\\', 'r': '\r', 'n': '\n'}

def unescape(v: str) -> str:
    out, i = [], 0
    while i < len(v):
        if v[i] == '\\':
            if i + 1 < len(v):
                out.append(ESC.get(v[i + 1], v[i + 1]))
            i += 2
        else:
            out.append(v[i])
            i += 1
    return ''.join(out)

def parse(line: str):
    tags, prefix = {}, None
    if line.startswith('@'):
        raw, line = line[1:].split(' ', 1)
        for item in raw.split(';'):
            k, _, v = item.partition('=')
            tags[k] = unescape(v)
    if line.startswith(':'):
        prefix, line = line[1:].split(' ', 1)
    if ' :' in line:
        line, trailing = line.split(' :', 1)
        params = line.split() + [trailing]
    else:
        params = line.split()
    return tags, prefix, params[0].upper(), params[1:]

# Test nhanh
print(parse('@time=2026-10-12T03:15:00.000Z;note=a\\sb :x!y@z PRIVMSG #c :hi there'))
# ({'time': '2026-10-12T03:15:00.000Z', 'note': 'a b'}, 'x!y@z', 'PRIVMSG', ['#c', 'hi there'])
Enter fullscreen mode Exit fullscreen mode

Đừng dùng regex cho phần này. Parser viết tay ngắn hơn, dễ test hơn, và xử lý được edge case như tag không có value (+typing hay draft/bot).

4. Bot asyncio hoàn chỉnh với SASL

Bot dưới đây chạy với Python 3.10 trở lên, chỉ dùng stdlib. Nó xử lý toàn bộ flow: negotiation, SASL PLAIN, join kênh, rồi in message kèm timestamp từ server.

flowchart LR
    A[TLS socket] --> B[readline]
    B --> C[parse tags/prefix/cmd]
    C --> D{Command}
    D -->|CAP/AUTHENTICATE| E[Negotiation state]
    D -->|PING| F[PONG]
    D -->|PRIVMSG| G[Handler + server-time]
    E --> H[writer.drain]
    F --> H
    G --> H
import asyncio, base64, os, ssl

HOST, PORT = 'irc.libera.chat', 6697
NICK = os.environ.get('IRC_NICK', 'vn_dev_bot')
PASSWORD = os.environ['IRC_PASSWORD']  # đừng hardcode
WANTED = {'sasl', 'server-time', 'message-tags', 'echo-message'}

async def main():
    reader, writer = await asyncio.open_connection(
        HOST, PORT, ssl=ssl.create_default_context())
    send = lambda s: writer.write((s + '\r\n').encode())
    send('CAP LS 302')
    send(f'NICK {NICK}')
    send(f'USER {NICK} 0 * :{NICK}')
    available = set()

    while raw := await reader.readline():
        line = raw.decode(errors='replace').rstrip('\r\n')
        tags, prefix, cmd, params = parse(line)

        if cmd == 'PING':
            send(f'PONG :{params[-1]}')
        elif cmd == 'CAP' and params[1] == 'LS':
            available |= {c.split('=')[0] for c in params[-1].split()}
            if params[2] != '*':  # dòng LS cuối cùng
                send('CAP REQ :' + ' '.join(sorted(WANTED & available)))
        elif cmd == 'CAP' and params[1] == 'ACK':
            send('AUTHENTICATE PLAIN' if 'sasl' in params[-1].split() else 'CAP END')
        elif cmd == 'CAP' and params[1] == 'NAK':
            send('CAP END')
        elif cmd == 'AUTHENTICATE' and params[0] == '+':
            cred = f'{NICK}\0{NICK}\0{PASSWORD}'.encode()
            send('AUTHENTICATE ' + base64.b64encode(cred).decode())
        elif cmd in ('903', '904', '905'):  # SASL ok / fail
            print('SASL:', params[-1])
            send('CAP END')
        elif cmd == '001':
            send('JOIN #vn-dev-test')
        elif cmd == 'PRIVMSG':
            who = prefix.split('!')[0]
            print(f"[{tags.get('time', '?')}] {params[0]} <{who}> {params[-1]}")
            if params[-1] == '!ping' and who != NICK:
                send(f'PRIVMSG {params[0]} :pong')
        await writer.drain()

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Chạy thử:

export IRC_PASSWORD='mat-khau-nickserv'
nohup python3 bot.py > bot.log 2>&1 &
tail -f bot.log
Enter fullscreen mode Exit fullscreen mode

Có vài lưu ý từ kinh nghiệm thực tế:

  • Phải gửi CAP END trong mọi nhánh, kể cả khi SASL fail (904). Nếu quên, server sẽ treo registration và tự ngắt kết nối sau khoảng 30-60 giây. Đây là bug phổ biến nhất.
  • Payload SASL PLAIN có dạng authzid\0authcid\0password. Nếu payload dài hơn 400 byte, bạn phải chia thành nhiều chunk. Password bình thường thì không cần lo chuyện này.
  • Có echo-message thì message của bot cũng quay về qua PRIVMSG. Vì vậy cần check who != NICK, nếu không bot sẽ tự trả lời chính nó và bị flood.
  • Nếu chạy production, hãy dùng SASL EXTERNAL với client certificate thay cho password. Libera.Chat hỗ trợ phương thức này qua CertFP.

Kết luận

IRCv3 là ví dụ đẹp về cách nâng cấp một giao thức 30 năm tuổi mà không làm hỏng hệ sinh thái. Những gì bạn nên làm ngay:

  1. Thử raw protocol bằng openssl s_client trước khi viết code. Mất 5 phút nhưng bạn sẽ hiểu flow rõ hơn nhiều so với đọc tài liệu.
  2. Luôn bắt đầu bằng CAP LS 302 và chỉ REQ những capability mà server thực sự có. Phải xử lý cả LS nhiều dòng lẫn NAK.
  3. Dùng SASL thay cho PRIVMSG NickServ IDENTIFY. Cách này hết race condition, và một số mạng bắt buộc SASL với dải IP từ cloud.
  4. Lấy timestamp từ tag server-time, đừng dùng datetime.now(). Log sẽ đúng giờ UTC, kể cả khi nhận lại message cũ qua bouncer.
  5. Viết parser riêng có unit test cho tag escaping. Đây là chỗ dễ sinh bug nhất và cũng dễ test nhất.
  6. Mang tư duy capability negotiation vào protocol của chính bạn: client và server thỏa thuận tính năng, không ai giả định ai đó đã được nâng cấp.

Bot 60 dòng ở trên đủ làm nền cho notifier CI/CD, bot alert server, hoặc bridge sang Telegram. Nó không cần SDK hay vendor lock-in nào, chỉ cần TCP, TLS và một spec mở.

Top comments (0)