Simple and extensible IRC bot framework written in Python with plugin architecture and database support
—
Sopel provides comprehensive IRC protocol handling including connection management, message sending, channel operations, user tracking, capability negotiation, and mode parsing. The IRC layer handles both low-level protocol details and high-level bot operations.
Core methods for sending messages and interacting with IRC channels and users.
class Sopel:
"""Main bot class with IRC communication methods."""
def say(self, text: str, recipient: str, max_messages: int = 1, truncation: str = '', trailing: str = '') -> None:
"""
Send a message to a channel or user.
Args:
text (str): Message text to send
recipient (str): Channel or nick to send to
max_messages (int): Maximum number of messages if text is split
truncation (str): String to indicate message was truncated
trailing (str): String to append after text
Returns:
None
"""
def reply(self, text: str, destination: str = None, reply_to: str = None, max_messages: int = 1) -> int:
"""
Reply to a user, optionally mentioning their nick.
Args:
text (str): Reply text
destination (str): Channel or nick to reply to
reply_to (str): Nick to mention in reply
max_messages (int): Maximum number of messages if text is split
Returns:
Number of messages sent
"""
def action(self, text: str, destination: str = None) -> None:
"""
Send a CTCP ACTION (/me) message.
Args:
text (str): Action text
destination (str): Channel or nick to send action to
"""
def notice(self, text: str, destination: str = None) -> None:
"""
Send a NOTICE message.
Args:
text (str): Notice text
destination (str): Channel or nick to send notice to
"""
def msg(self, recipient: str, text: str, max_messages: int = 1) -> int:
"""
Send a private message to a specific recipient.
Args:
recipient (str): Nick or channel to message
text (str): Message text
max_messages (int): Maximum number of messages if text is split
Returns:
Number of messages sent
"""
class SopelWrapper:
"""Bot wrapper for use in plugin functions."""
def say(self, message: str, destination: str = None, max_messages: int = 1, truncation: str = '', trailing: str = '') -> None:
"""Send message (same as Sopel.say)."""
def reply(self, message: str, destination: str = None, reply_to: str = None, notice: bool = False) -> None:
"""Reply to user (same as Sopel.reply)."""
def action(self, text: str, destination: str = None) -> None:
"""Send action (same as Sopel.action)."""
def notice(self, text: str, destination: str = None) -> None:
"""Send notice (same as Sopel.notice)."""Methods for joining, leaving, and managing IRC channels.
class Sopel:
"""Channel management methods."""
def join(self, channel: str, password: str = None) -> None:
"""
Join an IRC channel.
Args:
channel (str): Channel name to join (with # prefix)
password (str): Channel password if required
"""
def part(self, channel: str, message: str = None) -> None:
"""
Leave an IRC channel.
Args:
channel (str): Channel name to leave
message (str): Part message
"""
def kick(self, nick: str, channel: str = None, message: str = None) -> None:
"""
Kick a user from a channel.
Args:
nick (str): Nickname to kick
channel (str): Channel to kick from (defaults to current context)
message (str): Kick message
"""
def invite(self, nick: str, channel: str) -> None:
"""
Invite a user to a channel.
Args:
nick (str): Nickname to invite
channel (str): Channel to invite to
"""
def mode(self, target: str, modes: str = None) -> None:
"""
Set channel or user modes.
Args:
target (str): Channel or nick to set modes on
modes (str): Mode string (e.g., "+o nick", "-v nick")
"""Access to bot's knowledge of users and channels.
class Sopel:
"""User and channel tracking attributes."""
@property
def channels(self) -> 'IdentifierMemory':
"""
Dictionary of channels the bot is in.
Keys are Identifier objects for channel names.
Values are Channel objects containing user lists and permissions.
"""
@property
def users(self) -> 'IdentifierMemory':
"""
Dictionary of users the bot is aware of.
Keys are Identifier objects for nicknames.
Values are User objects with user information.
"""
@property
def nick(self) -> 'Identifier':
"""Bot's current nickname."""
def is_nick(self, nick: str) -> bool:
"""
Check if a nickname belongs to this bot.
Args:
nick (str): Nickname to check
Returns:
True if nick is bot's nickname
"""Methods for managing the IRC connection and bot lifecycle.
class Sopel:
"""Connection and lifecycle management."""
def run(self, host: str = None, port: int = None) -> None:
"""
Start the bot and connect to IRC.
Args:
host (str): IRC server hostname (overrides config)
port (int): IRC server port (overrides config)
"""
def restart(self, message: str = None) -> None:
"""
Restart the bot.
Args:
message (str): Quit message before restart
"""
def quit(self, message: str = None) -> None:
"""
Disconnect from IRC and quit.
Args:
message (str): Quit message
"""
def write(self, args: list, text: str = None) -> None:
"""
Send raw IRC command.
Args:
args (list): IRC command and parameters
text (str): Optional message text
"""
def safe_text_length(self, recipient: str) -> int:
"""
Get maximum safe text length for messages to recipient.
Args:
recipient (str): Target channel or nick
Returns:
Maximum safe message length in bytes
"""IRC capability negotiation for modern IRC features.
class Sopel:
"""IRC capability handling."""
@property
def server_capabilities(self) -> dict:
"""Dictionary of server-supported capabilities."""
@property
def enabled_capabilities(self) -> set:
"""Set of currently enabled capabilities."""
def request_capability(self, capability: str) -> None:
"""
Request an IRC capability.
Args:
capability (str): Capability name to request
"""IRC mode parsing and interpretation.
class ModeParser:
"""Parser for IRC mode strings."""
def parse(self, mode_string: str, params: list = None) -> dict:
"""
Parse IRC mode string.
Args:
mode_string (str): Mode string (e.g., "+ooo-v")
params (list): Mode parameters
Returns:
Dictionary of parsed mode changes
"""
class Sopel:
@property
def modeparser(self) -> ModeParser:
"""Mode parser instance for handling IRC modes."""from sopel import plugin
@plugin.command('say')
@plugin.example('.say #channel Hello everyone!')
def say_command(bot, trigger):
"""Make the bot say something in a channel."""
args = trigger.group(2)
if not args:
bot.reply("Usage: .say <channel> <message>")
return
parts = args.split(' ', 1)
if len(parts) < 2:
bot.reply("Usage: .say <channel> <message>")
return
channel, message = parts
bot.say(message, channel)
bot.reply(f"Message sent to {channel}")
@plugin.command('me')
@plugin.example('.me is excited about IRC bots!')
def action_command(bot, trigger):
"""Make the bot perform an action."""
if not trigger.group(2):
bot.reply("Usage: .me <action>")
return
action_text = trigger.group(2)
bot.action(action_text, trigger.sender)@plugin.command('join')
@plugin.require_admin()
def join_command(bot, trigger):
"""Join a channel."""
if not trigger.group(2):
bot.reply("Usage: .join <channel> [password]")
return
args = trigger.group(2).split(' ', 1)
channel = args[0]
password = args[1] if len(args) > 1 else None
if not channel.startswith('#'):
channel = '#' + channel
bot.join(channel, password)
bot.reply(f"Joining {channel}")
@plugin.command('part')
@plugin.require_admin()
def part_command(bot, trigger):
"""Leave a channel."""
channel = trigger.group(2) or trigger.sender
if not channel.startswith('#'):
bot.reply("Must specify a channel to leave")
return
bot.part(channel, "Leaving on admin request")
if trigger.sender != channel:
bot.reply(f"Left {channel}")
@plugin.command('kick')
@plugin.require_privilege(plugin.OP)
def kick_command(bot, trigger):
"""Kick a user from the channel."""
args = trigger.group(2)
if not args:
bot.reply("Usage: .kick <nick> [reason]")
return
parts = args.split(' ', 1)
nick = parts[0]
reason = parts[1] if len(parts) > 1 else f"Kicked by {trigger.nick}"
bot.kick(nick, trigger.sender, reason)@plugin.command('userinfo')
def userinfo_command(bot, trigger):
"""Show information about a user."""
nick = trigger.group(2) or trigger.nick
if nick in bot.users:
user = bot.users[nick]
info_parts = [f"User info for {nick}:"]
if hasattr(user, 'host'):
info_parts.append(f"Host: {user.host}")
if hasattr(user, 'account') and user.account:
info_parts.append(f"Account: {user.account}")
if hasattr(user, 'away') and user.away:
info_parts.append("Status: Away")
bot.reply(" | ".join(info_parts))
else:
bot.reply(f"No information available for {nick}")
@plugin.command('chaninfo')
def chaninfo_command(bot, trigger):
"""Show information about current channel."""
channel = trigger.sender
if not channel.startswith('#'):
bot.reply("This command only works in channels")
return
if channel in bot.channels:
chan = bot.channels[channel]
user_count = len(chan.users) if hasattr(chan, 'users') else 0
bot.reply(f"Channel {channel} has {user_count} users")
# Show channel modes if available
if hasattr(chan, 'modes'):
modes = '+'.join(chan.modes) if chan.modes else "none"
bot.reply(f"Channel modes: {modes}")
else:
bot.reply(f"Not currently in {channel}")@plugin.command('whois')
def whois_command(bot, trigger):
"""Get WHOIS information for a user."""
nick = trigger.group(2)
if not nick:
bot.reply("Usage: .whois <nick>")
return
# Send WHOIS request
bot.write(['WHOIS', nick])
bot.reply(f"WHOIS request sent for {nick}")
@plugin.event('RPL_WHOISUSER') # 311 numeric
def handle_whois_response(bot, trigger):
"""Handle WHOIS response."""
# Parse WHOIS response: :server 311 bot_nick target_nick username hostname * :realname
if len(trigger.args) >= 6:
target_nick = trigger.args[1]
username = trigger.args[2]
hostname = trigger.args[3]
realname = trigger.args[5]
bot.say(f"WHOIS {target_nick}: {username}@{hostname} ({realname})")
@plugin.command('mode')
@plugin.require_privilege(plugin.OP)
def mode_command(bot, trigger):
"""Set channel modes."""
args = trigger.group(2)
if not args:
bot.reply("Usage: .mode <modes> [parameters]")
return
# Set modes on current channel
bot.mode(trigger.sender, args)
bot.reply(f"Mode change requested: {args}")@plugin.event('001') # RPL_WELCOME - successful connection
def on_connect(bot, trigger):
"""Handle successful IRC connection."""
bot.say("Bot connected successfully!", bot.settings.core.owner)
@plugin.event('PING')
def handle_ping(bot, trigger):
"""Handle PING from server."""
# Sopel handles PING automatically, but you can add custom logic
pass
@plugin.event('ERROR')
def handle_error(bot, trigger):
"""Handle ERROR messages from server."""
error_msg = trigger.args[0] if trigger.args else "Unknown error"
bot.say(f"IRC Error: {error_msg}", bot.settings.core.owner)
@plugin.command('reconnect')
@plugin.require_owner()
def reconnect_command(bot, trigger):
"""Reconnect to IRC server."""
bot.quit("Reconnecting...")
# Bot will automatically reconnect based on configuration@plugin.capability('account-tag')
@plugin.command('account')
def account_command(bot, trigger):
"""Show user's account information (requires account-tag capability)."""
if 'account-tag' not in bot.enabled_capabilities:
bot.reply("Account information not available (capability not supported)")
return
account = getattr(trigger, 'account', None)
if account:
bot.reply(f"You are logged in as: {account}")
else:
bot.reply("You are not logged in to services")
@plugin.event('CAP')
def handle_capability(bot, trigger):
"""Handle capability negotiation messages."""
# Sopel handles this automatically, but you can add custom logic
subcommand = trigger.args[1]
if subcommand == 'ACK':
capabilities = trigger.args[2].split()
for cap in capabilities:
bot.say(f"Capability enabled: {cap}", bot.settings.core.owner)class Trigger:
"""Context information for IRC messages."""
# Message metadata
nick: str # Sender's nickname
user: str # Sender's username
host: str # Sender's hostname
hostmask: str # Full hostmask (nick!user@host)
sender: str # Channel or nick message came from
raw: str # Raw IRC message
# Message content
args: list # Message arguments
event: str # IRC event type (PRIVMSG, JOIN, etc.)
# Message properties
is_privmsg: bool # True if private message
account: str # Sender's services account (if available)
# Regex match methods
def group(self, n: int) -> str:
"""Get regex match group."""
def groups(self) -> tuple:
"""Get all regex match groups."""class Channel:
"""Represents an IRC channel."""
users: dict # Users in channel mapped to privilege levels
modes: set # Channel modes
topic: str # Channel topic
class User:
"""Represents an IRC user."""
nick: str # Current nickname
user: str # Username
host: str # Hostname
account: str # Services account
away: bool # Away status
channels: set # Channels user is in
class Identifier(str):
"""IRC identifier with case-insensitive comparison."""
def lower(self) -> str:
"""Get RFC1459 lowercase version."""Install with Tessl CLI
npx tessl i tessl/pypi-sopel