A modern, Swift-native IRC client library built with Swift 6 concurrency features.
- Modern Swift Concurrency: Built from the ground up with async/await, actors, and structured concurrency
- Type-Safe: Strongly typed messages, commands, and responses
- AsyncStream Events: Real-time event streaming using Swift's AsyncStream
- IRCv3 Support: CAP negotiation, SASL authentication, message tags, and more
- Aggregated Queries: Clean API for multi-message responses (WHOIS, NAMES, WHO, LIST, MOTD)
- Rate Limiting: Built-in configurable rate limiting to prevent flooding
- TLS Support: Secure connections via Network.framework
- Actor-Isolated: Thread-safe by design using Swift actors
- Comprehensive Parsing: Full IRC message parsing including tags, prefix, and parameters
- iOS 18.0+ / macOS 15.0+
- Swift 6.2+
- Xcode 16.0+
Add the following to your Package.swift
:
dependencies: [
.package(url: "https://github.com/nathanborror/swift-irc-generated", branch: "main")
]
Or add it via Xcode: File → Add Package Dependencies
import IRC
// Configure the client
let config = Client.Config(
server: "irc.libera.chat",
port: 6697,
useTLS: true,
nick: "SwiftBot",
username: "swiftbot",
realname: "Swift IRC Bot"
)
// Create transport and client
let transport = NWTransport()
let client = Client(config: config, transport: transport)
// Connect
try await client.connect()
// Listen for events
Task {
for await event in client.events {
switch event {
case .registered:
print("✅ Connected and registered!")
try await client.join("#swift")
case .privmsg(let target, let sender, let text, _):
print("[\(target)] <\(sender)> \(text)")
if text.hasPrefix("!hello") {
try await client.privmsg(target, "Hello, \(sender)!")
}
case .join(let channel, let nick, _):
print("→ \(nick) joined \(channel)")
case .part(let channel, let nick, let reason, _):
print("← \(nick) left \(channel)" + (reason.map { ": \($0)" } ?? ""))
case .error(let error):
print("❌ Error: \(error)")
case .disconnected(let error):
print("Disconnected: \(error?.localizedDescription ?? "cleanly")")
default:
break
}
}
}
// Wait for registration
await client.awaitRegistered()
// Send messages
try await client.privmsg("#swift", "Hello from Swift!")
The Client.Config
provides extensive configuration options:
let config = Client.Config(
server: "irc.libera.chat",
port: 6697,
useTLS: true,
nick: "MyBot",
username: "mybot", // Defaults to nick
realname: "My IRC Bot", // Defaults to nick
password: nil, // Server password (not NickServ)
sasl: .plain( // SASL authentication
username: "mybot",
password: "secret"
),
requestedCaps: [ // IRCv3 capabilities
"sasl",
"echo-message",
"message-tags",
"server-time",
"account-tag",
"extended-join",
"multi-prefix"
],
autoReconnect: false, // Auto-reconnect on disconnect
reconnectDelay: 5.0, // Seconds between reconnect attempts
pingTimeout: 120.0, // Seconds before ping timeout
rateLimit: .default // Rate limiting strategy
)
Configure rate limiting to prevent flooding:
// Default: 5 messages per 2 seconds
config.rateLimit = .default
// Custom rate limit
config.rateLimit = Client.Config.RateLimit(
messagesPerWindow: 10,
windowDuration: 5.0
)
// No rate limiting (not recommended)
config.rateLimit = .none
Authenticate with services using SASL:
// SASL PLAIN
config.sasl = .plain(
username: "mybot",
password: "mypassword"
)
// SASL EXTERNAL (for CertFP)
config.sasl = .external
// Join/Part channels
try await client.join("#channel")
try await client.join("#secret", key: "password")
try await client.part("#channel")
try await client.part("#channel", reason: "Goodbye!")
// Send messages
try await client.privmsg("#channel", "Hello!")
try await client.notice("#channel", "Notice message")
try await client.privmsg("Username", "Private message")
// Change nick
try await client.setNick("NewNick")
// Topic management
try await client.setTopic("#channel", topic: "New topic")
try await client.getTopic("#channel")
// Channel moderation
try await client.kick("#channel", nick: "BadUser", reason: "Spam")
try await client.invite("Friend", to: "#private")
try await client.setMode("#channel", modes: "+m")
try await client.setMode("MyNick", modes: "+i")
// Away status
try await client.away("Be right back")
try await client.away() // Clear away status
Some IRC commands return multiple messages. The library aggregates these automatically:
// WHOIS - Get detailed user information
let whois = try await client.whois("SomeUser")
print("Nick: \(whois.nick)")
print("Username: \(whois.username ?? "unknown")")
print("Host: \(whois.host ?? "unknown")")
print("Real name: \(whois.realname ?? "unknown")")
print("Channels: \(whois.channels.joined(separator: ", "))")
print("Idle: \(whois.idleSeconds ?? 0) seconds")
// NAMES - Get all users in a channel
let names = try await client.names("#swift")
print("Users in \(names.channel): \(names.names.count)")
for name in names.names {
print(" \(name)")
}
// WHO - Get detailed channel/user information
let who = try await client.who("#swift")
for entry in who.entries {
print("\(entry.nick): \(entry.username)@\(entry.host)")
}
// LIST - Get list of channels
let list = try await client.list()
for channel in list.entries {
print("\(channel.channel) (\(channel.userCount)): \(channel.topic)")
}
// MOTD - Get server message of the day
let motd = try await client.motd()
for line in motd.lines {
print(line)
}
The client provides a rich event stream:
for await event in client.events {
switch event {
case .connected:
print("Connected to server")
case .registered:
print("Registration complete")
case .disconnected(let error):
print("Disconnected: \(error?.localizedDescription ?? "cleanly")")
case .privmsg(let target, let sender, let text, let message):
// target: channel or your nick
// sender: who sent it
// text: message content
// message: full Message struct with tags, etc.
print("[\(target)] <\(sender)> \(text)")
case .notice(let target, let sender, let text, _):
print("[\(target)] -\(sender)- \(text)")
case .join(let channel, let nick, _):
print("\(nick) joined \(channel)")
case .part(let channel, let nick, let reason, _):
print("\(nick) left \(channel)")
case .quit(let nick, let reason, _):
print("\(nick) quit: \(reason ?? "")")
case .kick(let channel, let kicked, let by, let reason, _):
print("\(kicked) was kicked from \(channel) by \(by): \(reason ?? "")")
case .nick(let oldNick, let newNick, _):
print("\(oldNick) is now known as \(newNick)")
case .topic(let channel, let topic, _):
print("Topic for \(channel): \(topic ?? "no topic")")
case .mode(let target, let modes, _):
print("Mode \(modes) on \(target)")
case .error(let error):
print("Error: \(error)")
case .message(let message):
// All raw messages come through here too
// Use for handling custom numeric replies or extensions
print("Raw: \(message.raw)")
}
}
Messages are automatically parsed with rich metadata:
let message = Message.parse(":nick!user@host PRIVMSG #channel :Hello world")
print(message.prefix) // "nick!user@host"
print(message.nick) // "nick"
print(message.user) // "user"
print(message.host) // "host"
print(message.command) // "PRIVMSG"
print(message.params) // ["#channel", "Hello world"]
print(message.target) // "#channel"
print(message.text) // "Hello world"
print(message.channel) // "#channel"
// IRCv3 tags
let taggedMessage = Message.parse(
"@time=2024-01-01T12:00:00.000Z :nick!user@host PRIVMSG #channel :Hi"
)
print(taggedMessage.tags["time"]) // "2024-01-01T12:00:00.000Z"
// Numeric replies
let numericMsg = Message.parse(":server 001 nick :Welcome!")
print(numericMsg.isNumeric) // true
print(numericMsg.numericCode) // 1
print(numericMsg.numericName) // "RPL_WELCOME"
- Client (Actor): Main interface, handles connection lifecycle and message routing
- Transport (Protocol): Abstraction for network I/O
NWTransport
: Production implementation using Network.frameworkMockTransport
: Testing implementation with in-memory streams
- Message: Parsed IRC message with tags, prefix, command, and parameters
- Command: Type-safe outgoing command builder
- Aggregations: Actors that collect multi-message responses
The library leverages Swift's modern concurrency features:
- Actor isolation ensures thread-safe state management
- AsyncStream provides backpressure-aware event streaming
- Structured concurrency with task groups for managing I/O loops
- Continuations bridge callback-based network APIs with async/await
┌─────────────┐
│ Network │
└──────┬──────┘
│ readLine()
▼
┌─────────────┐
│ Read Loop │
└──────┬──────┘
│ parse()
▼
┌─────────────┐
│ Message │
└──────┬──────┘
│
├─────────► Aggregations (WHOIS, NAMES, etc.)
│
├─────────► Protocol Handlers (CAP, SASL, PING)
│
└─────────► Event Stream (user consumption)
- Disconnected →
connect()
- Connecting → Transport opens socket
- Connected → Start I/O loops
- Registering → CAP LS, NICK, USER, SASL (if configured)
- Registered → Ready for commands, emit
.registered
event - Disconnected → Cleanup, emit
.disconnected
event
Use MockTransport
for testing without real connections:
import IRC
let transport = MockTransport()
let client = Client(config: config, transport: transport)
// Queue server responses
await transport.queueRead(":server 001 nick :Welcome!")
await transport.queueRead(":server 376 nick :End of MOTD")
try await client.connect()
await client.awaitRegistered()
// Verify sent commands
let written = await transport.getWrittenLines()
XCTAssertTrue(written.contains("NICK SwiftBot"))
let transport = NWTransport()
let config = Client.Config(
server: "irc.libera.chat",
port: 6697,
useTLS: true,
nick: "EchoBot"
)
let client = Client(config: config, transport: transport)
try await client.connect()
await client.awaitRegistered()
try await client.join("#bots")
for await event in client.events {
if case .privmsg(let target, let sender, let text, _) = event {
if text.hasPrefix("!echo ") {
let reply = String(text.dropFirst(6))
try await client.privmsg(target, "\(sender): \(reply)")
}
}
}
import Foundation
for await event in client.events {
if case .privmsg(let target, _, let text, _) = event {
if let url = extractURL(from: text) {
if let title = try? await fetchTitle(from: url) {
try await client.privmsg(target, "📎 \(title)")
}
}
}
}
func extractURL(from text: String) -> URL? {
// URL extraction logic
}
func fetchTitle(from url: URL) async throws -> String {
// Fetch and parse HTML title
}
import Foundation
let logger = FileHandle(forWritingAtPath: "irc.log")!
for await event in client.events {
let timestamp = ISO8601DateFormatter().string(from: Date())
switch event {
case .privmsg(let target, let sender, let text, _):
let line = "[\(timestamp)] [\(target)] <\(sender)> \(text)\n"
logger.write(line.data(using: .utf8)!)
case .join(let channel, let nick, _):
let line = "[\(timestamp)] [\(channel)] → \(nick) joined\n"
logger.write(line.data(using: .utf8)!)
// ... other events
default:
break
}
}
For advanced use cases, you can send raw IRC commands:
// Send raw command
try await client.sendRaw("PRIVMSG #channel :Hello")
// Use Command enum (encodes automatically)
try await client.send(.raw("MODE #channel +m"))
// Access raw messages
for await event in client.events {
if case .message(let message) = event {
// Handle any message type
print("Command: \(message.command)")
print("Params: \(message.params)")
print("Raw: \(message.raw)")
// Check numeric codes
if let code = message.numericCode {
switch code {
case 353: // RPL_NAMREPLY
print("Names: \(message.text ?? "")")
default:
break
}
}
}
}
do {
try await client.connect()
await client.awaitRegistered()
try await client.join("#channel")
} catch let error as TransportError {
print("Transport error: \(error)")
} catch let error as ClientError {
print("Client error: \(error)")
} catch {
print("Unknown error: \(error)")
}
- Always await registration before sending commands (except CAP, NICK, USER, PASS)
- Handle disconnections gracefully and implement reconnection logic if needed
- Use rate limiting to avoid being kicked for flooding
- Process events asynchronously using separate tasks for long-running operations
- Clean up by calling
disconnect()
when done
- The client uses a single actor for thread safety, which serializes all operations
- Event processing is async, so slow handlers won't block the read loop
- Rate limiting prevents server-side throttling but adds latency to high-volume bots
- Message parsing is lazy where possible (tags, prefix parsing)
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
MIT License - see LICENSE file for details
Built with ❤️ using Swift 6 and modern concurrency features.