diff --git a/src/main/java/org/synyx/matrix/bot/MatrixClient.java b/src/main/java/org/synyx/matrix/bot/MatrixClient.java index 6462d5b..01cb65d 100644 --- a/src/main/java/org/synyx/matrix/bot/MatrixClient.java +++ b/src/main/java/org/synyx/matrix/bot/MatrixClient.java @@ -1,160 +1,110 @@ package org.synyx.matrix.bot; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import lombok.extern.slf4j.Slf4j; import org.synyx.matrix.bot.domain.MatrixEventId; import org.synyx.matrix.bot.domain.MatrixRoomId; -import org.synyx.matrix.bot.domain.MatrixUserId; -import org.synyx.matrix.bot.internal.MatrixAuthentication; -import org.synyx.matrix.bot.internal.MatrixEventNotifier; -import org.synyx.matrix.bot.internal.MatrixStateSynchronizer; -import org.synyx.matrix.bot.internal.api.MatrixApi; -import org.synyx.matrix.bot.internal.api.dto.MessageDto; -import org.synyx.matrix.bot.internal.api.dto.ReactionDto; -import org.synyx.matrix.bot.internal.api.dto.ReactionRelatesToDto; +import org.synyx.matrix.bot.internal.MatrixClientImpl; import java.util.Optional; -@Slf4j -public class MatrixClient { - - private final MatrixAuthentication authentication; - private final ObjectMapper objectMapper; - private final MatrixApi api; - private MatrixState state; - private MatrixStateSynchronizer stateSynchronizer; - private MatrixPersistedState persistedState; - private MatrixEventNotifier eventNotifier; - private boolean interruptionRequested; - - public MatrixClient(String hostname, String username, String password) { - - this.authentication = new MatrixAuthentication(username, password); - this.objectMapper = JsonMapper.builder() - .addModule(new Jdk8Module()) - .addModule(new JavaTimeModule()) - .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true) - .build(); - this.api = new MatrixApi(hostname, authentication, objectMapper); - this.state = null; - this.eventNotifier = null; - this.interruptionRequested = false; +/** + * An interface for a client connecting to a matrix server. + * Serves as the main method of communicating with the server. + */ +public interface MatrixClient { + + /** + * Creates a new matrix client to connect to the specified server. + * + * @param url The url for connecting to the intended matrix server. Must start with http:// or https:// + * @param username The username for logging into the matrix server. + * @param password The password for logging into the matrix server. + * @return A {@link MatrixClient} implementation that connects to the specified matrix server. + */ + static MatrixClient create(String url, String username, String password) { + + return new MatrixClientImpl(url, username, password); } - public void setEventCallback(MatrixEventConsumer eventConsumer) { - - this.eventNotifier = MatrixEventNotifier.from(objectMapper, eventConsumer).orElse(null); - } - - public void setPersistedState(MatrixPersistedState persistedState) { - - this.persistedState = persistedState; - } - - public void requestStopOfSync() { - - interruptionRequested = true; - api.terminateOpenConnections(); - } - - public void syncContinuous() { - - if (!authentication.isAuthenticated()) { - if (api.login()) { - log.info("Successfully logged in to matrix server as {}", - authentication.getUserId() - .map(MatrixUserId::toString) - .orElse("UNKNOWN") - ); - } else { - return; - } - } - - state = new MatrixState(authentication.getUserId().orElseThrow(IllegalStateException::new)); - stateSynchronizer = new MatrixStateSynchronizer(state, objectMapper); - - var maybeSyncResponse = api.syncFull(); - String lastBatch; - if (maybeSyncResponse.isPresent()) { - final var syncResponse = maybeSyncResponse.get(); - lastBatch = syncResponse.nextBatch(); - - stateSynchronizer.synchronizeState(syncResponse); - } else { - log.error("Failed to perform initial sync"); - return; - } - - if (eventNotifier != null) { - eventNotifier.getConsumer().onConnected(state); - } - - if (persistedState != null) { - final var maybePersistedLastBatch = persistedState.getLastBatch(); - if (maybePersistedLastBatch.isPresent()) { - lastBatch = maybePersistedLastBatch.get(); - } else { - persistedState.setLastBatch(lastBatch); - } - } - - while (!interruptionRequested) { - maybeSyncResponse = api.sync(lastBatch); - if (maybeSyncResponse.isPresent()) { - final var syncResponse = maybeSyncResponse.get(); - lastBatch = syncResponse.nextBatch(); - - stateSynchronizer.synchronizeState(syncResponse); - - if (eventNotifier != null) { - eventNotifier.notifyFromSynchronizationResponse(state, syncResponse); - } - - if (persistedState != null) { - persistedState.setLastBatch(lastBatch); - } - } - } - - interruptionRequested = false; - } - - public boolean isConnected() { - - return state != null; - } - - public Optional getState() { - - return Optional.ofNullable(state); - } - - public boolean sendMessage(MatrixRoomId roomId, String messageBody) { - - return api.sendEvent(roomId.getFormatted(), "m.room.message", new MessageDto(messageBody, "m.text")); - } - - public boolean addReaction(MatrixRoomId roomId, MatrixEventId eventId, String reaction) { - - final var reactionDto = new ReactionDto(new ReactionRelatesToDto(eventId.getFormatted(), reaction)); - return api.sendEvent(roomId.getFormatted(), "m.reaction", reactionDto); - } - - public boolean joinRoom(MatrixRoomId roomId) { - - return api.joinRoom(roomId.getFormatted(), "hello there"); - } - - public boolean leaveRoom(MatrixRoomId roomId) { - - return api.leaveRoom(roomId.getFormatted(), "bai"); - } + /** + * Sets a consumer object that gets called on events happening on the matrix server. + * Only one consumer can be set at any time. + * Calling this method again replaces any previous event callback. + * + * @param eventConsumer The consumer to call on events. + */ + void setEventCallback(MatrixEventConsumer eventConsumer); + + /** + * Optionally provides an interface to provide the current state of the matrix client. + * If not provided, any startup will act like the first startup and will ignore any previously sent messages. + * Providing a persisted state will make the client be able to determine which events happened while offline. + * + * @param persistedState An interface for persisting the matrix client state + */ + void setPersistedStateProvider(MatrixPersistedStateProvider persistedState); + + + /** + * The main matrix client event loop that continuously syncs all events happening on the matrix server to the client. + * This is a blocking call, so make sure to call it from a different thread if needed. + * + * @throws InterruptedException The sync has been interrupted + */ + void syncContinuous() throws InterruptedException; + + /** + * Requests the matrix client to stop syncing and terminate. + * May be called from a different thread. + */ + void requestStopOfSync(); + + + /** + * Returns whether the matrix client is currently connected to the server or not. + * + * @return {@code true} if the client is currently connected to the server, {@code false} otherwise. + */ + boolean isConnected(); + + /** + * Returns the current state of the matrix client. + * + * @return A {@link MatrixState} object if currently connected to a server, {@link Optional#empty()} otherwise. + */ + Optional getState(); + + /** + * Attempts to send a message to the specified room. + * + * @param roomId The id of the room to send the message to. + * @param messageBody The body of the message to send. + * @return A {@link MatrixEventId} containing the id of the event that was sent or {@link Optional#empty()} if sending the message did not succeed. + */ + Optional sendMessage(MatrixRoomId roomId, String messageBody); + + /** + * Attempts to add a reaction to an event (a message of the time). + * + * @param roomId The id of the room to send the message in. + * @param eventId The id of the event to react to. + * @param reaction The reaction to send. + * @return A {@link MatrixEventId} containing the id of the event that was sent or {@link Optional#empty()} if sending the reaction did not succeed. + */ + Optional addReaction(MatrixRoomId roomId, MatrixEventId eventId, String reaction); + + /** + * Attempts to join a room. + * + * @param roomId The id of the room to join. + * @return {@code true} if joining the room was successful, {@code false} otherwise. + */ + boolean joinRoom(MatrixRoomId roomId); + + /** + * Attempts to leave a room. + * + * @param roomId The id of the room to leave. + * @return {@code true} if leaving the room was successful, {@code false} otherwise. + */ + boolean leaveRoom(MatrixRoomId roomId); } diff --git a/src/main/java/org/synyx/matrix/bot/MatrixCommunicationException.java b/src/main/java/org/synyx/matrix/bot/MatrixCommunicationException.java new file mode 100644 index 0000000..a036022 --- /dev/null +++ b/src/main/java/org/synyx/matrix/bot/MatrixCommunicationException.java @@ -0,0 +1,17 @@ +package org.synyx.matrix.bot; + +/** + * An exception that was not recoverable from by the matrix client itself occurred while communicating with the matrix server. + */ +public class MatrixCommunicationException extends RuntimeException { + + public MatrixCommunicationException(String message) { + + super(message); + } + + public MatrixCommunicationException(String message, Throwable cause) { + + super(message, cause); + } +} diff --git a/src/main/java/org/synyx/matrix/bot/MatrixEventConsumer.java b/src/main/java/org/synyx/matrix/bot/MatrixEventConsumer.java index 3f2c28d..67a77f1 100644 --- a/src/main/java/org/synyx/matrix/bot/MatrixEventConsumer.java +++ b/src/main/java/org/synyx/matrix/bot/MatrixEventConsumer.java @@ -5,30 +5,76 @@ import org.synyx.matrix.bot.domain.MatrixRoomId; import org.synyx.matrix.bot.domain.MatrixRoomInvite; import org.synyx.matrix.bot.domain.MatrixUserId; +import org.synyx.matrix.bot.internal.MatrixClientImpl; +/** + * An interface providing callbacks for things happening on the matrix server that were received by the client. + * All methods have a default implementation that does nothing, so implementing classes only need to override whatever + * they want to listen to. + *

+ * Any reactions to events happening shall be performed using the appropriate {@link MatrixClientImpl} instance. + */ public interface MatrixEventConsumer { - default void onConnected(MatrixState state) { + /** + * The client successfully connected to the server. + * + * @param state The state after the initial synchronisation. + */ + default void onConnected(MatrixState state) { - } + } - default void onMessage(MatrixState state, MatrixRoom room, MatrixMessage message) { + /** + * A message event was received in a room that the client is part of. + * + * @param state The current client state. + * @param room The room the message was received in. + * @param message The message that was received. + */ + default void onMessage(MatrixState state, MatrixRoom room, MatrixMessage message) { - } + } - default void onInviteToRoom(MatrixState state, MatrixRoomInvite invite) { + /** + * An invitation to a room was received. + * + * @param state The current client state. + * @param invite The invite that was received. + */ + default void onInviteToRoom(MatrixState state, MatrixRoomInvite invite) { - } + } - default void onUserJoinRoom(MatrixState state, MatrixRoom room, MatrixUserId userId) { + /** + * A user joined a room that the client is part of. + * + * @param state The current client state. + * @param room The room that the user joined in. + * @param userId The id of the user that joined the room. + */ + default void onUserJoinRoom(MatrixState state, MatrixRoom room, MatrixUserId userId) { - } + } - default void onUserLeaveRoom(MatrixState state, MatrixRoom room, MatrixUserId userId) { + /** + * A user left a room that the client is part of. + * + * @param state The current client state. + * @param room The room that the user left from. + * @param userId The id of the user that left the room. + */ + default void onUserLeaveRoom(MatrixState state, MatrixRoom room, MatrixUserId userId) { - } + } - default void onSelfLeaveRoom(MatrixState state, MatrixRoomId roomId) { + /** + * The client left a room it was part of. May have been caused by external factors like kicks or bans. + * + * @param state The current client state. + * @param roomId The id of the room that the client left from. + */ + default void onSelfLeaveRoom(MatrixState state, MatrixRoomId roomId) { - } + } } diff --git a/src/main/java/org/synyx/matrix/bot/MatrixPersistedState.java b/src/main/java/org/synyx/matrix/bot/MatrixPersistedState.java deleted file mode 100644 index a36ee51..0000000 --- a/src/main/java/org/synyx/matrix/bot/MatrixPersistedState.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.synyx.matrix.bot; - -import java.util.Optional; - -public interface MatrixPersistedState { - - Optional getLastBatch(); - - void setLastBatch(String value); -} diff --git a/src/main/java/org/synyx/matrix/bot/MatrixPersistedStateProvider.java b/src/main/java/org/synyx/matrix/bot/MatrixPersistedStateProvider.java new file mode 100644 index 0000000..49bd9eb --- /dev/null +++ b/src/main/java/org/synyx/matrix/bot/MatrixPersistedStateProvider.java @@ -0,0 +1,10 @@ +package org.synyx.matrix.bot; + +import java.util.Optional; + +public interface MatrixPersistedStateProvider { + + Optional getLastBatch(); + + void setLastBatch(String value); +} diff --git a/src/main/java/org/synyx/matrix/bot/internal/MatrixAuthentication.java b/src/main/java/org/synyx/matrix/bot/internal/MatrixAuthentication.java index a59ee79..5c6d741 100644 --- a/src/main/java/org/synyx/matrix/bot/internal/MatrixAuthentication.java +++ b/src/main/java/org/synyx/matrix/bot/internal/MatrixAuthentication.java @@ -8,35 +8,41 @@ public class MatrixAuthentication { - @Getter - private final String username; - @Getter - private final String password; + @Getter + private final String username; + @Getter + private final String password; - @Setter - private MatrixUserId userId; - @Setter - private String bearerToken; + @Setter + private MatrixUserId userId; + @Setter + private String bearerToken; - public MatrixAuthentication(String username, String password) { + public MatrixAuthentication(String username, String password) { - this.username = username; - this.password = password; - this.bearerToken = null; - } + this.username = username; + this.password = password; + this.bearerToken = null; + } - public boolean isAuthenticated() { + public boolean isAuthenticated() { - return bearerToken != null; - } + return bearerToken != null; + } - public Optional getBearerToken() { + public void clear() { - return Optional.ofNullable(bearerToken); - } + bearerToken = null; + userId = null; + } - public Optional getUserId() { + public Optional getBearerToken() { - return Optional.ofNullable(userId); - } + return Optional.ofNullable(bearerToken); + } + + public Optional getUserId() { + + return Optional.ofNullable(userId); + } } diff --git a/src/main/java/org/synyx/matrix/bot/internal/MatrixBackoffException.java b/src/main/java/org/synyx/matrix/bot/internal/MatrixBackoffException.java new file mode 100644 index 0000000..e9bbcbe --- /dev/null +++ b/src/main/java/org/synyx/matrix/bot/internal/MatrixBackoffException.java @@ -0,0 +1,9 @@ +package org.synyx.matrix.bot.internal; + +public class MatrixBackoffException extends RuntimeException { + + public MatrixBackoffException(String message, Throwable e) { + + super(message, e); + } +} diff --git a/src/main/java/org/synyx/matrix/bot/internal/MatrixClientImpl.java b/src/main/java/org/synyx/matrix/bot/internal/MatrixClientImpl.java new file mode 100644 index 0000000..a4299b3 --- /dev/null +++ b/src/main/java/org/synyx/matrix/bot/internal/MatrixClientImpl.java @@ -0,0 +1,249 @@ +package org.synyx.matrix.bot.internal; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.extern.slf4j.Slf4j; +import org.synyx.matrix.bot.MatrixClient; +import org.synyx.matrix.bot.MatrixCommunicationException; +import org.synyx.matrix.bot.MatrixEventConsumer; +import org.synyx.matrix.bot.MatrixPersistedStateProvider; +import org.synyx.matrix.bot.MatrixState; +import org.synyx.matrix.bot.domain.MatrixEventId; +import org.synyx.matrix.bot.domain.MatrixRoomId; +import org.synyx.matrix.bot.domain.MatrixUserId; +import org.synyx.matrix.bot.internal.api.MatrixApi; +import org.synyx.matrix.bot.internal.api.MatrixApiException; +import org.synyx.matrix.bot.internal.api.dto.MessageDto; +import org.synyx.matrix.bot.internal.api.dto.ReactionDto; +import org.synyx.matrix.bot.internal.api.dto.ReactionRelatesToDto; +import org.synyx.matrix.bot.internal.api.dto.SyncResponseDto; + +import java.io.IOException; +import java.util.Optional; + +@Slf4j +public class MatrixClientImpl implements MatrixClient { + + private static final long DEFAULT_BACKOFF_IN_SEC = 3; + private static final long BACKOFF_MAX_IN_SEC = 60; + + private final MatrixAuthentication authentication; + private final ObjectMapper objectMapper; + private final MatrixApi api; + private MatrixState state; + private MatrixStateSynchronizer stateSynchronizer; + private MatrixPersistedStateProvider persistedState; + private MatrixEventNotifier eventNotifier; + private boolean interruptionRequested; + private long currentBackoffInSec; + + public MatrixClientImpl(String url, String username, String password) { + + this.authentication = new MatrixAuthentication(username, password); + this.objectMapper = JsonMapper.builder() + .addModule(new Jdk8Module()) + .addModule(new JavaTimeModule()) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true) + .build(); + this.api = new MatrixApi(url, authentication, objectMapper); + this.state = null; + this.eventNotifier = null; + this.interruptionRequested = false; + this.currentBackoffInSec = DEFAULT_BACKOFF_IN_SEC; + } + + @Override + public void setEventCallback(MatrixEventConsumer eventConsumer) { + + this.eventNotifier = MatrixEventNotifier.from(objectMapper, eventConsumer).orElse(null); + } + + @Override + public void setPersistedStateProvider(MatrixPersistedStateProvider persistedState) { + + this.persistedState = persistedState; + } + + @Override + public void syncContinuous() throws InterruptedException { + + while (!interruptionRequested) { + try { + if (!authentication.isAuthenticated()) { + try { + api.login(); + } catch (IOException e) { + throw new MatrixBackoffException("Failed to login to matrix server!", e); + } catch (MatrixApiException e) { + throw new MatrixCommunicationException("Failed to login to matrix server!", e); + } + + log.info("Successfully logged in to matrix server as {}", + authentication.getUserId() + .map(MatrixUserId::toString) + .orElse("UNKNOWN") + ); + } + + state = new MatrixState(authentication.getUserId().orElseThrow(IllegalStateException::new)); + stateSynchronizer = new MatrixStateSynchronizer(state, objectMapper); + + SyncResponseDto syncResponse; + try { + syncResponse = api.syncFull() + .orElseThrow(() -> new MatrixCommunicationException("No data in initial sync")); + } catch (MatrixApiException | IOException e) { + throw new MatrixBackoffException("Failed to perform initial sync", e); + } + + String lastBatch = syncResponse.nextBatch(); + stateSynchronizer.synchronizeState(syncResponse); + + if (eventNotifier != null) { + eventNotifier.getConsumer().onConnected(state); + } + + if (persistedState != null) { + final var maybePersistedLastBatch = persistedState.getLastBatch(); + if (maybePersistedLastBatch.isPresent()) { + lastBatch = maybePersistedLastBatch.get(); + } else { + persistedState.setLastBatch(lastBatch); + } + } + + while (!interruptionRequested) { + Optional maybePartialSyncResponse; + + try { + maybePartialSyncResponse = api.sync(lastBatch); + } catch (MatrixApiException | IOException e) { + throw new MatrixBackoffException("Could not partial sync", e); + } + + if (maybePartialSyncResponse.isPresent()) { + syncResponse = maybePartialSyncResponse.get(); + lastBatch = syncResponse.nextBatch(); + + stateSynchronizer.synchronizeState(syncResponse); + + if (eventNotifier != null) { + eventNotifier.notifyFromSynchronizationResponse(state, syncResponse); + } + + if (persistedState != null) { + persistedState.setLastBatch(lastBatch); + } + } + + currentBackoffInSec = DEFAULT_BACKOFF_IN_SEC; + } + + } catch (MatrixBackoffException e) { + log.warn("Sync failed: {}, backing off for {}s", e.getCause().getClass().getName(), currentBackoffInSec); + + clearSyncState(); + Thread.sleep(currentBackoffInSec * 1000); + currentBackoffInSec = Math.min(currentBackoffInSec * 2, BACKOFF_MAX_IN_SEC); + } + } + + clearSyncState(); + interruptionRequested = false; + currentBackoffInSec = DEFAULT_BACKOFF_IN_SEC; + } + + @Override + public void requestStopOfSync() { + + interruptionRequested = true; + api.terminateOpenConnections(); + } + + private void clearSyncState() { + + authentication.clear(); + state = null; + } + + @Override + public boolean isConnected() { + + return state != null; + } + + @Override + public Optional getState() { + + return Optional.ofNullable(state); + } + + @Override + public Optional sendMessage(MatrixRoomId roomId, String messageBody) { + + try { + return MatrixEventId.from( + api.sendEvent(roomId.getFormatted(), "m.room.message", new MessageDto(messageBody, "m.text")) + ); + } catch (InterruptedException | IOException e) { + log.error("Failed to send message", e); + } catch (MatrixApiException e) { + log.warn("Could not send message", e); + } + + return Optional.empty(); + } + + @Override + public Optional addReaction(MatrixRoomId roomId, MatrixEventId eventId, String reaction) { + + final var reactionDto = new ReactionDto(new ReactionRelatesToDto(eventId.getFormatted(), reaction)); + try { + return MatrixEventId.from( + api.sendEvent(roomId.getFormatted(), "m.reaction", reactionDto) + ); + } catch (InterruptedException | IOException e) { + log.error("Failed to add reaction", e); + } catch (MatrixApiException e) { + log.warn("Could not add reaction", e); + } + + return Optional.empty(); + } + + @Override + public boolean joinRoom(MatrixRoomId roomId) { + + try { + api.joinRoom(roomId.getFormatted(), "i'm a bot"); + return true; + } catch (InterruptedException | IOException e) { + log.error("Failed to join room", e); + } catch (MatrixApiException e) { + log.warn("Could not join room", e); + } + + return false; + } + + @Override + public boolean leaveRoom(MatrixRoomId roomId) { + + try { + api.leaveRoom(roomId.getFormatted(), "i'm a bot"); + return true; + } catch (InterruptedException | IOException e) { + log.error("Failed to leave room", e); + } catch (MatrixApiException e) { + log.warn("Could not leave room", e); + } + + return false; + } +} diff --git a/src/main/java/org/synyx/matrix/bot/internal/api/MatrixApi.java b/src/main/java/org/synyx/matrix/bot/internal/api/MatrixApi.java index 582b84a..c11267a 100644 --- a/src/main/java/org/synyx/matrix/bot/internal/api/MatrixApi.java +++ b/src/main/java/org/synyx/matrix/bot/internal/api/MatrixApi.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import org.synyx.matrix.bot.MatrixCommunicationException; import org.synyx.matrix.bot.domain.MatrixUserId; import org.synyx.matrix.bot.internal.MatrixAuthentication; import org.synyx.matrix.bot.internal.api.dto.EventIdResponseDto; @@ -21,208 +22,197 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.Optional; import java.util.UUID; @Slf4j public class MatrixApi { - private static final int SYNC_TIMEOUT = 30_000; + private static final Duration SYNC_TIMEOUT = Duration.of(30, ChronoUnit.SECONDS); + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.of(30, ChronoUnit.SECONDS); + private static final Duration SYNC_REQUEST_TIMEOUT = Duration.of((long) (SYNC_TIMEOUT.toMillis() * 1.5D), ChronoUnit.MILLIS); - private final URI baseUri; - private final MatrixAuthentication authentication; - private final HttpClient httpClient; - private final ObjectMapper objectMapper; + private final URI baseUri; + private final MatrixAuthentication authentication; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; - public MatrixApi(String hostname, MatrixAuthentication authentication, ObjectMapper objectMapper) { + public MatrixApi(String url, MatrixAuthentication authentication, ObjectMapper objectMapper) { - try { - this.baseUri = new URI(hostname); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - this.authentication = authentication; - this.httpClient = HttpClient.newHttpClient(); - this.objectMapper = objectMapper; + try { + this.baseUri = new URI(url); + } catch (URISyntaxException e) { + throw new MatrixCommunicationException("Invalid matrix URI", e); + } + this.authentication = authentication; + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = objectMapper; + } + + public void terminateOpenConnections() { + + httpClient.shutdownNow(); + } + + public void login() throws IOException, InterruptedException, MatrixApiException { + + final var response = httpClient.send( + post( + "/_matrix/client/v3/login", + null, + new MatrixLoginDto( + new MatrixIdentifierDto("m.id.user", authentication.getUsername()), + authentication.getPassword(), + "m.login.password" + ) + ).build(), + jsonBodyHandler(MatrixLoginResponseDto.class) + ); + + expected2xx("login", response); + + final var body = response.body(); + if (body == null) { + throw new MatrixApiException("Received no login data", response); } - public void terminateOpenConnections() { + final var userId = MatrixUserId.from(body.userId()) + .orElseThrow(IllegalStateException::new); + authentication.setUserId(userId); + authentication.setBearerToken(body.accessToken()); + } - httpClient.shutdownNow(); - } + public Optional sync(String since) throws IOException, InterruptedException, MatrixApiException { - public boolean login() { - - try { - final var response = httpClient.send( - post( - "/_matrix/client/v3/login", - null, - new MatrixLoginDto( - new MatrixIdentifierDto("m.id.user", authentication.getUsername()), - authentication.getPassword(), - "m.login.password" - ) - ).build(), - jsonBodyHandler(MatrixLoginResponseDto.class) - ); - - final var maybeBody = Optional.ofNullable(response.body()); - maybeBody.ifPresent(body -> { - final var userId = MatrixUserId.from(body.userId()) - .orElseThrow(IllegalStateException::new); - authentication.setUserId(userId); - authentication.setBearerToken(body.accessToken()); - }); - - return maybeBody.isPresent(); - } catch (IOException | InterruptedException e) { - log.error("Failed to login", e); - return false; - } - } + final var response = httpClient.send( + get( + "/_matrix/client/v3/sync", + "timeout=%d&since=%s".formatted( + SYNC_TIMEOUT.toMillis(), + URLEncoder.encode(since, StandardCharsets.UTF_8) + ) + ).timeout(SYNC_REQUEST_TIMEOUT).build(), + jsonBodyHandler(SyncResponseDto.class) + ); - public Optional sync(String since) { - - try { - final var response = httpClient.send( - get( - "/_matrix/client/v3/sync", - "timeout=%d&since=%s".formatted( - SYNC_TIMEOUT, - URLEncoder.encode(since, StandardCharsets.UTF_8) - ) - ).build(), - jsonBodyHandler(SyncResponseDto.class) - ); - - return Optional.ofNullable(response.body()); - } catch (IOException | InterruptedException e) { - log.error("Failed to sync", e); - } + expected2xx("syncing", response); - return Optional.empty(); - } + return Optional.ofNullable(response.body()); + } - public Optional syncFull() { + public Optional syncFull() throws IOException, InterruptedException, MatrixApiException { - try { - final var response = httpClient.send( - get("/_matrix/client/v3/sync", "timeout=0").build(), - jsonBodyHandler(SyncResponseDto.class) - ); + final var response = httpClient.send( + get("/_matrix/client/v3/sync", "timeout=0").build(), + jsonBodyHandler(SyncResponseDto.class) + ); - return Optional.ofNullable(response.body()); - } catch (IOException | InterruptedException e) { - log.error("Failed to sync", e); - } + expected2xx("full syncing", response); - return Optional.empty(); - } + return Optional.ofNullable(response.body()); + } - public boolean sendEvent(String roomId, String eventType, Object event) { - - final var uri = "/_matrix/client/v3/rooms/%s/send/%s/%s".formatted( - roomId, - eventType, - UUID.randomUUID() - ); - try { - final var response = httpClient.send( - put(uri, null, event).build(), - jsonBodyHandler(EventIdResponseDto.class) - ); - return response.statusCode() >= 200 && response.statusCode() < 300; - } catch (IOException | InterruptedException e) { - log.error("Failed to send event", e); - } + public String sendEvent(String roomId, String eventType, Object event) throws IOException, InterruptedException, MatrixApiException { - return false; - } + final var uri = "/_matrix/client/v3/rooms/%s/send/%s/%s".formatted( + roomId, + eventType, + UUID.randomUUID() + ); - public boolean joinRoom(String roomId, String reason) { - - final var uri = "/_matrix/client/v3/rooms/%s/join".formatted(roomId); - try { - final var response = httpClient.send( - post(uri, null, new RoomJoinPayloadDto(reason)).build(), - HttpResponse.BodyHandlers.ofString() - ); - return response.statusCode() >= 200 && response.statusCode() < 300; - } catch (IOException | InterruptedException e) { - log.error("Failed to join room", e); - } + final var response = httpClient.send( + put(uri, null, event).build(), + jsonBodyHandler(EventIdResponseDto.class) + ); - return false; - } + expected2xx("sending event", response); - public boolean leaveRoom(String roomId, String reason) { - - final var uri = "/_matrix/client/v3/rooms/%s/leave".formatted(roomId); - try { - final var response = httpClient.send( - post(uri, null, new RoomLeavePayloadDto(reason)).build(), - HttpResponse.BodyHandlers.ofString() - ); - return response.statusCode() >= 200 && response.statusCode() < 300; - } catch (IOException | InterruptedException e) { - log.error("Failed to leave room", e); - } + return response.body().eventId(); + } - return false; - } + public void joinRoom(String roomId, String reason) throws IOException, InterruptedException, MatrixApiException { + + final var uri = "/_matrix/client/v3/rooms/%s/join".formatted(roomId); + final var response = httpClient.send( + post(uri, null, new RoomJoinPayloadDto(reason)).build(), + HttpResponse.BodyHandlers.ofString() + ); + + expected2xx("joining room", response); + } + + public void leaveRoom(String roomId, String reason) throws IOException, InterruptedException, MatrixApiException { + + final var uri = "/_matrix/client/v3/rooms/%s/leave".formatted(roomId); + final var response = httpClient.send( + post(uri, null, new RoomLeavePayloadDto(reason)).build(), + HttpResponse.BodyHandlers.ofString() + ); + + expected2xx("leaving room", response); + } + + private HttpRequest.Builder get(String url, String query) { - private HttpRequest.Builder get(String url, String query) { + return request(url, query).GET(); + } - return request(url, query).GET(); + private HttpRequest.Builder put(String url, String query, T body) { + + try { + return request(url, query) + .header("Content-Type", "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); + } catch (JsonProcessingException e) { + throw new MatrixCommunicationException("Failed to parse JSON", e); } + } - private HttpRequest.Builder put(String url, String query, T body) { + private HttpRequest.Builder post(String url, String query, T body) { - try { - return request(url, query) - .header("Content-Type", "application/json") - .PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + try { + return request(url, query) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); + } catch (JsonProcessingException e) { + throw new MatrixCommunicationException("Failed to parse JSON", e); } + } - private HttpRequest.Builder post(String url, String query, T body) { + private HttpRequest.Builder request(String url, String query) { - try { - return request(url, query) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + HttpRequest.Builder builder; + try { + builder = HttpRequest.newBuilder(new URI(baseUri.getScheme(), null, baseUri.getHost(), baseUri.getPort(), url, query, null)) + .timeout(DEFAULT_REQUEST_TIMEOUT); + } catch (URISyntaxException e) { + throw new MatrixCommunicationException("Invalid URI when trying to make API request", e); } - private HttpRequest.Builder request(String url, String query) { + authentication.getBearerToken().ifPresent(token -> builder.header("Authorization", "Bearer %s".formatted(token))); - HttpRequest.Builder builder; - try { - builder = HttpRequest.newBuilder(new URI(baseUri.getScheme(), null, baseUri.getHost(), baseUri.getPort(), url, query, null)); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } + return builder; + } - authentication.getBearerToken().ifPresent(token -> builder.header("Authorization", "Bearer %s".formatted(token))); + private HttpResponse.BodyHandler jsonBodyHandler(Class clazz) { - return builder; - } + return responseInfo -> HttpResponse.BodySubscribers.mapping(HttpResponse.BodySubscribers.ofByteArray(), bytes -> { + try { + return objectMapper.readValue(bytes, clazz); + } catch (IOException e) { + throw new MatrixCommunicationException("Invalid URI when trying to make API request", e); + } + } + ); + } + + private void expected2xx(String performedAction, HttpResponse response) throws MatrixApiException { - private HttpResponse.BodyHandler jsonBodyHandler(Class clazz) { - - return responseInfo -> HttpResponse.BodySubscribers.mapping(HttpResponse.BodySubscribers.ofByteArray(), bytes -> { - try { - log.debug("sync: {}", new String(bytes, StandardCharsets.UTF_8)); - return objectMapper.readValue(bytes, clazz); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - ); + final var statusCode = response.statusCode(); + if (statusCode < 200 || statusCode >= 300) { + throw new MatrixApiException(performedAction, response); } + } } diff --git a/src/main/java/org/synyx/matrix/bot/internal/api/MatrixApiException.java b/src/main/java/org/synyx/matrix/bot/internal/api/MatrixApiException.java new file mode 100644 index 0000000..9dceccc --- /dev/null +++ b/src/main/java/org/synyx/matrix/bot/internal/api/MatrixApiException.java @@ -0,0 +1,17 @@ +package org.synyx.matrix.bot.internal.api; + +import java.io.IOException; +import java.net.http.HttpResponse; + +public class MatrixApiException extends Exception { + + public MatrixApiException(String performedAction, HttpResponse response) { + + super("%s failed - %d".formatted(performedAction, response.statusCode())); + } + + public MatrixApiException(String performedAction, IOException ioException) { + + super("%s failed - %s".formatted(performedAction, ioException.getClass().getName()), ioException); + } +}