ObjIRC  Documentation

/*
 * Copyright (c) 2010, 2011, 2012, 2013, 2016, 2017, 2018, 2021, 2024
 *   Jonathan Schleifer <js@nil.im>
 *
 * https://fossil.nil.im/objirc
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice is present in all copies.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */

#define IRC_CONNECTION_M

#include <stdarg.h>

#import <ObjFW/ObjFW.h>

#import "IRCConnection.h"
#import "IRCUser.h"

@interface IRCConnection () <OFTCPSocketDelegate>
@end

@implementation IRCConnection
@synthesize socketClass = _socketClass;
@synthesize server = _server, port = _port;
@synthesize nickname = _nickname, username = _username, realname = _realname;
@synthesize delegate = _delegate, socket = _socket;
@synthesize fallbackEncoding = _fallbackEncoding;
@synthesize pingInterval = _pingInterval, pingTimeout = _pingTimeout;

+ (instancetype)connection
{
	return [[[self alloc] init] autorelease];
}

- (instancetype)init
{
	self = [super init];

	@try {
		_socketClass = [OFTCPSocket class];
		_channels = [[OFMutableDictionary alloc] init];
		_port = 6667;
		_fallbackEncoding = OFStringEncodingISO8859_1;
		_pingInterval = 120;
		_pingTimeout = 30;
	} @catch (id e) {
		[self release];
		@throw e;
	}

	return self;
}

- (void)dealloc
{
	[_socket release];
	[_server release];
	[_nickname release];
	[_username release];
	[_realname release];
	[_channels release];
	[_pingData release];
	[_pingTimer release];

	[super dealloc];
}

- (void)connect
{
	void *pool = objc_autoreleasePoolPush();

	if (_socket != nil)
		@throw [OFAlreadyOpenException exceptionWithObject: self];

	_socket = [[_socketClass alloc] init];
	[_socket setDelegate: self];
	[_socket asyncConnectToHost: _server port: _port];

	objc_autoreleasePoolPop(pool);
}

-     (void)socket: (OF_KINDOF(OFTCPSocket *))socket
  didConnectToHost: (OFString *)host
	      port: (uint16_t)port
	 exception: (id)exception
{
	if (exception != nil) {
		if ([_delegate respondsToSelector:
		    @selector(connection:didFailToConnectWithException:)])
			[_delegate	       connection: self
			    didFailToConnectWithException: exception];

		return;
	}

	if ([_delegate respondsToSelector:
	    @selector(connection:didCreateSocket:)])
		[_delegate connection: self didCreateSocket: _socket];

	[self sendLineWithFormat: @"NICK %@", _nickname];
	[self sendLineWithFormat: @"USER %@ * 0 :%@", _username, _realname];

	[socket asyncReadLine];
}

- (void)disconnect
{
	[self disconnectWithReason: nil];
}

- (void)disconnectWithReason: (OFString *)reason
{
	void *pool = objc_autoreleasePoolPush();

	reason = [[reason componentsSeparatedByString: @"\n"] firstObject];

	if (reason == nil)
		[self sendLine: @"QUIT"];
	else
		[self sendLineWithFormat: @"QUIT :%@", reason];

	objc_autoreleasePoolPop(pool);
}

- (void)joinChannel: (OFString *)channel
{
	void *pool = objc_autoreleasePoolPush();

	channel = [channel componentsSeparatedByString: @"\n"].firstObject;

	[self sendLineWithFormat: @"JOIN %@", channel];

	objc_autoreleasePoolPop(pool);
}

- (void)leaveChannel: (OFString *)channel
{
	[self leaveChannel: channel reason: nil];
}

- (void)leaveChannel: (OFString *)channel reason: (OFString *)reason
{
	void *pool = objc_autoreleasePoolPush();

	channel = [channel componentsSeparatedByString: @"\n"].firstObject;
	reason = [reason componentsSeparatedByString: @"\n"].firstObject;

	if (reason == nil)
		[self sendLineWithFormat: @"PART %@", channel];
	else
		[self sendLineWithFormat: @"PART %@ :%@", channel, reason];

	[_channels removeObjectForKey: channel];

	objc_autoreleasePoolPop(pool);
}

- (void)sendLine: (OFString *)line
{
	if ([_delegate respondsToSelector: @selector(connection:didSendLine:)])
		[_delegate connection: self didSendLine: line];

	[_socket writeLine: line];
}

- (void)sendLineWithFormat: (OFConstantString *)format, ...
{
	void *pool = objc_autoreleasePoolPush();
	OFString *line;
	va_list args;

	va_start(args, format);
	line = [[[OFString alloc] initWithFormat: format
				       arguments: args] autorelease];
	va_end(args);

	[self sendLine: line];

	objc_autoreleasePoolPop(pool);
}

- (void)sendMessage: (OFString *)message to: (OFString *)to
{
	void *pool = objc_autoreleasePoolPush();

	for (OFString *line in [message componentsSeparatedByString: @"\n"])
		[self sendLineWithFormat: @"PRIVMSG %@ :%@", to, line];

	objc_autoreleasePoolPop(pool);
}

- (void)sendNotice: (OFString *)notice to: (OFString *)to
{
	void *pool = objc_autoreleasePoolPush();

	for (OFString *line in [notice componentsSeparatedByString: @"\n"])
		[self sendLineWithFormat: @"NOTICE %@ :%@", to, line];

	objc_autoreleasePoolPop(pool);
}

- (void)kickUser: (OFString *)user
	 channel: (OFString *)channel
	  reason: (OFString *)reason
{
	void *pool = objc_autoreleasePoolPush();

	reason = [[reason componentsSeparatedByString: @"\n"] firstObject];

	[self sendLineWithFormat: @"KICK %@ %@ :%@", channel, user, reason];

	objc_autoreleasePoolPop(pool);
}

- (void)changeNicknameTo: (OFString *)nickname
{
	void *pool = objc_autoreleasePoolPush();

	nickname = [nickname componentsSeparatedByString: @"\n"].firstObject;

	[self sendLineWithFormat: @"NICK %@", nickname];

	objc_autoreleasePoolPop(pool);
}

- (void)irc_processLine: (OFString *)line
{
	OFArray *components;
	OFString *action = nil;

	if ([_delegate respondsToSelector:
	    @selector(connection:didReceiveLine:)])
		[_delegate connection: self didReceiveLine: line];

	components = [line componentsSeparatedByString: @" "];

	/* PING */
	if (components.count == 2 &&
	    [components.firstObject isEqual: @"PING"]) {
		OFMutableString *s = [[line mutableCopy] autorelease];
		[s replaceCharactersInRange: OFMakeRange(0, 4)
				 withString: @"PONG"];
		[self sendLine: s];

		return;
	}

	/* PONG */
	if (components.count == 4 &&
	    [[components objectAtIndex: 1] isEqual: @"PONG"] &&
	    [[components objectAtIndex: 3] isEqual: _pingData]) {
		[_pingTimer invalidate];

		[_pingData release];
		[_pingTimer release];

		_pingData = nil;
		_pingTimer = nil;
	}

	action = [[components objectAtIndex: 1] uppercaseString];

	/* Connected */
	if ([action isEqual: @"001"] && components.count >= 4) {
		if ([_delegate respondsToSelector:
		    @selector(connectionWasEstablished:)])
			[_delegate connectionWasEstablished: self];

		[OFTimer scheduledTimerWithTimeInterval: _pingInterval
						 target: self
					       selector: @selector(irc_sendPing)
						repeats: true];

		return;
	}

	/* JOIN */
	if ([action isEqual: @"JOIN"] && components.count == 3) {
		OFString *who = [components objectAtIndex: 0];
		OFString *where = [components objectAtIndex: 2];
		IRCUser *user;
		OFMutableSet *channel;

		who = [who substringFromIndex: 1];
		user = [IRCUser userWithString: who];

		if ([who hasPrefix:
		    [_nickname stringByAppendingString: @"!"]]) {
			channel = [OFMutableSet set];
			[_channels setObject: channel forKey: where];
		} else
			channel = [_channels objectForKey: where];

		[channel addObject: user.nickname];

		if ([_delegate respondsToSelector:
		    @selector(connection:didSeeUser:joinChannel:)])
			[_delegate connection: self
				   didSeeUser: user
				  joinChannel: where];

		return;
	}

	/* NAMES reply */
	if ([action isEqual: @"353"] && components.count >= 6) {
		OFString *where;
		OFMutableSet *channel;
		OFArray *users;
		size_t pos;

		where = [components objectAtIndex: 4];

		if ((channel = [_channels objectForKey: where]) == nil) {
			/* We did not request that */
			return;
		}

		pos = [[components objectAtIndex: 0] length] +
		    [[components objectAtIndex: 1] length] +
		    [[components objectAtIndex: 2] length] +
		    [[components objectAtIndex: 3] length] +
		    [[components objectAtIndex: 4] length] + 6;

		users = [[line substringWithRange:
		    OFMakeRange(pos, line.length - pos)]
		    componentsSeparatedByString: @" "];

		for (OFString *user in users) {
			if ([user hasPrefix: @"@"] || [user hasPrefix: @"+"] ||
			    [user hasPrefix: @"%"] || [user hasPrefix: @"*"])
				user = [user substringFromIndex: 1];

			[channel addObject: user];
		}

		if ([_delegate respondsToSelector:
		    @selector(connection:didReceiveNamesForChannel:)])
			[_delegate	   connection: self
			    didReceiveNamesForChannel: where];

		return;
	}

	/* PART */
	if ([action isEqual: @"PART"] && [components count] >= 3) {
		OFString *who = [components objectAtIndex: 0];
		OFString *where = [components objectAtIndex: 2];
		IRCUser *user;
		OFMutableSet *channel;
		OFString *reason = nil;
		size_t pos = who.length + 1 +
		    [[components objectAtIndex: 1] length] + 1 + where.length;

		who = [who substringFromIndex: 1];
		user = [IRCUser userWithString: who];
		channel = [_channels objectForKey: where];

		if (components.count > 3)
			reason = [line substringFromIndex: pos + 2];

		[channel removeObject: user.nickname];

		if ([_delegate respondsToSelector:
		    @selector(connection:didSeeUser:leaveChannel:reason:)])
			[_delegate connection: self
				   didSeeUser: user
				 leaveChannel: where
				       reason: reason];

		return;
	}

	/* KICK */
	if ([action isEqual: @"KICK"] && components.count >= 4) {
		OFString *who = [components objectAtIndex: 0];
		OFString *where = [components objectAtIndex: 2];
		OFString *whom = [components objectAtIndex: 3];
		IRCUser *user;
		OFMutableSet *channel;
		OFString *reason = nil;
		size_t pos = who.length + 1 +
		    [[components objectAtIndex: 1] length] + 1 +
		    where.length + 1 + whom.length;

		who = [who substringFromIndex: 1];
		user = [IRCUser userWithString: who];
		channel = [_channels objectForKey: where];

		if (components.count > 4)
			reason = [line substringFromIndex: pos + 2];

		[channel removeObject: user.nickname];

		if ([_delegate respondsToSelector:
		    @selector(connection:didSeeUser:kickUser:channel:reason:)])
			[_delegate connection: self
				   didSeeUser: user
				     kickUser: whom
				      channel: where
				       reason: reason];

		return;
	}

	/* QUIT */
	if ([action isEqual: @"QUIT"] && components.count >= 2) {
		OFString *who = [components objectAtIndex: 0];
		IRCUser *user;
		OFString *reason = nil;
		size_t pos = who.length + 1 +
		    [[components objectAtIndex: 1] length];

		who = [who substringFromIndex: 1];
		user = [IRCUser userWithString: who];

		if ([components count] > 2)
			reason = [line substringFromIndex: pos + 2];

		for (OFString *channel in _channels)
			[[_channels objectForKey: channel]
			    removeObject: user.nickname];

		if ([_delegate respondsToSelector:
		    @selector(connection:didSeeUserQuit:reason:)])
			[_delegate connection: self
			       didSeeUserQuit: user
				       reason: reason];

		return;
	}

	/* NICK */
	if ([action isEqual: @"NICK"] && components.count == 3) {
		OFString *who = [components objectAtIndex: 0];
		OFString *nickname = [components objectAtIndex: 2];
		IRCUser *user;

		who = [who substringFromIndex: 1];
		nickname = [nickname substringFromIndex: 1];

		user = [IRCUser userWithString: who];

		if ([user.nickname isEqual: _nickname]) {
			[_nickname release];
			_nickname = [nickname copy];
		}

		for (OFMutableSet *channel in _channels) {
			if ([channel containsObject: user.nickname]) {
				[channel removeObject: user.nickname];
				[channel addObject: nickname];
			}
		}

		if ([_delegate respondsToSelector:
		    @selector(connection:didSeeUser:changeNicknameTo:)])
			[_delegate connection: self
				   didSeeUser: user
			     changeNicknameTo: nickname];

		return;
	}

	/* PRIVMSG */
	if ([action isEqual: @"PRIVMSG"] && components.count >= 4) {
		OFString *from = [components objectAtIndex: 0];
		OFString *to = [components objectAtIndex: 2];
		IRCUser *user;
		OFString *message;
		size_t pos = from.length + 1 +
		    [[components objectAtIndex: 1] length] + 1 + to.length;

		from = [from substringFromIndex: 1];
		message = [line substringFromIndex: pos + 2];
		user = [IRCUser userWithString: from];

		if (![to isEqual: _nickname]) {
			if ([_delegate respondsToSelector: @selector(connection:
			    didReceiveMessage:channel:user:)])
				[_delegate connection: self
				    didReceiveMessage: message
					      channel: to
						 user: user];
		} else {
			if ([_delegate respondsToSelector: @selector(connection:
			    didReceivePrivateMessage:user:)])
				[_delegate	  connection: self
				    didReceivePrivateMessage: message
							user: user];
		}

		return;
	}

	/* NOTICE */
	if ([action isEqual: @"NOTICE"] && components.count >= 4) {
		OFString *from = [components objectAtIndex: 0];
		OFString *to = [components objectAtIndex: 2];
		IRCUser *user = nil;
		OFString *notice;
		size_t pos = from.length + 1 +
		    [[components objectAtIndex: 1] length] + 1 + to.length;

		from = [from substringFromIndex: 1];
		notice = [line substringFromIndex: pos + 2];

		if (![from containsString: @"!"] || [to isEqual: @"*"]) {
			/* System message - ignore for now */
			return;
		}

		user = [IRCUser userWithString: from];

		if (![to isEqual: _nickname]) {
			if ([_delegate respondsToSelector: @selector(connection:
			    didReceiveNotice:channel:user:)])
				[_delegate connection: self
				     didReceiveNotice: notice
					      channel: to
						 user: user];
		} else {
			if ([_delegate respondsToSelector:
			    @selector(connection:didReceiveNotice:user:)])
				[_delegate connection: self
				     didReceiveNotice: notice
						 user: user];
		}

		return;
	}
}

- (void)irc_sendPing
{
	[_pingData release];
	[_pingTimer release];
	_pingData = nil;
	_pingTimer = nil;

	_pingData = [[OFString alloc] initWithFormat: @":%d", rand()];
	[_socket writeFormat: @"PING %@\r\n", _pingData];

	_pingTimer = [[OFTimer
	    scheduledTimerWithTimeInterval: _pingTimeout
				    target: self
				  selector: @selector(irc_pingTimeout)
				   repeats: false] retain];
}

- (void)irc_pingTimeout
{
	if ([_delegate respondsToSelector: @selector(connectionWasClosed:)])
		[_delegate connectionWasClosed: self];

	[_socket cancelAsyncRequests];
	[_socket release];
	_socket = nil;
}

- (bool)stream: (OF_KINDOF(OFStream *))stream
   didReadLine: (OFString *)line
     exception: (OFException *)exception
{
	if (line != nil) {
		[self irc_processLine: line];

		if (_fallbackEncodingUsed) {
			_fallbackEncodingUsed = false;
			[stream asyncReadLine];
			return false;
		}

		return true;
	}

	if ([exception isKindOfClass: [OFInvalidEncodingException class]]) {
		_fallbackEncodingUsed = true;
		[stream asyncReadLineWithEncoding: _fallbackEncoding];
		return false;
	}

	if ([_delegate respondsToSelector: @selector(connectionWasClosed:)])
		[_delegate connectionWasClosed: self];

	[_pingTimer invalidate];

	[_socket performSelector: @selector(cancelAsyncRequests) afterDelay: 0];
	[_socket release];
	_socket = nil;

	return false;
}

- (OFSet OF_GENERIC(OFString *) *)usersInChannel: (OFString *)channel
{
	return [[[_channels objectForKey: channel] copy] autorelease];
}
@end