ircbits.com

Index / Writing an IRC bot: from a raw socket to something you'd actually run

Writing an IRC bot: from a raw socket to something you'd actually run

Updated July 15, 2026

Illustration: The core read loop of an IRC bot

The reason "write an IRC bot" has been a rite of passage for thirty years is that the protocol is small enough to hold in your head. No SDK, no auth dance with OAuth, no webhooks — open a socket, send lines of text, read lines of text. You can write a bot that joins a channel and responds to commands in about fifteen lines, and crucially, you'll understand every one of them.

This article goes from that fifteen-line version up to something you'd be comfortable leaving running. If you just want the landscape of existing bot software (eggdrop, Limnoria, Sopel, bridges), that's the bots overview — start there if you'd rather configure than code. This is the build-it page.

Step 1: the whole thing, in raw Python

No libraries. This connects, survives the server's PING checks, joins a channel, and echoes back anything said to it with a ! prefix. Read it top to bottom — it's the entire protocol in miniature.

import socket

HOST, PORT = "irc.libera.chat", 6667   # plaintext, for learning only
NICK = "demobot123"
CHAN = "#demobot-test"

sock = socket.create_connection((HOST, PORT))
def send(line):
    print(">>", line)
    sock.sendall((line + "\r\n").encode("utf-8"))

# --- the handshake (see the protocol article) ---
send(f"NICK {NICK}")
send(f"USER {NICK} 0 * :demo bot")

buf = ""
while True:
    buf += sock.recv(4096).decode("utf-8", errors="replace")
    while "\r\n" in buf:
        line, buf = buf.split("\r\n", 1)
        print("<<", line)

        # 1. Answer PING or the server will drop us.
        if line.startswith("PING"):
            send("PONG" + line[4:])

        # 2. When the MOTD ends (376) — or there's none (422) — join.
        elif " 376 " in line or " 422 " in line:
            send(f"JOIN {CHAN}")

        # 3. React to channel messages.
        elif "PRIVMSG" in line:
            prefix, _, rest = line.partition(" PRIVMSG ")
            target, _, text = rest.partition(" :")
            speaker = prefix.split("!")[0].lstrip(":")
            if text.startswith("!echo "):
                send(f"PRIVMSG {target} :{speaker}: {text[6:]}")

That's a working bot. Everything else in this article is making it good: not talking in plaintext, knowing who it's talking to, and not falling over the first time the connection hiccups.

Step 2: TLS and SASL (do this for anything real)

The plaintext port 6667 is fine for a one-evening experiment and wrong for anything you'll keep. Two upgrades:

TLS is a one-line change — wrap the socket before you use it, and connect to port 6697:

import ssl
ctx = ssl.create_default_context()
raw = socket.create_connection((HOST, 6697))
sock = ctx.wrap_socket(raw, server_hostname=HOST)

SASL logs the bot into its registered account during the handshake, which is how networks know a bot is who it claims to be — and increasingly a requirement for doing anything useful (joining +r registered-only channels, getting a cloak, being trusted at all). First register the bot's nick like any user. Then the handshake gains a capability-negotiation step before NICK/USER:

CAP REQ :sasl
AUTHENTICATE PLAIN
AUTHENTICATE <base64 of  \0account\0password >
   (server replies 900/903 on success)
CAP END

The PLAIN payload is authzid\0authcid\0password — in practice \0account\0password — base64-encoded. Once you see numeric 903 (SASL authentication successful), send CAP END and continue the normal handshake. The IRCv3 SASL spec has the exact exchange if you're implementing it by hand.

Step 3: the things that bite you

The gap between "works once" and "runs for a month" is a short list of real-world problems the raw version ignores:

  • Reconnection. Connections drop — ping timeouts, netsplits, server restarts. A real bot wraps the whole connect-and-loop in a retry with exponential backoff (wait 1s, then 2s, 4s, 8s… up to a cap) so it doesn't hammer the server when the network's having a bad day.
  • Rate limiting. Send too fast and the server will throttle or disconnect you for flooding. Queue your outgoing lines and pace them — roughly one message per couple of seconds is safe; the server advertises real limits in numeric 005. Don't make the bot reply to 100 lines in a burst.
  • Nick already in use (433). Your bot's nick might be taken on reconnect (often by its own ghost from the previous session). Handle 433 by trying an alternate nick, and use SASL/NickServ GHOST to reclaim the real one.
  • Message length. Lines are capped (historically 512 bytes including the nick!user@host prefix the server prepends). Long output must be split across multiple PRIVMSGs, accounting for the prefix length you don't control.
  • Encoding. It's 2026; assume UTF-8, but decode defensively — some ancient channels still emit Latin-1, and a single bad byte shouldn't crash your read loop. (Note the errors="replace" in the example.)
  • CTCP. Messages wrapped in \x01 (like \x01VERSION\x01 or the ACTION behind /me) are CTCP requests. At minimum, don't choke on them; ideally answer VERSION and PING.

Step 4: use a library (you've earned it)

Having done it by hand once, there's no virtue in maintaining your own protocol parser. Mature libraries handle all of Step 3 for you:

  • Python — the irc library, or jump straight to a full framework like Limnoria (plugins, permissions, dozens of stock features) or Sopel (lighter, decorator-based, very fast to write simple commands in). For most "I want a channel utility bot" goals, Sopel or Limnoria means you write only your feature, not the plumbing.
  • Gogirc (from the Ergo team, good IRCv3 support) or the older go-ircevent.
  • Rust — the irc crate, async, Tokio-based.
  • Nodeirc-framework.
  • Rubycinch, the classic plugin-friendly framework.

A Sopel command, for contrast with the raw version above, is about this much:

from sopel import plugin

@plugin.command("echo")
def echo(bot, trigger):
    bot.say(f"{trigger.nick}: {trigger.group(2)}")

Same behaviour, none of the sockets, plus TLS, SASL, reconnection, rate limiting and CTCP already handled.

The etiquette — which now has actual teeth

This part is not optional and the rules tightened recently. The old norm — ask the channel operators before bringing a bot in — has hardened into enforced policy, with Libera.Chat's 2026 update making operator permission a requirement and singling out LLM-driven bots that join and talk uninvited. The working rules:

  • Get op permission before your bot joins any channel you don't run. Test in your own channel (#yourbot-test) first. Always.
  • Mark it as a bot. Set the IRCv3 bot mode so clients and the network can see it's automated.
  • Trigger on commands, never on everything. A bot that responds to every line is a bot that gets banned. Prefix-triggered commands (!weather, !faq) only; nothing that talks unprompted.
  • Rate-limit yourself before the server does it for you.
  • Respect NOTICE. Never auto-reply to a NOTICE — that's the convention that stops two bots from melting a channel in an infinite loop.

What's worth building

Channels genuinely appreciate: loggers (with the channel's consent — announce it in the topic), CI/build notifiers for project channels, URL-title fetchers, factoid bots (!faq install → a canned answer), and games — trivia bots have kept channels populated for two decades. The bots that get banned are the ones that talk too much, paste too much, or showed up without asking.

Build the raw version once so the protocol is never mysterious again. Then run the library version, because the interesting part was never the socket — it was what you do with the lines.