diff --git a/src/main/java/oakbot/CliArguments.java b/src/main/java/oakbot/CliArguments.java index 53f0503..93e5768 100644 --- a/src/main/java/oakbot/CliArguments.java +++ b/src/main/java/oakbot/CliArguments.java @@ -74,6 +74,6 @@ All messages that are sent to the mock chat room are displayed in stdout (this Prints the version of this program. --help - Prints this help message.""".formatted(Main.VERSION, Main.URL, defaultContext); + Prints this help message.""".formatted(Main.getVersion(), Main.getUrl(), defaultContext); } } diff --git a/src/main/java/oakbot/Main.java b/src/main/java/oakbot/Main.java index 5c28ad7..44a3142 100644 --- a/src/main/java/oakbot/Main.java +++ b/src/main/java/oakbot/Main.java @@ -47,9 +47,9 @@ public final class Main { private static final Logger logger = LoggerFactory.getLogger(Main.class); - public static final String VERSION; - public static final String URL; - public static final Instant BUILT; + private static final String VERSION; + private static final String URL; + private static final Instant BUILT; static { var props = new Properties(); @@ -78,24 +78,36 @@ public final class Main { BUILT = built; } - private static final String defaultContextPath = "bot-context.xml"; + public static String getVersion() { + return VERSION; + } + + public static String getUrl() { + return URL; + } + + public static Instant getBuilt() { + return BUILT; + } + + private static final String DEFAULT_CONTEXT_PATH = "bot-context.xml"; public static void main(String[] args) throws Exception { var arguments = new CliArguments(args); if (arguments.help()) { - var help = arguments.printHelp(defaultContextPath); + var help = arguments.printHelp(DEFAULT_CONTEXT_PATH); System.out.println(help); return; } if (arguments.version()) { - System.out.println(Main.VERSION); + System.out.println(Main.getVersion()); return; } var mock = arguments.mock(); - var contextPath = (arguments.context() == null) ? defaultContextPath : arguments.context(); + var contextPath = (arguments.context() == null) ? DEFAULT_CONTEXT_PATH : arguments.context(); BotProperties botProperties; Database database; diff --git a/src/main/java/oakbot/bot/ActionContext.java b/src/main/java/oakbot/bot/ActionContext.java new file mode 100644 index 0000000..df2ee47 --- /dev/null +++ b/src/main/java/oakbot/bot/ActionContext.java @@ -0,0 +1,29 @@ +package oakbot.bot; + +import com.github.mangstadt.sochat4j.ChatMessage; + +/** + * Context information for executing chat actions. + * Provides access to bot functionality without exposing entire Bot class. + */ +public class ActionContext { + private final Bot bot; + private final ChatMessage message; + + public ActionContext(Bot bot, ChatMessage message) { + this.bot = bot; + this.message = message; + } + + public Bot getBot() { + return bot; + } + + public ChatMessage getMessage() { + return message; + } + + public int getRoomId() { + return message.getRoomId(); + } +} diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index e85db8f..3e5cb62 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -16,6 +16,7 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; import java.util.regex.Pattern; import org.jsoup.Jsoup; @@ -53,18 +54,14 @@ public class Bot implements IBot { private static final Logger logger = LoggerFactory.getLogger(Bot.class); static final int BOTLER_ID = 13750349; + + private static final Duration ROOM_JOIN_DELAY = Duration.ofSeconds(2); - private final String userName; - private final String trigger; - private final String greeting; - private final Integer userId; + private final BotConfiguration config; + private final SecurityConfiguration security; private final IChatClient connection; private final AtomicLong choreIdCounter = new AtomicLong(); private final BlockingQueue choreQueue = new PriorityBlockingQueue<>(); - private final List admins; - private final List bannedUsers; - private final List allowedUsers; - private final Duration hideOneboxesAfter; private final Rooms rooms; private final Integer maxRooms; private final List listeners; @@ -101,15 +98,13 @@ public class Bot implements IBot { private Bot(Builder builder) { connection = Objects.requireNonNull(builder.connection); - userName = (connection.getUsername() == null) ? builder.userName : connection.getUsername(); - userId = (connection.getUserId() == null) ? builder.userId : connection.getUserId(); - hideOneboxesAfter = builder.hideOneboxesAfter; - trigger = Objects.requireNonNull(builder.trigger); - greeting = builder.greeting; + var userName = (connection.getUsername() == null) ? builder.userName : connection.getUsername(); + var userId = (connection.getUserId() == null) ? builder.userId : connection.getUserId(); + + config = new BotConfiguration(userName, userId, builder.trigger, builder.greeting, builder.hideOneboxesAfter); + security = new SecurityConfiguration(builder.admins, builder.bannedUsers, builder.allowedUsers); + maxRooms = builder.maxRooms; - admins = builder.admins; - bannedUsers = builder.bannedUsers; - allowedUsers = builder.allowedUsers; stats = builder.stats; database = (builder.database == null) ? new MemoryDatabase() : builder.database; rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); @@ -160,7 +155,7 @@ private void joinRoomsOnStart(boolean quiet) { * resolve an issue where the bot chooses to ignore all messages * in certain rooms. */ - Sleeper.sleep(2000); + Sleeper.sleep(ROOM_JOIN_DELAY); } try { @@ -322,9 +317,9 @@ private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - if (!quiet && greeting != null) { + if (!quiet && config.greeting() != null) { try { - sendMessage(room, greeting); + sendMessage(room, config.greeting()); } catch (RoomPermissionException e) { logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); } @@ -360,17 +355,21 @@ public void leave(int roomId) throws IOException { @Override public String getUsername() { - return userName; + return config.userName(); } @Override public Integer getUserId() { - return userId; + return config.userId(); } @Override public List getAdminUsers() { - return admins; + return security.getAdmins(); + } + + private boolean isAdminUser(Integer userId) { + return security.isAdmin(userId); } @Override @@ -381,7 +380,7 @@ public boolean isRoomOwner(int roomId, int userId) throws IOException { @Override public String getTrigger() { - return trigger; + return config.trigger(); } @Override @@ -603,28 +602,52 @@ public int compareTo(Chore that) { * The "lowest" value will be popped off the queue first. */ - if (this instanceof StopChore && that instanceof StopChore) { + if (isBothStopChore(that)) { return 0; } - if (this instanceof StopChore) { + if (isThisStopChore()) { return -1; } - if (that instanceof StopChore) { + if (isThatStopChore(that)) { return 1; } - if (this instanceof CondenseMessageChore && that instanceof CondenseMessageChore) { + if (isBothCondenseMessageChore(that)) { return Long.compare(this.choreId, that.choreId); } - if (this instanceof CondenseMessageChore) { + if (isThisCondenseMessageChore()) { return -1; } - if (that instanceof CondenseMessageChore) { + if (isThatCondenseMessageChore(that)) { return 1; } return Long.compare(this.choreId, that.choreId); } + + private boolean isBothStopChore(Chore that) { + return this instanceof StopChore && that instanceof StopChore; + } + + private boolean isThisStopChore() { + return this instanceof StopChore; + } + + private boolean isThatStopChore(Chore that) { + return that instanceof StopChore; + } + + private boolean isBothCondenseMessageChore(Chore that) { + return this instanceof CondenseMessageChore && that instanceof CondenseMessageChore; + } + + private boolean isThisCondenseMessageChore() { + return this instanceof CondenseMessageChore; + } + + private boolean isThatCondenseMessageChore(Chore that) { + return that instanceof CondenseMessageChore; + } } private class StopChore extends Chore { @@ -688,22 +711,30 @@ public void complete() { } private void handleMessage(ChatMessage message) { - if (timeout && !isAdminUser(message.getUserId())) { + var userId = message.getUserId(); + var isAdminUser = isAdminUser(userId); + var isBotInTimeout = timeout && !isAdminUser; + + if (isBotInTimeout) { //bot is in timeout, ignore return; } - if (message.getContent() == null) { + var messageWasDeleted = message.getContent() == null; + if (messageWasDeleted) { //user deleted their message, ignore return; } - if (!allowedUsers.isEmpty() && !allowedUsers.contains(message.getUserId())) { + var hasAllowedUsersList = !security.getAllowedUsers().isEmpty(); + var userIsAllowed = security.isAllowed(userId); + if (hasAllowedUsersList && !userIsAllowed) { //message was posted by a user who is not in the green list, ignore return; } - if (bannedUsers.contains(message.getUserId())) { + var userIsBanned = security.isBanned(userId); + if (userIsBanned) { //message was posted by a banned user, ignore return; } @@ -714,7 +745,7 @@ private void handleMessage(ChatMessage message) { return; } - if (message.getUserId() == userId) { + if (message.getUserId() == config.userId()) { //message was posted by this bot handleBotMessage(message); return; @@ -757,9 +788,9 @@ private void handleBotMessage(ChatMessage message) { * the URL is still preserved. */ var messageIsOnebox = message.getContent().isOnebox(); - if (postedMessage != null && hideOneboxesAfter != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { + if (postedMessage != null && config.hideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); - var hideIn = hideOneboxesAfter.minus(postedMessageAge); + var hideIn = config.hideOneboxesAfter().minus(postedMessageAge); logger.atInfo().log(() -> { var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; @@ -796,34 +827,22 @@ private void handleActions(ChatMessage message, ChatActions actions) { var queue = new LinkedList<>(actions.getActions()); while (!queue.isEmpty()) { var action = queue.removeFirst(); + processAction(action, message, queue); + } + } - if (action instanceof PostMessage pm) { - handlePostMessageAction(pm, message); - continue; - } - - if (action instanceof DeleteMessage dm) { - var response = handleDeleteMessageAction(dm, message); - queue.addAll(response.getActions()); - continue; - } - - if (action instanceof JoinRoom jr) { - var response = handleJoinRoomAction(jr); - queue.addAll(response.getActions()); - continue; - } - - if (action instanceof LeaveRoom lr) { - handleLeaveRoomAction(lr); - continue; - } - - if (action instanceof Shutdown) { - stop(); - continue; - } + private void processAction(ChatAction action, ChatMessage message, LinkedList queue) { + // Polymorphic dispatch - each action knows how to execute itself + // Special handling for PostMessage delays is done within PostMessage.execute() + if (action instanceof PostMessage pm && pm.delay() != null) { + // Delayed messages need access to internal scheduling + handlePostMessageAction(pm, message); + return; } + + var context = new ActionContext(Bot.this, message); + var response = action.execute(context); + queue.addAll(response.getActions()); } private void handlePostMessageAction(PostMessage action, ChatMessage message) { @@ -863,21 +882,10 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { if (joinedRoom.canPost()) { return action.onSuccess().get(); } - - try { - leave(action.roomId()); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + " after it was found that the bot can't post messages to it."); - } - + leaveRoomSafely(action.roomId(), () -> "Problem leaving room " + action.roomId() + " after it was found that the bot can't post messages to it."); return action.ifLackingPermissionToPost().get(); } catch (PrivateRoomException | RoomPermissionException e) { - try { - leave(action.roomId()); - } catch (Exception e2) { - logger.atError().setCause(e2).log(() -> "Problem leaving room " + action.roomId() + " after it was found that the bot can't join or post messages to it."); - } - + leaveRoomSafely(action.roomId(), () -> "Problem leaving room " + action.roomId() + " after it was found that the bot can't join or post messages to it."); return action.ifLackingPermissionToPost().get(); } catch (RoomNotFoundException e) { return action.ifRoomDoesNotExist().get(); @@ -886,6 +894,20 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { } } + /** + * Attempts to leave a room and logs any errors that occur. + * @param roomId the room ID to leave + * @param logMessage supplier for the complete log message (evaluated only if an error occurs) + **/ + private void leaveRoomSafely(int roomId, Supplier logMessage) { + try { + leave(roomId); + } + catch (Exception e) { + logger.atError().setCause(e).log(logMessage); + } + } + private void handleLeaveRoomAction(LeaveRoom action) { try { leave(action.roomId()); diff --git a/src/main/java/oakbot/bot/BotConfiguration.java b/src/main/java/oakbot/bot/BotConfiguration.java new file mode 100644 index 0000000..9bcfb68 --- /dev/null +++ b/src/main/java/oakbot/bot/BotConfiguration.java @@ -0,0 +1,22 @@ +package oakbot.bot; + +import java.time.Duration; + +/** + * Configuration settings for the Bot. + * Groups related configuration parameters together. + * + * @param userName the bot's username + * @param userId the bot's user ID + * @param trigger the command trigger (e.g., "/") + * @param greeting the greeting message to post when joining rooms + * @param hideOneboxesAfter duration after which to condense/hide onebox messages + */ +public record BotConfiguration( + String userName, + Integer userId, + String trigger, + String greeting, + Duration hideOneboxesAfter +) { +} diff --git a/src/main/java/oakbot/bot/ChatAction.java b/src/main/java/oakbot/bot/ChatAction.java index 272bd15..af1279c 100644 --- a/src/main/java/oakbot/bot/ChatAction.java +++ b/src/main/java/oakbot/bot/ChatAction.java @@ -2,8 +2,14 @@ /** * Represents an action to perform in response to a chat message. + * Implementations define specific actions like posting messages, joining rooms, etc. * @author Michael Angstadt */ public interface ChatAction { - //empty + /** + * Executes this action. + * @param context the execution context containing bot and message information + * @return additional actions to be performed, or empty if none + */ + ChatActions execute(ActionContext context); } diff --git a/src/main/java/oakbot/bot/ChatCommand.java b/src/main/java/oakbot/bot/ChatCommand.java index ad83505..7aff2ea 100644 --- a/src/main/java/oakbot/bot/ChatCommand.java +++ b/src/main/java/oakbot/bot/ChatCommand.java @@ -92,51 +92,89 @@ public List getContentAsArgs() { } var md = getContentMarkdown().trim(); + return parseArguments(md); + } + + /** + * Parses a markdown string into whitespace-delimited arguments. + * Handles quoted strings and escape sequences. + * @param text the text to parse + * @return the list of arguments + */ + private List parseArguments(String text) { var args = new ArrayList(); + var parser = new ArgumentParser(); + var it = new CharIterator(text); - var inQuotes = false; - var escapeNext = false; - var sb = new StringBuilder(); - var it = new CharIterator(md); while (it.hasNext()) { var c = it.next(); + parser.processCharacter(c, args); + } + parser.finalizeCurrentArgument(args); + return args; + } + + /** + * Helper class to parse command arguments with quote and escape handling. + */ + private static class ArgumentParser { + private final StringBuilder currentArg = new StringBuilder(); + private boolean inQuotes = false; + private boolean escapeNext = false; + + void processCharacter(char c, List args) { if (escapeNext) { - sb.append(c); - escapeNext = false; - continue; + handleEscapedCharacter(c); + return; } - if (Character.isWhitespace(c) && !inQuotes) { - if (!sb.isEmpty()) { - args.add(sb.toString()); - sb.setLength(0); - } - continue; + if (c == '\\') { + escapeNext = true; + return; } if (c == '"') { - if (inQuotes) { - args.add(sb.toString()); - sb.setLength(0); - } - inQuotes = !inQuotes; - continue; + handleQuote(args); + return; } - if (c == '\\') { - escapeNext = true; - continue; + if (Character.isWhitespace(c) && !inQuotes) { + handleWhitespace(args); + return; } - sb.append(c); + currentArg.append(c); } - if (!sb.isEmpty()) { - args.add(sb.toString()); + private void handleEscapedCharacter(char c) { + currentArg.append(c); + escapeNext = false; } - return args; + private void handleQuote(List args) { + if (inQuotes) { + addCurrentArgument(args); + } + inQuotes = !inQuotes; + } + + private void handleWhitespace(List args) { + if (currentArg.length() > 0) { + addCurrentArgument(args); + } + } + + private void addCurrentArgument(List args) { + args.add(currentArg.toString()); + currentArg.setLength(0); + } + + void finalizeCurrentArgument(List args) { + if (currentArg.length() > 0) { + addCurrentArgument(args); + } + } } /** diff --git a/src/main/java/oakbot/bot/DeleteMessage.java b/src/main/java/oakbot/bot/DeleteMessage.java index 9bdac01..cadae07 100644 --- a/src/main/java/oakbot/bot/DeleteMessage.java +++ b/src/main/java/oakbot/bot/DeleteMessage.java @@ -75,4 +75,16 @@ public DeleteMessage onError(Function actions) { onError = actions; return this; } + + @Override + public ChatActions execute(ActionContext context) { + try { + var room = context.getBot().getRoom(context.getRoomId()); + room.deleteMessage(messageId); + return onSuccess.get(); + } catch (Exception e) { + return onError.apply(e); + } + } + } diff --git a/src/main/java/oakbot/bot/JoinRoom.java b/src/main/java/oakbot/bot/JoinRoom.java index 1ff17e8..cbfa7da 100644 --- a/src/main/java/oakbot/bot/JoinRoom.java +++ b/src/main/java/oakbot/bot/JoinRoom.java @@ -118,4 +118,45 @@ public JoinRoom onError(Function actions) { onError = actions; return this; } + + @Override + public ChatActions execute(ActionContext context) { + var bot = context.getBot(); + + // Check max rooms limit + var maxRooms = bot.getMaxRooms(); + if (maxRooms != null && bot.getRooms().size() >= maxRooms) { + return onError.apply(new java.io.IOException("Cannot join room. Max rooms reached.")); + } + + try { + bot.join(roomId); + var room = bot.getRoom(roomId); + + if (room != null && room.canPost()) { + return onSuccess.get(); + } + + // Can't post, leave the room + try { + bot.leave(roomId); + } catch (Exception e) { + // Log but don't propagate + } + + return ifLackingPermissionToPost.get(); + } catch (com.github.mangstadt.sochat4j.RoomNotFoundException e) { + return ifRoomDoesNotExist.get(); + } catch (com.github.mangstadt.sochat4j.PrivateRoomException | com.github.mangstadt.sochat4j.RoomPermissionException e) { + try { + bot.leave(roomId); + } catch (Exception e2) { + // Log but don't propagate + } + return ifLackingPermissionToPost.get(); + } catch (Exception e) { + return onError.apply(e); + } + } + } diff --git a/src/main/java/oakbot/bot/LeaveRoom.java b/src/main/java/oakbot/bot/LeaveRoom.java index 9dc7f84..c883158 100644 --- a/src/main/java/oakbot/bot/LeaveRoom.java +++ b/src/main/java/oakbot/bot/LeaveRoom.java @@ -31,4 +31,15 @@ public LeaveRoom roomId(int roomId) { this.roomId = roomId; return this; } + + @Override + public ChatActions execute(ActionContext context) { + try { + context.getBot().leave(roomId); + } catch (Exception e) { + // Log but don't propagate + } + return ChatActions.doNothing(); + } + } diff --git a/src/main/java/oakbot/bot/PostMessage.java b/src/main/java/oakbot/bot/PostMessage.java index d92fb2d..d024f16 100644 --- a/src/main/java/oakbot/bot/PostMessage.java +++ b/src/main/java/oakbot/bot/PostMessage.java @@ -198,6 +198,29 @@ public Duration delay() { return delay; } + @Override + public ChatActions execute(ActionContext context) { + try { + var bot = context.getBot(); + var roomId = context.getRoomId(); + + if (delay != null) { + // Delayed messages require access to Bot's internal scheduling + // Return empty actions and let Bot handle it specially + return ChatActions.doNothing(); + } else { + if (broadcast) { + bot.broadcastMessage(this); + } else { + bot.sendMessage(roomId, this); + } + } + } catch (Exception e) { + // Exceptions are logged by Bot + } + return ChatActions.doNothing(); + } + @Override public int hashCode() { return Objects.hash(broadcast, bypassFilters, condensedMessage, delay, ephemeral, message, parentId, splitStrategy); diff --git a/src/main/java/oakbot/bot/SecurityConfiguration.java b/src/main/java/oakbot/bot/SecurityConfiguration.java new file mode 100644 index 0000000..6849278 --- /dev/null +++ b/src/main/java/oakbot/bot/SecurityConfiguration.java @@ -0,0 +1,43 @@ +package oakbot.bot; + +import java.util.List; + +/** + * Security-related configuration for the Bot. + * Manages user permissions and access control. + */ +public class SecurityConfiguration { + private final List admins; + private final List bannedUsers; + private final List allowedUsers; + + public SecurityConfiguration(List admins, List bannedUsers, List allowedUsers) { + this.admins = List.copyOf(admins); + this.bannedUsers = List.copyOf(bannedUsers); + this.allowedUsers = List.copyOf(allowedUsers); + } + + public List getAdmins() { + return admins; + } + + public List getBannedUsers() { + return bannedUsers; + } + + public List getAllowedUsers() { + return allowedUsers; + } + + public boolean isAdmin(Integer userId) { + return admins.contains(userId); + } + + public boolean isBanned(Integer userId) { + return bannedUsers.contains(userId); + } + + public boolean isAllowed(Integer userId) { + return allowedUsers.isEmpty() || allowedUsers.contains(userId); + } +} diff --git a/src/main/java/oakbot/bot/Shutdown.java b/src/main/java/oakbot/bot/Shutdown.java index 27e161b..abfc327 100644 --- a/src/main/java/oakbot/bot/Shutdown.java +++ b/src/main/java/oakbot/bot/Shutdown.java @@ -5,5 +5,9 @@ * @author Michael Angstadt */ public class Shutdown implements ChatAction { - //empty + @Override + public ChatActions execute(ActionContext context) { + context.getBot().stop(); + return ChatActions.doNothing(); + } } diff --git a/src/main/java/oakbot/command/AboutCommand.java b/src/main/java/oakbot/command/AboutCommand.java index d036fd5..d7d5f93 100644 --- a/src/main/java/oakbot/command/AboutCommand.java +++ b/src/main/java/oakbot/command/AboutCommand.java @@ -45,12 +45,12 @@ public HelpDoc help() { @Override public ChatActions onMessage(ChatCommand chatCommand, IBot bot) { var relativeDf = new RelativeDateFormat(); - var built = LocalDateTime.ofInstant(Main.BUILT, ZoneId.systemDefault()); + var built = LocalDateTime.ofInstant(Main.getBuilt(), ZoneId.systemDefault()); //@formatter:off var cb = new ChatBuilder() .bold("OakBot").append(" by ").link("Michael", "https://stackoverflow.com/users/13379/michael").append(" | ") - .link("source code", Main.URL).append(" | ") + .link("source code", Main.getUrl()).append(" | ") .append("JAR built on: ").append(relativeDf.format(built)).append(" | ") .append("started up: ").append(relativeDf.format(startedUp)); //@formatter:on diff --git a/src/main/java/oakbot/discord/AboutCommand.java b/src/main/java/oakbot/discord/AboutCommand.java index 69b9de0..f7b3bd5 100644 --- a/src/main/java/oakbot/discord/AboutCommand.java +++ b/src/main/java/oakbot/discord/AboutCommand.java @@ -33,12 +33,12 @@ public HelpDoc help() { @Override public void onMessage(String content, MessageReceivedEvent event, BotContext context) { var relativeDf = new RelativeDateFormat(); - var built = LocalDateTime.ofInstant(Main.BUILT, ZoneId.systemDefault()); + var built = LocalDateTime.ofInstant(Main.getBuilt(), ZoneId.systemDefault()); //@formatter:off var cb = new ChatBuilder() .bold("OakBot").append(" by ").link("Michael", "https://stackoverflow.com/users/13379/michael").nl() - .link("source code", Main.URL).nl() + .link("source code", Main.getUrl()).nl() .append("JAR built on: ").append(relativeDf.format(built)).append(" | ") .append("started up: ").append(relativeDf.format(startedUp)); //@formatter:on