ObjMatrix  Documentation

/*
 * Copyright (c) 2020, 2021, 2024 Jonathan Schleifer <js@nil.im>
 *
 * https://fl.nil.im/objmatrix
 *
 * 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 appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
 * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
 * AND FITNESS.  IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
 * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
 * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
 * OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
 * PERFORMANCE OF THIS SOFTWARE.
 */

#import "MTXClient.h"
#import "MTXRequest.h"

#import "MTXFetchRoomListFailedException.h"
#import "MTXJoinRoomFailedException.h"
#import "MTXLeaveRoomFailedException.h"
#import "MTXLoginFailedException.h"
#import "MTXLogoutFailedException.h"
#import "MTXSendMessageFailedException.h"
#import "MTXSyncFailedException.h"

static void
validateHomeserver(OFIRI *homeserver)
{
	if (![homeserver.scheme isEqual: @"http"] &&
	    ![homeserver.scheme isEqual: @"https"])
		@throw [OFUnsupportedProtocolException
		    exceptionWithIRI: homeserver];

	if (homeserver.path != nil && ![homeserver.path isEqual: @"/"])
		@throw [OFInvalidArgumentException exception];

	if (homeserver.user != nil || homeserver.password != nil ||
	    homeserver.query != nil || homeserver.fragment != nil)
		@throw [OFInvalidArgumentException exception];
}

@implementation MTXClient
{
	bool _syncing;
}

+ (instancetype)clientWithUserID: (OFString *)userID
			deviceID: (OFString *)deviceID
		     accessToken: (OFString *)accessToken
		      homeserver: (OFIRI *)homeserver
			 storage: (id <MTXStorage>)storage
{
	return [[[self alloc] initWithUserID: userID
				    deviceID: deviceID
				 accessToken: accessToken
				  homeserver: homeserver
				     storage: storage] autorelease];
}

+ (void)logInWithUser: (OFString *)user
	     password: (OFString *)password
	   homeserver: (OFIRI *)homeserver
	      storage: (id <MTXStorage>)storage
		block: (MTXClientLoginBlock)block
{
	void *pool = objc_autoreleasePoolPush();

	validateHomeserver(homeserver);

	MTXRequest *request = [MTXRequest
	    requestWithPath: @"/_matrix/client/r0/login"
		accessToken: nil
		 homeserver: homeserver];
	request.method = OFHTTPRequestMethodPost;
	request.body = @{
		@"type": @"m.login.password",
		@"identifier": @{
			@"type": @"m.id.user",
			@"user": user
		},
		@"password": password
	};

	[request performWithBlock: ^ (MTXResponse response, int statusCode,
				       id exception) {
		if (exception != nil) {
			block(nil, exception);
			return;
		}

		if (statusCode != 200) {
			id exception = [MTXLoginFailedException
			    exceptionWithUser: user
				   homeserver: homeserver
				   statusCode: statusCode
				     response: response];
			block(nil, exception);
			return;
		}

		OFString *userID = response[@"user_id"];
		OFString *deviceID = response[@"device_id"];
		OFString *accessToken = response[@"access_token"];
		if (![userID isKindOfClass: OFString.class] ||
		    ![deviceID isKindOfClass: OFString.class] ||
		    ![accessToken isKindOfClass: OFString.class]) {
			block(nil,
			    [OFInvalidServerResponseException exception]);
			return;
		}

		OFString *baseIRI =
		    response[@"well_known"][@"m.homeserver"][@"base_url"];
		if (baseIRI != nil &&
		    ![baseIRI isKindOfClass: OFString.class]) {
			block(nil,
			    [OFInvalidServerResponseException exception]);
			return;
		}

		OFIRI *realHomeserver;
		if (baseIRI != nil) {
			@try {
				realHomeserver = [OFIRI IRIWithString: baseIRI];
			} @catch (id e) {
				block(nil, e);
				return;
			}
		} else
			realHomeserver = homeserver;

		MTXClient *client = [MTXClient clientWithUserID: userID
						       deviceID: deviceID
						    accessToken: accessToken
						     homeserver: realHomeserver
							storage: storage];
		block(client, nil);
	}];

	objc_autoreleasePoolPop(pool);
}

- (instancetype)initWithUserID: (OFString *)userID
		      deviceID: (OFString *)deviceID
		   accessToken: (OFString *)accessToken
		    homeserver: (OFIRI *)homeserver
		       storage: (id <MTXStorage>)storage
{
	self = [super init];

	@try {
		validateHomeserver(homeserver);

		_userID = [userID copy];
		_deviceID = [deviceID copy];
		_accessToken = [accessToken copy];
		_homeserver = [homeserver copy];
		_storage = [storage retain];
		_syncTimeout = 300;
	} @catch (id e) {
		[self release];
		@throw e;
	}

	return self;
}

- (void)dealloc
{
	[_userID release];
	[_deviceID release];
	[_accessToken release];
	[_homeserver release];
	[_storage release];

	[super dealloc];
}

- (OFString *)description
{
	return [OFString stringWithFormat:
	    @"<%@\n"
	    @"\tUser ID = %@\n"
	    @"\tDevice ID = %@\n"
	    @"\tAccess token = %@\n"
	    @"\tHomeserver = %@\n"
	    @">",
	    self.class, _userID, _deviceID, _accessToken, _homeserver];
}

- (MTXRequest *)requestWithPath: (OFString *)path
{
	return [MTXRequest requestWithPath: path
			       accessToken: _accessToken
				homeserver: _homeserver];
}

- (void)startSyncLoop
{
	if (_syncing)
		return;

	_syncing = true;

	void *pool = objc_autoreleasePoolPush();
	MTXRequest *request = [self
	    requestWithPath: @"/_matrix/client/r0/sync"];
	unsigned long long timeoutMs = _syncTimeout * 1000;
	OFMutableArray<OFPair <OFString *, OFString *> *> *queryItems =
	    [OFMutableArray array];
	OFString *since = [_storage nextBatchForDeviceID: _deviceID];

	[queryItems addObject:
	    [OFPair pairWithFirstObject: @"timeout"
			   secondObject: @(timeoutMs).stringValue]];

	if (since != nil)
		[queryItems addObject:
		    [OFPair pairWithFirstObject: @"since"
				   secondObject: since]];

	request.queryItems = queryItems;
	[request performWithBlock: ^ (MTXResponse response, int statusCode,
				       id exception) {
		if (exception != nil) {
			if (_syncExceptionHandler != NULL)
				_syncExceptionHandler(exception);
			return;
		}

		if (statusCode != 200) {
			if (_syncExceptionHandler != NULL)
				_syncExceptionHandler([MTXSyncFailedException
				    exceptionWithStatusCode: statusCode
						   response: response
						     client: self]);
			return;
		}

		OFString *nextBatch = response[@"next_batch"];
		if (![nextBatch isKindOfClass: OFString.class]) {
			if (_syncExceptionHandler != NULL)
				_syncExceptionHandler(
				    [OFInvalidServerResponseException
				    exception]);
			return;
		}

		@try {
			[_storage transactionWithBlock: ^ {
				[_storage setNextBatch: nextBatch
					   forDeviceID: _deviceID];

				[self processRoomsSync: response[@"rooms"]];
				[self processPresenceSync:
				    response[@"presence"]];
				[self processAccountDataSync:
				    response[@"account_data"]];
				[self processToDeviceSync:
				    response[@"to_device"]];

				return true;
			}];
		} @catch (id e) {
			if (_syncExceptionHandler != NULL)
				_syncExceptionHandler(e);
			return;
		}

		if (_syncing)
			[self startSyncLoop];
	}];

	objc_autoreleasePoolPop(pool);
}

- (void)stopSyncLoop
{
	_syncing = false;
}

- (void)logOutWithBlock: (MTXClientResponseBlock)block
{
	void *pool = objc_autoreleasePoolPush();
	MTXRequest *request =
	    [self requestWithPath: @"/_matrix/client/r0/logout"];
	request.method = OFHTTPRequestMethodPost;
	[request performWithBlock: ^ (MTXResponse response, int statusCode,
				       id exception) {
		if (exception != nil) {
			block(exception);
			return;
		}

		if (statusCode != 200) {
			block([MTXLogoutFailedException
			    exceptionWithStatusCode: statusCode
					   response: response
					     client: self]);
			return;
		}

		block(nil);
	}];

	objc_autoreleasePoolPop(pool);
}

- (void)fetchRoomListWithBlock: (MTXClientRoomListBlock)block
{
	void *pool = objc_autoreleasePoolPush();
	MTXRequest *request =
	    [self requestWithPath: @"/_matrix/client/r0/joined_rooms"];
	[request performWithBlock: ^ (MTXResponse response, int statusCode,
				       id exception) {
		if (exception != nil) {
			block(nil, exception);
			return;
		}

		if (statusCode != 200) {
			block(nil, [MTXFetchRoomListFailedException
			    exceptionWithStatusCode: statusCode
					   response: response
					     client: self]);
			return;
		}

		OFArray<OFString *> *joinedRooms = response[@"joined_rooms"];
		if (![joinedRooms isKindOfClass: OFArray.class]) {
			block(nil,
			    [OFInvalidServerResponseException exception]);
			return;
		}
		for (OFString *room in joinedRooms) {
			if (![room isKindOfClass: OFString.class]) {
				block(nil,
				    [OFInvalidServerResponseException
				    exception]);
				return;
			}
		}

		block(response[@"joined_rooms"], nil);
	}];

	objc_autoreleasePoolPop(pool);
}

- (void)joinRoom: (OFString *)room block: (MTXClientRoomJoinBlock)block
{
	void *pool = objc_autoreleasePoolPush();
	MTXRequest *request = [self requestWithPath:
	    [OFString stringWithFormat: @"/_matrix/client/r0/join/%@", room]];
	request.method = OFHTTPRequestMethodPost;
	[request performWithBlock: ^ (MTXResponse response, int statusCode,
				       id exception) {
		if (exception != nil) {
			block(nil, exception);
			return;
		}

		if (statusCode != 200) {
			block(nil, [MTXJoinRoomFailedException
			    exceptionWithRoom: room
				   statusCode: statusCode
				     response: response
				       client: self]);
			return;
		}

		OFString *roomID = response[@"room_id"];
		if (![roomID isKindOfClass: OFString.class]) {
			block(nil,
			    [OFInvalidServerResponseException exception]);
			return;
		}

		block(roomID, nil);
	}];

	objc_autoreleasePoolPop(pool);
}

- (void)leaveRoom: (OFString *)roomID block: (MTXClientResponseBlock)block
{
	void *pool = objc_autoreleasePoolPush();
	MTXRequest *request = [self requestWithPath: [OFString
	    stringWithFormat: @"/_matrix/client/r0/rooms/%@/leave", roomID]];
	request.method = OFHTTPRequestMethodPost;
	[request performWithBlock: ^ (MTXResponse response, int statusCode,
				       id exception) {
		if (exception != nil) {
			block(exception);
			return;
		}

		if (statusCode != 200) {
			block([MTXLeaveRoomFailedException
			    exceptionWithRoomID: roomID
				     statusCode: statusCode
				       response: response
					 client: self]);
			return;
		}

		block(nil);
	}];

	objc_autoreleasePoolPop(pool);
}

- (void)sendMessage: (OFString *)message
	     roomID: (OFString *)roomID
	      block: (MTXClientResponseBlock)block
{
	void *pool = objc_autoreleasePoolPush();
	OFString *path = [OFString stringWithFormat:
	    @"/_matrix/client/r0/rooms/%@/send/m.room.message", roomID];
	MTXRequest *request = [self requestWithPath: path];
	request.method = OFHTTPRequestMethodPost;
	request.body = @{
		@"msgtype": @"m.text",
		@"body": message
	};
	[request performWithBlock: ^ (MTXResponse response, int statusCode,
				       id exception) {
		if (exception != nil) {
			block(exception);
			return;
		}

		if (statusCode != 200) {
			block([MTXSendMessageFailedException
			    exceptionWithMessage: message
					  roomID: roomID
				      statusCode: statusCode
					response: response
					  client: self]);
			return;
		}

		block(nil);
	}];

	objc_autoreleasePoolPop(pool);
}

- (void)processRoomsSync: (OFDictionary<OFString *, id> *)rooms
{
	[self processJoinedRooms: rooms[@"join"]];
	[self processInvitedRooms: rooms[@"invite"]];
	[self processLeftRooms: rooms[@"leave"]];
}

- (void)processPresenceSync: (OFDictionary<OFString *, id> *)presence
{
}

- (void)processAccountDataSync: (OFDictionary<OFString *, id> *)accountData
{
}

- (void)processToDeviceSync: (OFDictionary<OFString *, id> *)toDevice
{
}

- (void)processJoinedRooms: (OFDictionary<OFString *, id> *)rooms
{
	if (rooms == nil)
		return;

	for (OFString *roomID in rooms)
		[_storage addJoinedRoom: roomID forUser: _userID];
}

- (void)processInvitedRooms: (OFDictionary<OFString *, id> *)rooms
{
	if (rooms == nil)
		return;
}

- (void)processLeftRooms: (OFDictionary<OFString *, id> *)rooms
{
	if (rooms == nil)
		return;

	for (OFString *roomID in rooms)
		[_storage removeJoinedRoom: roomID forUser: _userID];
}
@end