Simple and extensible IRC bot framework written in Python with plugin architecture and database support
—
Sopel provides comprehensive utility functions and classes for IRC formatting, time handling, web operations, mathematical calculations, logging, and identifier management. These tools simplify common bot development tasks and provide robust functionality for plugin developers.
Functions and constants for applying IRC formatting codes to text messages.
# Formatting functions
def bold(text: str) -> str:
"""
Apply bold formatting to text.
Args:
text (str): Text to make bold
Returns:
Text with bold formatting codes
"""
def italic(text: str) -> str:
"""
Apply italic formatting to text.
Args:
text (str): Text to make italic
Returns:
Text with italic formatting codes
"""
def underline(text: str) -> str:
"""
Apply underline formatting to text.
Args:
text (str): Text to underline
Returns:
Text with underline formatting codes
"""
def strikethrough(text: str) -> str:
"""
Apply strikethrough formatting to text.
Args:
text (str): Text to strikethrough
Returns:
Text with strikethrough formatting codes
"""
def monospace(text: str) -> str:
"""
Apply monospace formatting to text.
Args:
text (str): Text to make monospace
Returns:
Text with monospace formatting codes
"""
def reverse(text: str) -> str:
"""
Apply reverse formatting to text.
Args:
text (str): Text to reverse colors
Returns:
Text with reverse formatting codes
"""
def color(text: str, fg: int = None, bg: int = None) -> str:
"""
Apply color formatting to text.
Args:
text (str): Text to colorize
fg (int): Foreground color code (0-15)
bg (int): Background color code (0-15)
Returns:
Text with color formatting codes
"""
def hex_color(text: str, fg: str = None, bg: str = None) -> str:
"""
Apply hexadecimal color formatting to text.
Args:
text (str): Text to colorize
fg (str): Foreground hex color (e.g., "#FF0000")
bg (str): Background hex color
Returns:
Text with hex color formatting codes
"""
def plain(text: str) -> str:
"""
Remove all formatting codes from text.
Args:
text (str): Text to strip formatting from
Returns:
Plain text without formatting codes
"""
def hex_color(text: str, fg: str = None, bg: str = None) -> str:
"""
Apply hex color formatting to text.
Args:
text (str): Text to color
fg (str, optional): Foreground hex color (e.g., '#FF0000')
bg (str, optional): Background hex color (e.g., '#00FF00')
Returns:
Text with hex color formatting codes
"""
def strikethrough(text: str) -> str:
"""
Apply strikethrough formatting to text.
Args:
text (str): Text to strike through
Returns:
Text with strikethrough formatting codes
"""
def monospace(text: str) -> str:
"""
Apply monospace formatting to text.
Args:
text (str): Text to make monospace
Returns:
Text with monospace formatting codes
"""
def reverse(text: str) -> str:
"""
Apply reverse-color formatting to text.
Args:
text (str): Text to reverse colors
Returns:
Text with reverse-color formatting codes
"""
# Formatting constants
CONTROL_NORMAL: str # Reset all formatting
CONTROL_COLOR: str # Color control code
CONTROL_HEX_COLOR: str # Hex color control code
CONTROL_BOLD: str # Bold control code
CONTROL_ITALIC: str # Italic control code
CONTROL_UNDERLINE: str # Underline control code
CONTROL_STRIKETHROUGH: str # Strikethrough control code
CONTROL_MONOSPACE: str # Monospace control code
CONTROL_REVERSE: str # Reverse control code
# Color enumeration
class colors(enum.Enum):
"""Standard IRC color codes."""
WHITE: int = 0
BLACK: int = 1
BLUE: int = 2
GREEN: int = 3
RED: int = 4
BROWN: int = 5
PURPLE: int = 6
ORANGE: int = 7
YELLOW: int = 8
LIGHT_GREEN: int = 9
TEAL: int = 10
LIGHT_CYAN: int = 11
LIGHT_BLUE: int = 12
PINK: int = 13
GREY: int = 14
LIGHT_GREY: int = 15Functions for time formatting, timezone handling, and duration calculations.
def validate_timezone(zone: str) -> str:
"""
Validate and normalize timezone string.
Args:
zone (str): Timezone identifier
Returns:
Normalized timezone string
Raises:
ValueError: If timezone is invalid
"""
def validate_format(tformat: str) -> str:
"""
Validate time format string.
Args:
tformat (str): Time format string
Returns:
Validated format string
Raises:
ValueError: If format is invalid
"""
def get_nick_timezone(db: 'SopelDB', nick: str) -> str | None:
"""
Get timezone preference for a user.
Args:
db (SopelDB): Database connection
nick (str): User nickname
Returns:
User's timezone or None if not set
"""
def get_channel_timezone(db: 'SopelDB', channel: str) -> str | None:
"""
Get timezone preference for a channel.
Args:
db (SopelDB): Database connection
channel (str): Channel name
Returns:
Channel's timezone or None if not set
"""
def get_timezone(db: 'SopelDB' = None, config: 'Config' = None, zone: str = None,
nick: str = None, channel: str = None) -> str:
"""
Get timezone from various sources with fallback logic.
Args:
db (SopelDB): Database connection
config (Config): Bot configuration
zone (str): Explicit timezone
nick (str): User nickname to check
channel (str): Channel name to check
Returns:
Resolved timezone string
"""
def format_time(dt=None, zone=None, format=None) -> str:
"""
Format datetime with timezone and format options.
Args:
dt: Datetime object (defaults to now)
zone (str): Timezone for formatting
format (str): Time format string
Returns:
Formatted time string
"""
def seconds_to_split(seconds: int) -> 'Duration':
"""
Convert seconds to Duration namedtuple.
Args:
seconds (int): Number of seconds
Returns:
Duration with years, days, hours, minutes, seconds
"""
def get_time_unit(unit: str) -> int:
"""
Get number of seconds in a time unit.
Args:
unit (str): Time unit name (second, minute, hour, day, week, etc.)
Returns:
Number of seconds in the unit
"""
def seconds_to_human(seconds: int, precision: int = 2) -> str:
"""
Convert seconds to human-readable duration string.
Args:
seconds (int): Number of seconds
precision (int): Number of time units to include
Returns:
Human-readable duration (e.g., "2 hours, 30 minutes")
"""
# Duration namedtuple
Duration = namedtuple('Duration', ['years', 'days', 'hours', 'minutes', 'seconds'])Functions for web requests, user agent management, and HTTP operations.
def get_user_agent() -> str:
"""
Get default User-Agent string for web requests.
Returns:
User-Agent string identifying Sopel
"""
def get_session() -> 'requests.Session':
"""
Get configured requests session with appropriate headers.
Returns:
Requests session object with Sopel User-Agent
"""
# Additional web utilities available in sopel.tools.web moduleSafe mathematical expression evaluation and utility functions.
def eval_equation(equation: str) -> float:
"""
Safely evaluate mathematical expression.
Args:
equation (str): Mathematical expression to evaluate
Returns:
Result of the calculation
Raises:
ValueError: If expression is invalid or unsafe
"""
def guarded_mul(left: float, right: float) -> float:
"""
Multiply two numbers with overflow protection.
Args:
left (float): First number
right (float): Second number
Returns:
Product of the numbers
Raises:
ValueError: If result would overflow
"""
def guarded_pow(num: float, exp: float) -> float:
"""
Raise number to power with complexity protection.
Args:
num (float): Base number
exp (float): Exponent
Returns:
Result of num ** exp
Raises:
ValueError: If operation is too complex
"""
def pow_complexity(num: int, exp: int) -> float:
"""
Calculate complexity score for power operation.
Args:
num (int): Base number
exp (int): Exponent
Returns:
Complexity score for the operation
"""IRC identifier management with proper case-insensitive comparison.
class Identifier(str):
"""
IRC identifier with RFC1459 case-insensitive comparison.
Handles IRC nickname and channel name comparison properly.
"""
def __new__(cls, identifier: str) -> 'Identifier':
"""Create new identifier instance."""
def lower(self) -> str:
"""Get RFC1459 lowercase version of identifier."""
class IdentifierFactory:
"""Factory for creating Identifier instances."""
def __init__(self, case_mapping: str = 'rfc1459'):
"""
Initialize identifier factory.
Args:
case_mapping (str): Case mapping to use ('rfc1459' or 'ascii')
"""
def __call__(self, identifier: str) -> Identifier:
"""Create identifier using this factory's case mapping."""
def ascii_lower(text: str) -> str:
"""
Convert text to lowercase using ASCII rules.
Args:
text (str): Text to convert
Returns:
Lowercase text using ASCII case mapping
"""Utility classes for storing data with identifier-based access.
class SopelMemory(dict):
"""
Dictionary subclass for storing bot data.
Provides additional utility methods for data management.
"""
def __init__(self):
"""Initialize empty memory storage."""
def lock(self, key: str) -> 'threading.Lock':
"""
Get thread lock for a specific key.
Args:
key (str): Key to get lock for
Returns:
Threading lock object
"""
class SopelIdentifierMemory(SopelMemory):
"""
Memory storage using IRC identifiers as keys.
Provides case-insensitive access using IRC identifier rules.
"""
def __init__(self, identifier_factory: IdentifierFactory = None):
"""
Initialize identifier memory.
Args:
identifier_factory (IdentifierFactory): Factory for creating identifiers
"""
class SopelMemoryWithDefault(SopelMemory):
"""
Memory storage with default value factory.
Automatically creates default values for missing keys.
"""
def __init__(self, default_factory: callable = None):
"""
Initialize memory with default factory.
Args:
default_factory (callable): Function to create default values
"""Functions for plugin logging and message output.
def get_logger(plugin_name: str) -> 'logging.Logger':
"""
Get logger instance for a plugin.
Args:
plugin_name (str): Name of the plugin
Returns:
Logger configured for the plugin
"""
def get_sendable_message(text: str, max_length: int = 400) -> tuple[str, str]:
"""
Split text into sendable message and excess.
Args:
text (str): Text to split
max_length (int): Maximum message length in bytes
Returns:
Tuple of (sendable_text, excess_text)
"""
def get_hostmask_regex(mask: str) -> 'Pattern':
"""
Create regex pattern for IRC hostmask matching.
Args:
mask (str): Hostmask pattern with wildcards
Returns:
Compiled regex pattern for matching
"""
def chain_loaders(*lazy_loaders) -> callable:
"""
Chain multiple lazy loader functions together.
Args:
*lazy_loaders: Lazy loader functions
Returns:
Combined lazy loader function
"""from sopel import plugin
from sopel.formatting import bold, color, italic, plain
@plugin.command('format')
@plugin.example('.format Hello World')
def format_example(bot, trigger):
"""Demonstrate text formatting."""
text = trigger.group(2) or "Sample Text"
# Apply various formatting
formatted_examples = [
bold(text),
italic(text),
color(text, colors.RED),
color(text, colors.WHITE, colors.BLUE),
bold(color(text, colors.GREEN))
]
for example in formatted_examples:
bot.say(example)
# Show plain text version
bot.say(f"Plain: {plain(formatted_examples[0])}")
@plugin.command('rainbow')
def rainbow_text(bot, trigger):
"""Create rainbow-colored text."""
text = trigger.group(2) or "RAINBOW"
rainbow_colors = [colors.RED, colors.ORANGE, colors.YELLOW,
colors.GREEN, colors.BLUE, colors.PURPLE]
colored_chars = []
for i, char in enumerate(text):
if char != ' ':
color_code = rainbow_colors[i % len(rainbow_colors)]
colored_chars.append(color(char, color_code))
else:
colored_chars.append(char)
bot.say(''.join(colored_chars))from sopel import plugin
from sopel.tools.time import format_time, seconds_to_human, get_time_unit
@plugin.command('time')
@plugin.example('.time UTC')
def time_command(bot, trigger):
"""Show current time in specified timezone."""
zone = trigger.group(2) or 'UTC'
try:
current_time = format_time(zone=zone, format='%Y-%m-%d %H:%M:%S %Z')
bot.reply(f"Current time in {zone}: {current_time}")
except ValueError as e:
bot.reply(f"Invalid timezone: {e}")
@plugin.command('uptime')
def uptime_command(bot, trigger):
"""Show bot uptime."""
import time
# Calculate uptime (this would need to be tracked elsewhere)
uptime_seconds = int(time.time() - bot.start_time) # Hypothetical
uptime_human = seconds_to_human(uptime_seconds)
bot.reply(f"Bot uptime: {uptime_human}")
@plugin.command('remind')
@plugin.example('.remind 30m Check the logs')
def remind_command(bot, trigger):
"""Set a reminder with time parsing."""
args = trigger.group(2)
if not args:
bot.reply("Usage: .remind <time> <message>")
return
parts = args.split(' ', 1)
if len(parts) < 2:
bot.reply("Usage: .remind <time> <message>")
return
time_str, message = parts
# Parse time string (simplified)
try:
if time_str.endswith('m'):
minutes = int(time_str[:-1])
seconds = minutes * get_time_unit('minute')
elif time_str.endswith('h'):
hours = int(time_str[:-1])
seconds = hours * get_time_unit('hour')
else:
seconds = int(time_str)
# Schedule reminder (would need actual scheduling)
bot.reply(f"Reminder set for {seconds_to_human(seconds)}: {message}")
except ValueError:
bot.reply("Invalid time format. Use: 30m, 2h, or seconds")from sopel import plugin
from sopel.tools.calculation import eval_equation
@plugin.command('calc')
@plugin.example('.calc 2 + 2 * 3')
def calc_command(bot, trigger):
"""Safely calculate mathematical expressions."""
expression = trigger.group(2)
if not expression:
bot.reply("Usage: .calc <expression>")
return
try:
result = eval_equation(expression)
bot.reply(f"{expression} = {result}")
except ValueError as e:
bot.reply(f"Calculation error: {e}")
except Exception as e:
bot.reply(f"Invalid expression: {e}")
@plugin.command('convert')
@plugin.example('.convert 100 F to C')
def convert_command(bot, trigger):
"""Temperature conversion with calculations."""
args = trigger.group(2)
if not args:
bot.reply("Usage: .convert <temp> <F|C> to <C|F>")
return
parts = args.split()
if len(parts) != 4 or parts[2].lower() != 'to':
bot.reply("Usage: .convert <temp> <F|C> to <C|F>")
return
try:
temp = float(parts[0])
from_unit = parts[1].upper()
to_unit = parts[3].upper()
if from_unit == 'F' and to_unit == 'C':
result = (temp - 32) * 5/9
bot.reply(f"{temp}°F = {result:.2f}°C")
elif from_unit == 'C' and to_unit == 'F':
result = temp * 9/5 + 32
bot.reply(f"{temp}°C = {result:.2f}°F")
else:
bot.reply("Supported conversions: F to C, C to F")
except ValueError:
bot.reply("Invalid temperature value")from sopel import plugin
from sopel.tools import Identifier, SopelMemory
# Plugin-level memory storage
user_scores = SopelMemory()
@plugin.command('score')
def score_command(bot, trigger):
"""Show or modify user scores."""
args = trigger.group(2)
if not args:
# Show current user's score
nick = Identifier(trigger.nick)
score = user_scores.get(nick, 0)
bot.reply(f"Your score: {score}")
return
parts = args.split()
if len(parts) == 1:
# Show specified user's score
nick = Identifier(parts[0])
score = user_scores.get(nick, 0)
bot.reply(f"Score for {nick}: {score}")
elif len(parts) == 2:
# Set score (admin only)
if trigger.nick not in bot.settings.core.admins:
bot.reply("Only admins can set scores")
return
nick = Identifier(parts[0])
try:
new_score = int(parts[1])
user_scores[nick] = new_score
bot.reply(f"Set score for {nick} to {new_score}")
except ValueError:
bot.reply("Score must be a number")
@plugin.command('leaderboard')
def leaderboard_command(bot, trigger):
"""Show score leaderboard."""
if not user_scores:
bot.reply("No scores recorded yet")
return
# Sort by score (descending)
sorted_scores = sorted(user_scores.items(), key=lambda x: x[1], reverse=True)
top_5 = sorted_scores[:5]
leaderboard = ["🏆 Leaderboard:"]
for i, (nick, score) in enumerate(top_5, 1):
leaderboard.append(f"{i}. {nick}: {score}")
bot.reply(" | ".join(leaderboard))from sopel import plugin
from sopel.tools import get_logger
# Get plugin-specific logger
LOGGER = get_logger('my_plugin')
@plugin.command('debug')
@plugin.require_admin()
def debug_command(bot, trigger):
"""Show debug information."""
args = trigger.group(2)
if args == 'memory':
# Show memory usage information
import psutil
process = psutil.Process()
memory_mb = process.memory_info().rss / 1024 / 1024
bot.reply(f"Memory usage: {memory_mb:.1f} MB")
elif args == 'channels':
# Show channel information
channel_count = len(bot.channels)
channel_list = list(bot.channels.keys())[:5] # First 5
bot.reply(f"In {channel_count} channels: {', '.join(channel_list)}")
elif args == 'users':
# Show user information
user_count = len(bot.users)
bot.reply(f"Tracking {user_count} users")
else:
bot.reply("Debug options: memory, channels, users")
# Log debug information
LOGGER.info(f"Debug command used by {trigger.nick}: {args}")
@plugin.command('log')
@plugin.require_owner()
def log_test(bot, trigger):
"""Test logging at different levels."""
message = trigger.group(2) or "Test message"
LOGGER.debug(f"Debug: {message}")
LOGGER.info(f"Info: {message}")
LOGGER.warning(f"Warning: {message}")
LOGGER.error(f"Error: {message}")
bot.reply("Log messages sent at all levels")# Control character constants
CONTROL_FORMATTING: list # List of all formatting control chars
CONTROL_NON_PRINTING: list # List of non-printing control charsDuration = namedtuple('Duration', ['years', 'days', 'hours', 'minutes', 'seconds'])# Type aliases for memory storage
IdentifierMemory = SopelIdentifierMemory
Memory = SopelMemory
MemoryWithDefault = SopelMemoryWithDefaultInstall with Tessl CLI
npx tessl i tessl/pypi-sopel