My first IRC bot was a Tcl Eggdrop in 1999. It crashed every few hours and I had no idea why. When I rewrote it in Python around 2004, the crash-to-understanding ratio improved dramatically. Python's readability made IRC protocol debugging almost pleasant — you could actually read your own code six months later. I've written dozens of IRC bots since then, from quick throwaway scripts to long-running channel management tools. Here's how to do it properly in 2024.
Why Python for IRC Bots
Python is not the fastest language for IRC bots. It's not the most memory-efficient. But it has two properties that matter more: the standard library includes everything you need for basic IRC communication, and the code stays readable as complexity grows. An Eggdrop Tcl script or a mIRC script becomes unreadable at around 500 lines. A Python bot can stay clean at 5,000.
The IRC protocol is text-based, line-oriented, and UTF-8 compatible in modern implementations. Python handles all of this natively. Socket programming in Python is straightforward. String parsing is trivial. And when you need async I/O — which you will, once your bot does anything beyond basic message response — asyncio is built in.
Three approaches exist, each suited to different use cases:
- Raw sockets — Maximum control, minimal dependencies. Good for learning, bad for production.
- The
irclibrary — Handles protocol details, reconnection, event dispatching. The pragmatic choice. - Asyncio with raw protocol — Modern, concurrent, suitable for bots that interact with external APIs.
The Raw Socket Approach
Start here if you want to understand the IRC protocol at the wire level. Every IRC message is a UTF-8 string terminated by \r\n, with a maximum length of 512 bytes (per RFC 1459) or 4096 bytes with the IRCv3 message-tags capability. A minimal bot needs to handle exactly four things: connecting, registering, responding to PING, and parsing incoming messages.
import socket
import ssl
def irc_connect(server, port, nick, channels, use_tls=True):
"""Connect to an IRC server and join channels."""
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
if use_tls:
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(raw_sock, server_hostname=server)
else:
sock = raw_sock
sock.connect((server, port))
send(sock, f"NICK {nick}")
send(sock, f"USER {nick} 0 * :Python IRC Bot")
buffer = ""
while True:
data = sock.recv(4096).decode("utf-8", errors="replace")
if not data:
break
buffer += data
while "\r\n" in buffer:
line, buffer = buffer.split("\r\n", 1)
print(f"<< {line}")
handle_line(sock, line, nick, channels)
def send(sock, msg):
"""Send a raw IRC line."""
print(f">> {msg}")
sock.send(f"{msg}\r\n".encode("utf-8"))
def handle_line(sock, line, nick, channels):
"""Parse and respond to an IRC message."""
if line.startswith("PING"):
send(sock, line.replace("PING", "PONG", 1))
return
parts = line.split(" ")
# After registration (001 numeric), join channels
if len(parts) > 1 and parts[1] == "001":
for chan in channels:
send(sock, f"JOIN {chan}")
return
# Handle PRIVMSG
if len(parts) > 3 and parts[1] == "PRIVMSG":
source = parts[0].lstrip(":")
sender_nick = source.split("!")[0]
target = parts[2]
message = " ".join(parts[3:]).lstrip(":")
if message.startswith("!ping"):
reply_to = target if target.startswith("#") else sender_nick
send(sock, f"PRIVMSG {reply_to} :pong, {sender_nick}")
if __name__ == "__main__":
irc_connect("irc.libera.chat", 6697, "mybot", ["#test"])
This is about 50 lines and it works. It connects, registers, joins channels, responds to PING, and handles a !ping command. It also has fatal flaws: no reconnection logic, no flood protection, no proper message parsing (the naive split approach breaks on messages containing spaces in unexpected positions), and no SASL authentication.
Using the irc Library
The irc library (installable via pip install irc) wraps the protocol in a clean event-driven interface. It handles connection registration, PING/PONG, reconnection, and message parsing. You write event handlers; the library handles the protocol.
import irc.bot
import irc.strings
class ChannelBot(irc.bot.SingleServerIRCBot):
def __init__(self, server, port, nick, channel, password=None):
# TLS: use irc.connection.Factory(wrapper=ssl.wrap_socket)
import ssl
factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
irc.bot.SingleServerIRCBot.__init__(
self,
[(server, port, password)],
nick,
nick,
connect_factory=factory
)
self.channel = channel
self.command_prefix = "!"
def on_nicknameinuse(self, conn, event):
conn.nick(conn.get_nickname() + "_")
def on_welcome(self, conn, event):
conn.join(self.channel)
print(f"Joined {self.channel}")
def on_pubmsg(self, conn, event):
message = event.arguments[0]
sender = event.source.nick
channel = event.target
if not message.startswith(self.command_prefix):
return
cmd = message.split()[0][len(self.command_prefix):].lower()
args = message.split()[1:] if len(message.split()) > 1 else []
if cmd == "ping":
conn.privmsg(channel, f"pong, {sender}")
elif cmd == "users":
chan_obj = self.channels.get(channel)
if chan_obj:
count = len(chan_obj.users())
conn.privmsg(channel, f"There are {count} users in {channel}")
elif cmd == "uptime":
import time
delta = int(time.time() - self._connect_time)
hours, remainder = divmod(delta, 3600)
minutes, seconds = divmod(remainder, 60)
conn.privmsg(channel, f"Uptime: {hours}h {minutes}m {seconds}s")
def on_welcome(self, conn, event):
import time
self._connect_time = time.time()
conn.join(self.channel)
if __name__ == "__main__":
bot = ChannelBot("irc.libera.chat", 6697, "mybot", "#test")
bot.start()
This is already more capable than the raw socket version. It handles nickname collisions, exposes channel state (user lists, topic, modes), and provides a clean method to override for each IRC event. The library uses the jaraco.stream package for buffered I/O and the more-itertools package for message batching.
irc library's SingleServerIRCBot handles automatic reconnection by default. If you need to connect to multiple servers or networks simultaneously, use the irc.client.Reactor class directly and manage your own connection objects. Each connection runs in the same thread via select-based multiplexing.An Asyncio-Based Bot
For bots that need to interact with HTTP APIs, databases, or other async services, the irc library's blocking event loop becomes a bottleneck. You can solve this with threads, but the cleaner approach in 2024 is asyncio. There's no mature asyncio IRC library that I'd recommend unconditionally, so I typically build on asyncio.Protocol directly.
import asyncio
import ssl
class IRCProtocol(asyncio.Protocol):
def __init__(self, nick, channels, on_message_cb):
self.nick = nick
self.channels = channels
self.on_message_cb = on_message_cb
self.transport = None
self.buffer = ""
def connection_made(self, transport):
self.transport = transport
self.send_raw(f"NICK {self.nick}")
self.send_raw(f"USER {self.nick} 0 * :Async IRC Bot")
def data_received(self, data):
self.buffer += data.decode("utf-8", errors="replace")
while "\r\n" in self.buffer:
line, self.buffer = self.buffer.split("\r\n", 1)
asyncio.ensure_future(self.process_line(line))
def send_raw(self, msg):
if self.transport:
self.transport.write(f"{msg}\r\n".encode("utf-8"))
async def process_line(self, line):
if line.startswith("PING"):
self.send_raw(line.replace("PING", "PONG", 1))
return
parts = line.split(" ", 3)
if len(parts) > 1 and parts[1] == "001":
for ch in self.channels:
self.send_raw(f"JOIN {ch}")
return
if len(parts) > 3 and parts[1] == "PRIVMSG":
source_nick = parts[0].split("!")[0].lstrip(":")
target = parts[2]
text = parts[3].lstrip(":")
await self.on_message_cb(self, source_nick, target, text)
async def handle_message(proto, sender, target, text):
"""Handle incoming messages with async capabilities."""
reply_to = target if target.startswith("#") else sender
if text.startswith("!weather "):
city = text[9:].strip()
# Example: async HTTP call to a weather API
import aiohttp
async with aiohttp.ClientSession() as session:
url = f"https://wttr.in/{city}?format=3"
async with session.get(url) as resp:
if resp.status == 200:
weather = await resp.text()
proto.send_raw(f"PRIVMSG {reply_to} :{weather.strip()}")
else:
proto.send_raw(f"PRIVMSG {reply_to} :Could not fetch weather.")
async def main():
ctx = ssl.create_default_context()
loop = asyncio.get_event_loop()
transport, protocol = await loop.create_connection(
lambda: IRCProtocol("asyncbot", ["#test"], handle_message),
"irc.libera.chat", 6697, ssl=ctx
)
await asyncio.Future() # Run forever
if __name__ == "__main__":
asyncio.run(main())
The key difference here is that handle_message is an async function. It can await HTTP requests, database queries, or any other coroutine without blocking the IRC event loop. This is the pattern I use for bots that need to interact with external services — fetching data from APIs, posting notifications, or forwarding messages between IRC and other platforms.
Practical Features: Logging, Commands, and Channel Management
A useful IRC bot needs more than message parsing. Here are the features I implement in every production bot.
Channel logging is the most requested feature. Store messages with timestamps, handle nick changes, joins, parts, and quits.
import datetime
import os
class ChannelLogger:
def __init__(self, log_dir="/var/log/ircbot"):
self.log_dir = log_dir
os.makedirs(log_dir, exist_ok=True)
def log(self, channel, event_type, nick, text=""):
date_str = datetime.date.today().isoformat()
time_str = datetime.datetime.now().strftime("%H:%M:%S")
filename = f"{channel.lstrip('#')}_{date_str}.log"
filepath = os.path.join(self.log_dir, filename)
formats = {
"msg": f"[{time_str}] <{nick}> {text}",
"action": f"[{time_str}] * {nick} {text}",
"join": f"[{time_str}] --> {nick} has joined {channel}",
"part": f"[{time_str}] <-- {nick} has left {channel} ({text})",
"quit": f"[{time_str}] <-- {nick} has quit ({text})",
"kick": f"[{time_str}] --- {nick} was kicked ({text})",
"topic": f"[{time_str}] --- {nick} changed topic to: {text}",
"mode": f"[{time_str}] --- {nick} sets mode {text}",
}
line = formats.get(event_type, f"[{time_str}] {event_type}: {nick} {text}")
with open(filepath, "a", encoding="utf-8") as f:
f.write(line + "\n")
Command routing with permissions. Not every user should be able to run every command.
class CommandRouter:
def __init__(self):
self.commands = {}
self.admins = set() # Set of hostmasks: nick!user@host patterns
def command(self, name, admin_only=False):
"""Decorator to register a command handler."""
def decorator(func):
self.commands[name] = {"handler": func, "admin": admin_only}
return func
return decorator
def is_admin(self, hostmask):
import fnmatch
return any(fnmatch.fnmatch(hostmask, pattern) for pattern in self.admins)
async def dispatch(self, proto, sender, hostmask, target, text):
if not text.startswith("!"):
return
parts = text.split()
cmd_name = parts[0][1:].lower()
args = parts[1:]
cmd = self.commands.get(cmd_name)
if not cmd:
return
if cmd["admin"] and not self.is_admin(hostmask):
proto.send_raw(f"PRIVMSG {target} :Permission denied, {sender}.")
return
await cmd["handler"](proto, sender, target, args)
router = CommandRouter()
router.admins.add("marco!*@*.example.com")
@router.command("kick", admin_only=True)
async def cmd_kick(proto, sender, target, args):
if args:
reason = " ".join(args[1:]) or "Requested"
proto.send_raw(f"KICK {target} {args[0]} :{reason}")
Flood protection on the output side is non-negotiable. A bot that sends messages faster than the server allows will get disconnected.
import time
import collections
class RateLimiter:
def __init__(self, max_messages=3, per_seconds=2.0):
self.max_messages = max_messages
self.per_seconds = per_seconds
self.timestamps = collections.deque()
def can_send(self):
now = time.monotonic()
while self.timestamps and now - self.timestamps[0] > self.per_seconds:
self.timestamps.popleft()
return len(self.timestamps) < self.max_messages
def record(self):
self.timestamps.append(time.monotonic())
Deployment and Process Management
A bot that runs on your laptop dies when your laptop sleeps. For production bots, I use systemd on Linux.
# /etc/systemd/system/ircbot.service
[Unit]
Description=IRC Channel Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ircbot
Group=ircbot
WorkingDirectory=/opt/ircbot
ExecStart=/opt/ircbot/venv/bin/python /opt/ircbot/bot.py
Restart=always
RestartSec=30
Environment=PYTHONUNBUFFERED=1
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/ircbot
[Install]
WantedBy=multi-user.target
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable ircbot
sudo systemctl start ircbot
sudo journalctl -u ircbot -f # Follow logs
For configuration, I use a plain JSON file rather than environment variables. IRC bots need multiple settings — server, port, nick, channels, NickServ password, admin hostmasks — and a config file keeps them organized.
{
"server": "irc.libera.chat",
"port": 6697,
"tls": true,
"nick": "mybot",
"nickserv_password": "CHANGE_ME",
"channels": ["#mychannel", "#otherchannel"],
"admin_masks": ["marco!*@unaffiliated/marco"],
"log_dir": "/var/log/ircbot",
"command_prefix": "!"
}
chmod 600), not in your bot's source code. If your bot's code is in a git repository, the password will end up in the commit history. Use .gitignore on your config file or use a secrets manager.One more thing: test your bot on a private server or a test channel before deploying to a public network. Running untested code on Libera.Chat or OFTC will get your bot K-lined if it misbehaves. I learned this the hard way in 2002 when a regex bug in my Eggdrop caused it to echo every message in a loop. The K-line lasted a week.
FAQ
Which Python version should I use for IRC bots?
Python 3.10 or later. You need proper UTF-8 handling, asyncio improvements, and match/case statements are useful for command routing. Python 2 is dead — do not write new IRC bots in Python 2.
How do I authenticate my bot with NickServ?
Two methods: send PRIVMSG NickServ :IDENTIFY password after connecting, or use SASL authentication during the connection phase. SASL is preferred because it authenticates before your bot is visible on the network. The irc library supports SASL via the sasl module. For raw sockets, you need to implement the CAP negotiation and SASL PLAIN mechanism manually (base64 encode \x00nick\x00password).
Can I connect my bot to multiple networks simultaneously?
Yes. With the irc library, use irc.client.Reactor with multiple server_connect() calls. With asyncio, create multiple protocol instances with separate create_connection() calls — they'll multiplex naturally on the event loop. Avoid spawning one thread per network; async I/O handles this cleanly.
How do I handle Unicode and non-ASCII characters?
Modern IRC networks use UTF-8. Decode incoming bytes with .decode("utf-8", errors="replace") to handle malformed input gracefully. Some older networks or clients still send Latin-1 or CP1252. If you're connecting to an old network, try chardet for encoding detection, but be aware it's heuristic-based and not always correct.