From ca6f14337b3e820c63e6d58a77551e8255e133b2 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Thu, 27 Nov 2025 01:49:01 -0400 Subject: [PATCH 01/17] Chore: Fix design and implementation smells --- src/main/java/oakbot/CliArguments.java | 2 +- src/main/java/oakbot/Main.java | 26 +++- src/main/java/oakbot/bot/Bot.java | 136 ++++++++++-------- src/main/java/oakbot/bot/ChatAction.java | 8 +- src/main/java/oakbot/bot/DeleteMessage.java | 11 ++ src/main/java/oakbot/bot/JoinRoom.java | 40 ++++++ src/main/java/oakbot/bot/LeaveRoom.java | 10 ++ src/main/java/oakbot/bot/PostMessage.java | 23 +++ src/main/java/oakbot/bot/Shutdown.java | 6 +- .../java/oakbot/command/AboutCommand.java | 4 +- .../java/oakbot/discord/AboutCommand.java | 4 +- 11 files changed, 196 insertions(+), 74 deletions(-) diff --git a/src/main/java/oakbot/CliArguments.java b/src/main/java/oakbot/CliArguments.java index 53f05030..93e57684 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 5c28ad70..44a31420 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/Bot.java b/src/main/java/oakbot/bot/Bot.java index e85db8f6..70352e45 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -54,17 +54,11 @@ public class Bot implements IBot { private static final Logger logger = LoggerFactory.getLogger(Bot.class); static final int BOTLER_ID = 13750349; - 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 +95,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); @@ -322,9 +314,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.getGreeting() != null) { try { - sendMessage(room, greeting); + sendMessage(room, config.getGreeting()); } catch (RoomPermissionException e) { logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); } @@ -360,17 +352,21 @@ public void leave(int roomId) throws IOException { @Override public String getUsername() { - return userName; + return config.getUserName(); } @Override public Integer getUserId() { - return userId; + return config.getUserId(); } @Override public List getAdminUsers() { - return admins; + return security.getAdmins(); + } + + private boolean isAdminUser(Integer userId) { + return security.isAdmin(userId); } @Override @@ -381,7 +377,7 @@ public boolean isRoomOwner(int roomId, int userId) throws IOException { @Override public String getTrigger() { - return trigger; + return config.getTrigger(); } @Override @@ -603,28 +599,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 +708,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; } @@ -757,9 +785,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.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); - var hideIn = hideOneboxesAfter.minus(postedMessageAge); + var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); logger.atInfo().log(() -> { var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; @@ -796,34 +824,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(this, message); + var response = action.execute(context); + queue.addAll(response.getActions()); } private void handlePostMessageAction(PostMessage action, ChatMessage message) { diff --git a/src/main/java/oakbot/bot/ChatAction.java b/src/main/java/oakbot/bot/ChatAction.java index 272bd152..af1279c7 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/DeleteMessage.java b/src/main/java/oakbot/bot/DeleteMessage.java index 9bdac016..ae56cb6d 100644 --- a/src/main/java/oakbot/bot/DeleteMessage.java +++ b/src/main/java/oakbot/bot/DeleteMessage.java @@ -75,4 +75,15 @@ 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 1ff17e89..e78e1899 100644 --- a/src/main/java/oakbot/bot/JoinRoom.java +++ b/src/main/java/oakbot/bot/JoinRoom.java @@ -118,4 +118,44 @@ 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 9dc7f841..367bc765 100644 --- a/src/main/java/oakbot/bot/LeaveRoom.java +++ b/src/main/java/oakbot/bot/LeaveRoom.java @@ -31,4 +31,14 @@ 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 d92fb2dd..59d44216 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 + // For now, return empty actions and let Bot handle it + 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/Shutdown.java b/src/main/java/oakbot/bot/Shutdown.java index 27e161b7..abfc327e 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 d036fd5a..d7d5f930 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 69b9de09..f7b3bd5e 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 From 00f8662b891b46f3865aa2fa24cfc0839c6b7454 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Thu, 27 Nov 2025 03:23:52 -0400 Subject: [PATCH 02/17] Chore: Fix design and implementation smells --- src/main/java/oakbot/bot/ActionContext.java | 29 +++++++++++++ .../java/oakbot/bot/BotConfiguration.java | 43 +++++++++++++++++++ .../oakbot/bot/SecurityConfiguration.java | 43 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/main/java/oakbot/bot/ActionContext.java create mode 100644 src/main/java/oakbot/bot/BotConfiguration.java create mode 100644 src/main/java/oakbot/bot/SecurityConfiguration.java diff --git a/src/main/java/oakbot/bot/ActionContext.java b/src/main/java/oakbot/bot/ActionContext.java new file mode 100644 index 00000000..df2ee470 --- /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/BotConfiguration.java b/src/main/java/oakbot/bot/BotConfiguration.java new file mode 100644 index 00000000..48fbb72f --- /dev/null +++ b/src/main/java/oakbot/bot/BotConfiguration.java @@ -0,0 +1,43 @@ +package oakbot.bot; + +import java.time.Duration; + +/** + * Configuration settings for the Bot. + * Groups related configuration parameters together. + */ +public class BotConfiguration { + private final String userName; + private final Integer userId; + private final String trigger; + private final String greeting; + private final Duration hideOneboxesAfter; + + public BotConfiguration(String userName, Integer userId, String trigger, String greeting, Duration hideOneboxesAfter) { + this.userName = userName; + this.userId = userId; + this.trigger = trigger; + this.greeting = greeting; + this.hideOneboxesAfter = hideOneboxesAfter; + } + + public String getUserName() { + return userName; + } + + public Integer getUserId() { + return userId; + } + + public String getTrigger() { + return trigger; + } + + public String getGreeting() { + return greeting; + } + + public Duration getHideOneboxesAfter() { + return hideOneboxesAfter; + } +} diff --git a/src/main/java/oakbot/bot/SecurityConfiguration.java b/src/main/java/oakbot/bot/SecurityConfiguration.java new file mode 100644 index 00000000..68492785 --- /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); + } +} From a78bc02294ee6fe9aec9e1a90612b36479c84cd6 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Thu, 27 Nov 2025 03:35:56 -0400 Subject: [PATCH 03/17] chore: Magic number fix --- src/main/java/oakbot/bot/Bot.java | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 70352e45..884760b8 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -53,6 +53,8 @@ public class Bot implements IBot { private static final Logger logger = LoggerFactory.getLogger(Bot.class); static final int BOTLER_ID = 13750349; + + private static final int ROOM_JOIN_DELAY_MS = 2000; private final BotConfiguration config; private final SecurityConfiguration security; @@ -152,7 +154,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_MS); } try { @@ -593,6 +595,16 @@ public Chore() { public abstract void complete(); + /** + * Logs an error that occurred during chore execution. + * This method is pulled up from subclasses to provide common error logging functionality. + * @param message the error message + * @param cause the exception that caused the error + */ + protected void logError(String message, Exception cause) { + logger.atError().setCause(cause).log(() -> message); + } + @Override public int compareTo(Chore that) { /* @@ -986,7 +998,7 @@ public void complete() { room.deleteMessage(id); } } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]"); + logError("Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]", e); } } @@ -1018,7 +1030,7 @@ public void complete() { try { task.run(Bot.this); } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running scheduled task."); + logError("Problem running scheduled task.", e); } scheduleTask(task); } @@ -1052,7 +1064,7 @@ public void complete() { try { task.run(room, Bot.this); } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running inactivity task in room " + room.getRoomId() + "."); + logError("Problem running inactivity task in room " + room.getRoomId() + ".", e); } } @@ -1082,7 +1094,7 @@ public void complete() { sendMessage(roomId, message); } } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message()); + logError("Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message(), e); } } } From 88242a60610fb7773c865e0f0e68b880f9890ed7 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Thu, 27 Nov 2025 09:38:23 -0400 Subject: [PATCH 04/17] Reduced the cylometic complexity of a method --- src/main/java/oakbot/bot/ChatCommand.java | 90 ++++++++++++++++------- 1 file changed, 64 insertions(+), 26 deletions(-) diff --git a/src/main/java/oakbot/bot/ChatCommand.java b/src/main/java/oakbot/bot/ChatCommand.java index ad835051..7aff2ea1 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); + } + } } /** From ee56a0dc2ed4e0fc929f71ec9de5ef4ca64ac0f2 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Thu, 27 Nov 2025 09:42:48 -0400 Subject: [PATCH 05/17] refactoring --- src/main/java/oakbot/bot/Bot.java | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 884760b8..0accac99 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -892,20 +892,10 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { 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(), "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(), "the bot can't join or post messages to it"); return action.ifLackingPermissionToPost().get(); } catch (RoomNotFoundException e) { return action.ifRoomDoesNotExist().get(); @@ -914,6 +904,19 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { } } + /** + * Attempts to leave a room and logs any errors that occur. + * @param roomId the room ID to leave + * @param reason the reason for leaving (used in error message) + */ + private void leaveRoomSafely(int roomId, String reason) { + try { + leave(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem leaving room " + roomId + " after it was found that " + reason + "."); + } + } + private void handleLeaveRoomAction(LeaveRoom action) { try { leave(action.roomId()); From f5a3c1ac90bb0a64f01e650f50f8aec370745ddf Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Fri, 28 Nov 2025 19:56:57 -0400 Subject: [PATCH 06/17] Make ROOM_JOIN_DELAY as Duration. Remove logging function and revert it to earlier --- src/main/java/oakbot/bot/Bot.java | 2560 ++++++++++++++--------------- 1 file changed, 1275 insertions(+), 1285 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 0accac99..2bd340b3 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -1,33 +1,6 @@ package oakbot.bot; -import java.io.IOException; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.PriorityBlockingQueue; -import java.util.concurrent.atomic.AtomicLong; -import java.util.regex.Pattern; - -import org.jsoup.Jsoup; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.github.mangstadt.sochat4j.ChatMessage; -import com.github.mangstadt.sochat4j.IChatClient; -import com.github.mangstadt.sochat4j.IRoom; -import com.github.mangstadt.sochat4j.PrivateRoomException; -import com.github.mangstadt.sochat4j.RoomNotFoundException; -import com.github.mangstadt.sochat4j.RoomPermissionException; +import com.github.mangstadt.sochat4j.*; import com.github.mangstadt.sochat4j.event.Event; import com.github.mangstadt.sochat4j.event.InvitationEvent; import com.github.mangstadt.sochat4j.event.MessageEditedEvent; @@ -35,7 +8,6 @@ import com.github.mangstadt.sochat4j.util.Sleeper; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; - import oakbot.Database; import oakbot.MemoryDatabase; import oakbot.Rooms; @@ -45,1275 +17,1293 @@ import oakbot.listener.Listener; import oakbot.task.ScheduledTask; import oakbot.util.ChatBuilder; +import org.jsoup.Jsoup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; /** * A Stackoverflow chat bot. + * * @author Michael Angstadt */ public class Bot implements IBot { - private static final Logger logger = LoggerFactory.getLogger(Bot.class); - static final int BOTLER_ID = 13750349; - - private static final int ROOM_JOIN_DELAY_MS = 2000; - - 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 Rooms rooms; - private final Integer maxRooms; - private final List listeners; - private final List responseFilters; - private final List scheduledTasks; - private final List inactivityTasks; - private final Map timeOfLastMessageByRoom = new HashMap<>(); - private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); - private final Statistics stats; - private final Database database; - private final Timer timer = new Timer(); - private TimerTask timeoutTask; - private volatile boolean timeout = false; - - /** - *

- * A collection of messages that the bot posted, but have not been "echoed" - * back yet in the chat room. When a message is echoed back, it is removed - * from this map. - *

- *

- * This is used to determine whether something the bot posted was converted - * to a onebox. It is then used to edit the message in order to hide the - * onebox. - *

- *
    - *
  • Key = The message ID.
  • - *
  • Value = The raw message content that was sent to the chat room by the - * bot (which can be different from what was echoed back).
  • - *
- */ - private final Map postedMessages = new HashMap<>(); - - private Bot(Builder builder) { - connection = Objects.requireNonNull(builder.connection); - - 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; - stats = builder.stats; - database = (builder.database == null) ? new MemoryDatabase() : builder.database; - rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); - listeners = builder.listeners; - scheduledTasks = builder.tasks; - inactivityTasks = builder.inactivityTasks; - responseFilters = builder.responseFilters; - } - - private void scheduleTask(ScheduledTask task) { - var nextRun = task.nextRun(); - if (nextRun <= 0) { - return; - } - - scheduleChore(nextRun, new ScheduledTaskChore(task)); - } - - private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { - var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); - inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); - } - - /** - * Starts the chat bot. The bot will join the rooms in the current thread - * before launching its own thread. - * @param quiet true to start the bot without broadcasting the greeting - * message, false to broadcast the greeting message - * @return the thread that the bot is running in. This thread will terminate - * when the bot terminates - * @throws IOException if there's a network problem - */ - public Thread connect(boolean quiet) throws IOException { - joinRoomsOnStart(quiet); - - var thread = new ChoreThread(); - thread.start(); - return thread; - } - - private void joinRoomsOnStart(boolean quiet) { - var first = true; - var roomsCopy = new ArrayList<>(rooms.getRooms()); - for (var room : roomsCopy) { - if (!first) { - /* - * Insert a pause between joining each room in an attempt to - * resolve an issue where the bot chooses to ignore all messages - * in certain rooms. - */ - Sleeper.sleep(ROOM_JOIN_DELAY_MS); - } - - try { - joinRoom(room, quiet); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); - rooms.remove(room); - } - - first = false; - } - } - - private class ChoreThread extends Thread { - @Override - public void run() { - try { - scheduledTasks.forEach(Bot.this::scheduleTask); - - while (true) { - Chore chore; - try { - chore = choreQueue.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); - break; - } - - if (chore instanceof StopChore || chore instanceof FinishChore) { - break; - } - - chore.complete(); - database.commit(); - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); - } finally { - try { - connection.close(); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); - } - - database.commit(); - timer.cancel(); - } - } - } - - @Override - public List getLatestMessages(int roomId, int count) throws IOException { - var room = connection.getRoom(roomId); - var notInRoom = (room == null); - if (notInRoom) { - return List.of(); - } - - //@formatter:off + 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 BotConfiguration config; + private final SecurityConfiguration security; + private final IChatClient connection; + private final AtomicLong choreIdCounter = new AtomicLong(); + private final BlockingQueue choreQueue = new PriorityBlockingQueue<>(); + private final Rooms rooms; + private final Integer maxRooms; + private final List listeners; + private final List responseFilters; + private final List scheduledTasks; + private final List inactivityTasks; + private final Map timeOfLastMessageByRoom = new HashMap<>(); + private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); + private final Statistics stats; + private final Database database; + private final Timer timer = new Timer(); + private TimerTask timeoutTask; + private volatile boolean timeout = false; + + /** + *

+ * A collection of messages that the bot posted, but have not been "echoed" + * back yet in the chat room. When a message is echoed back, it is removed + * from this map. + *

+ *

+ * This is used to determine whether something the bot posted was converted + * to a onebox. It is then used to edit the message in order to hide the + * onebox. + *

+ *
    + *
  • Key = The message ID.
  • + *
  • Value = The raw message content that was sent to the chat room by the + * bot (which can be different from what was echoed back).
  • + *
+ */ + private final Map postedMessages = new HashMap<>(); + + private Bot(Builder builder) { + connection = Objects.requireNonNull(builder.connection); + + 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; + stats = builder.stats; + database = (builder.database == null) ? new MemoryDatabase() : builder.database; + rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); + listeners = builder.listeners; + scheduledTasks = builder.tasks; + inactivityTasks = builder.inactivityTasks; + responseFilters = builder.responseFilters; + } + + private void scheduleTask(ScheduledTask task) { + var nextRun = task.nextRun(); + if (nextRun <= 0) { + return; + } + + scheduleChore(nextRun, new ScheduledTaskChore(task)); + } + + private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { + var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); + inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); + } + + /** + * Starts the chat bot. The bot will join the rooms in the current thread + * before launching its own thread. + * @param quiet true to start the bot without broadcasting the greeting + * message, false to broadcast the greeting message + * @return the thread that the bot is running in. This thread will terminate + * when the bot terminates + * @throws IOException if there's a network problem + */ + public Thread connect(boolean quiet) throws IOException { + joinRoomsOnStart(quiet); + + var thread = new ChoreThread(); + thread.start(); + return thread; + } + + private void joinRoomsOnStart(boolean quiet) { + var first = true; + var roomsCopy = new ArrayList<>(rooms.getRooms()); + for (var room : roomsCopy) { + if (!first) { + /* + * Insert a pause between joining each room in an attempt to + * resolve an issue where the bot chooses to ignore all messages + * in certain rooms. + */ + Sleeper.sleep(ROOM_JOIN_DELAY); + } + + try { + joinRoom(room, quiet); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); + rooms.remove(room); + } + + first = false; + } + } + + private class ChoreThread extends Thread { + @Override + public void run() { + try { + scheduledTasks.forEach(Bot.this::scheduleTask); + + while (true) { + Chore chore; + try { + chore = choreQueue.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); + break; + } + + if (chore instanceof StopChore || chore instanceof FinishChore) { + break; + } + + chore.complete(); + database.commit(); + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); + } finally { + try { + connection.close(); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); + } + + database.commit(); + timer.cancel(); + } + } + } + + @Override + public List getLatestMessages(int roomId, int count) throws IOException { + var room = connection.getRoom(roomId); + var notInRoom = (room == null); + if (notInRoom) { + return List.of(); + } + + //@formatter:off return room.getMessages(count).stream() .map(this::convertFromBotlerRelayMessage) .toList(); //@formatter:on - } - - @Override - public String getOriginalMessageContent(long messageId) throws IOException { - return connection.getOriginalMessageContent(messageId); - } - - @Override - public String uploadImage(String url) throws IOException { - return connection.uploadImage(url); - } - - @Override - public String uploadImage(byte[] data) throws IOException { - return connection.uploadImage(data); - } - - @Override - public void sendMessage(int roomId, PostMessage message) throws IOException { - var room = connection.getRoom(roomId); - if (room != null) { - sendMessage(room, message); - } - } - - private void sendMessage(IRoom room, String message) throws IOException { - sendMessage(room, new PostMessage(message)); - } - - private void sendMessage(IRoom room, PostMessage message) throws IOException { - final String filteredMessage; - if (message.bypassFilters()) { - filteredMessage = message.message(); - } else { - var messageText = message.message(); - for (var filter : responseFilters) { - if (filter.isEnabled(room.getRoomId())) { - messageText = filter.filter(messageText); - } - } - filteredMessage = messageText; - } - - logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); - - synchronized (postedMessages) { - var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); - var condensedMessage = message.condensedMessage(); - var ephemeral = message.ephemeral(); - - var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); - postedMessages.put(messageIds.get(0), postedMessage); - } - } - - @Override - public void join(int roomId) throws IOException { - joinRoom(roomId); - } - - /** - * Joins a room. - * @param roomId the room ID - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { - return joinRoom(roomId, false); - } - - /** - * Joins a room. - * @param roomId the room ID - * @param quiet true to not post an announcement message, false to post one - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { - var room = connection.getRoom(roomId); - if (room != null) { - return room; - } - - logger.atInfo().log(() -> "Joining room " + roomId + "..."); - - room = connection.joinRoom(roomId); - - room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - - if (!quiet && config.getGreeting() != null) { - try { - sendMessage(room, config.getGreeting()); - } catch (RoomPermissionException e) { - logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); - } - } - - rooms.add(roomId); - - for (var task : inactivityTasks) { - var nextRun = task.getInactivityTime(room, this); - if (nextRun == null) { - continue; - } - - scheduleTask(task, room, nextRun); - } - - return room; - } - - @Override - public void leave(int roomId) throws IOException { - logger.atInfo().log(() -> "Leaving room " + roomId + "..."); - - inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); - timeOfLastMessageByRoom.remove(roomId); - rooms.remove(roomId); - - var room = connection.getRoom(roomId); - if (room != null) { - room.leave(); - } - } - - @Override - public String getUsername() { - return config.getUserName(); - } - - @Override - public Integer getUserId() { - return config.getUserId(); - } - - @Override - public List getAdminUsers() { - return security.getAdmins(); - } - - private boolean isAdminUser(Integer userId) { - return security.isAdmin(userId); - } - - @Override - public boolean isRoomOwner(int roomId, int userId) throws IOException { - var userInfo = connection.getUserInfo(roomId, userId); - return (userInfo == null) ? false : userInfo.isOwner(); - } - - @Override - public String getTrigger() { - return config.getTrigger(); - } - - @Override - public List getRooms() { - return rooms.getRooms(); - } - - @Override - public IRoom getRoom(int roomId) { - return connection.getRoom(roomId); - } - - @Override - public List getHomeRooms() { - return rooms.getHomeRooms(); - } - - @Override - public List getQuietRooms() { - return rooms.getQuietRooms(); - } - - @Override - public Integer getMaxRooms() { - return maxRooms; - } - - @Override - public void broadcastMessage(PostMessage message) throws IOException { - for (var room : connection.getRooms()) { - if (!rooms.isQuietRoom(room.getRoomId())) { - sendMessage(room, message); - } - } - } - - @Override - public synchronized void timeout(Duration duration) { - if (timeout) { - timeoutTask.cancel(); - } else { - timeout = true; - } - - timeoutTask = new TimerTask() { - @Override - public void run() { - timeout = false; - } - }; - - timer.schedule(timeoutTask, duration.toMillis()); - } - - @Override - public synchronized void cancelTimeout() { - timeout = false; - if (timeoutTask != null) { - timeoutTask.cancel(); - } - } - - /** - * Sends a signal to immediately stop processing tasks. The bot thread will - * stop running once it is done processing the current task. - */ - public void stop() { - choreQueue.add(new StopChore()); - } - - /** - * Sends a signal to finish processing the tasks in the queue, and then - * terminate. - */ - public void finish() { - choreQueue.add(new FinishChore()); - } - - private TimerTask scheduleChore(long delay, Chore chore) { - var timerTask = new TimerTask() { - @Override - public void run() { - choreQueue.add(chore); - } - }; - timer.schedule(timerTask, delay); - - return timerTask; - } - - private TimerTask scheduleChore(Duration delay, Chore chore) { - return scheduleChore(delay.toMillis(), chore); - } - - /** - * Represents a message that was posted to the chat room. - * @author Michael Angstadt - */ - private static class PostedMessage { - private final Instant timePosted; - private final String originalContent; - private final String condensedContent; - private final boolean ephemeral; - private final int roomId; - private final long parentId; - private final List messageIds; - - /** - * @param timePosted the time the message was posted - * @param originalContent the original message that the bot sent to the - * chat room - * @param condensedContent the text that the message should be changed - * to after the amount of time specified in the "hideOneboxesAfter" - * setting - * @param ephemeral true to delete the message after the amount of time - * specified in the "hideOneboxesAfter" setting, false not to - * @param roomId the ID of the room the message was posted in - * @param parentId the ID of the message that this was a reply to - * @param messageIds the ID of each message that was actually posted to - * the room (the chat client may split up the original message due to - * length limitations) - */ - public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { - this.timePosted = timePosted; - this.originalContent = originalContent; - this.condensedContent = condensedContent; - this.ephemeral = ephemeral; - this.roomId = roomId; - this.parentId = parentId; - this.messageIds = messageIds; - } - - /** - * Gets the time the message was posted. - * @return the time the message was posted - */ - public Instant getTimePosted() { - return timePosted; - } - - /** - * Gets the content of the original message that the bot sent to the - * chat room. This is used for when a message was converted to a onebox. - * @return the original content - */ - public String getOriginalContent() { - return originalContent; - } - - /** - * Gets the text that the message should be changed to after the amount - * of time specified in the "hideOneboxesAfter" setting. - * @return the new content or null to leave the message alone - */ - public String getCondensedContent() { - return condensedContent; - } - - /** - * Gets the ID of each message that was actually posted to the room. The - * chat client may split up the original message due to length - * limitations. - * @return the message IDs - */ - public List getMessageIds() { - return messageIds; - } - - /** - * Gets the ID of the room the message was posted in. - * @return the room ID - */ - public int getRoomId() { - return roomId; - } - - /** - * Determines if the message has requested that it be condensed or - * deleted after the amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * @return true to condense or delete the message, false to leave it - * alone - */ - public boolean isCondensableOrEphemeral() { - return condensedContent != null || isEphemeral(); - } - - /** - * Determines if the message has requested that it be deleted after the - * amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * @return true to delete the message, false not to - */ - public boolean isEphemeral() { - return ephemeral; - } - - /** - * Gets the ID of the message that this was a reply to. - * @return the parent ID or 0 if it's not a reply - */ - public long getParentId() { - return parentId; - } - } - - private abstract class Chore implements Comparable { - private final long choreId; - - public Chore() { - choreId = choreIdCounter.getAndIncrement(); - } - - public abstract void complete(); - - /** - * Logs an error that occurred during chore execution. - * This method is pulled up from subclasses to provide common error logging functionality. - * @param message the error message - * @param cause the exception that caused the error - */ - protected void logError(String message, Exception cause) { - logger.atError().setCause(cause).log(() -> message); - } - - @Override - public int compareTo(Chore that) { - /* - * The "lowest" value will be popped off the queue first. - */ - - if (isBothStopChore(that)) { - return 0; - } - if (isThisStopChore()) { - return -1; - } - if (isThatStopChore(that)) { - return 1; - } - - if (isBothCondenseMessageChore(that)) { - return Long.compare(this.choreId, that.choreId); - } - if (isThisCondenseMessageChore()) { - return -1; - } - 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 { - @Override - public void complete() { - //empty - } - } - - private class FinishChore extends Chore { - @Override - public void complete() { - //empty - } - } - - private class ChatEventChore extends Chore { - private final Event event; - - public ChatEventChore(Event event) { - this.event = event; - } - - @Override - public void complete() { - if (event instanceof MessagePostedEvent mpe) { - handleMessage(mpe.getMessage()); - return; - } - - if (event instanceof MessageEditedEvent mee) { - handleMessage(mee.getMessage()); - return; - } - - if (event instanceof InvitationEvent ie) { - var roomId = ie.getRoomId(); - var userId = ie.getUserId(); - var inviterIsAdmin = isAdminUser(userId); - - boolean acceptInvitation; - if (inviterIsAdmin) { - acceptInvitation = true; - } else { - try { - acceptInvitation = isRoomOwner(roomId, userId); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); - acceptInvitation = false; - } - } - - if (acceptInvitation) { - handleInvitation(ie); - } - - return; - } - - logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); - } - - private void handleMessage(ChatMessage message) { - var userId = message.getUserId(); - var isAdminUser = isAdminUser(userId); - var isBotInTimeout = timeout && !isAdminUser; - - if (isBotInTimeout) { - //bot is in timeout, ignore - return; - } - - var messageWasDeleted = message.getContent() == null; - if (messageWasDeleted) { - //user deleted their message, ignore - return; - } - - 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; - } - - var userIsBanned = security.isBanned(userId); - if (userIsBanned) { - //message was posted by a banned user, ignore - return; - } - - var room = connection.getRoom(message.getRoomId()); - if (room == null) { - //the bot is no longer in the room - return; - } - - if (message.getUserId() == userId) { - //message was posted by this bot - handleBotMessage(message); - return; - } - - message = convertFromBotlerRelayMessage(message); - - timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); - - var actions = handleListeners(message); - handleActions(message, actions); - } - - private void handleBotMessage(ChatMessage message) { - PostedMessage postedMessage; - synchronized (postedMessages) { - postedMessage = postedMessages.remove(message.getMessageId()); - } - - /* - * Check to see if the message should be edited for brevity - * after a short time so it doesn't spam the chat history. - * - * This could happen if (1) the bot posted something that Stack - * Overflow Chat converted to a onebox (e.g. an image) or (2) - * the message itself has asked to be edited (e.g. a javadoc - * description). - * - * ===What is a onebox?=== - * - * Stack Overflow Chat converts certain URLs to "oneboxes". - * Oneboxes can be fairly large and can spam the chat. For - * example, if the message is a URL to an image, the image - * itself will be displayed in the chat room. This is nice, but - * gets annoying if the image is large or if it's an animated - * GIF. - * - * After giving people some time to see the onebox, the bot will - * edit the message so that the onebox no longer displays, but - * the URL is still preserved. - */ - var messageIsOnebox = message.getContent().isOnebox(); - if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { - var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); - var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); - - logger.atInfo().log(() -> { - var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; - return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); - }); - - scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); - } - } - - private ChatActions handleListeners(ChatMessage message) { - var actions = new ChatActions(); - for (var listener : listeners) { - try { - actions.addAll(listener.onMessage(message, Bot.this)); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running listener."); - } - } - return actions; - } - - private void handleActions(ChatMessage message, ChatActions actions) { - if (actions.isEmpty()) { - return; - } - - logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); - - if (stats != null) { - stats.incMessagesRespondedTo(); - } - - var queue = new LinkedList<>(actions.getActions()); - while (!queue.isEmpty()) { - var action = queue.removeFirst(); - processAction(action, message, queue); - } - } - - 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(this, message); - var response = action.execute(context); - queue.addAll(response.getActions()); - } - - private void handlePostMessageAction(PostMessage action, ChatMessage message) { - try { - if (action.delay() != null) { - scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); - } else { - if (action.broadcast()) { - broadcastMessage(action); - } else { - sendMessage(message.getRoomId(), action); - } - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); - } - } - - private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { - try { - var room = connection.getRoom(message.getRoomId()); - room.deleteMessage(action.messageId()); - return action.onSuccess().get(); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); - return action.onError().apply(e); - } - } - - private ChatActions handleJoinRoomAction(JoinRoom action) { - if (maxRooms != null && connection.getRooms().size() >= maxRooms) { - return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); - } - - try { - var joinedRoom = joinRoom(action.roomId()); - if (joinedRoom.canPost()) { - return action.onSuccess().get(); - } - - leaveRoomSafely(action.roomId(), "the bot can't post messages to it"); - return action.ifLackingPermissionToPost().get(); - } catch (PrivateRoomException | RoomPermissionException e) { - leaveRoomSafely(action.roomId(), "the bot can't join or post messages to it"); - return action.ifLackingPermissionToPost().get(); - } catch (RoomNotFoundException e) { - return action.ifRoomDoesNotExist().get(); - } catch (Exception e) { - return action.onError().apply(e); - } - } - - /** - * Attempts to leave a room and logs any errors that occur. - * @param roomId the room ID to leave - * @param reason the reason for leaving (used in error message) - */ - private void leaveRoomSafely(int roomId, String reason) { - try { - leave(roomId); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem leaving room " + roomId + " after it was found that " + reason + "."); - } - } - - private void handleLeaveRoomAction(LeaveRoom action) { - try { - leave(action.roomId()); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); - } - } - - private void handleInvitation(InvitationEvent event) { - /* - * If the bot is currently connected to multiple rooms, the - * invitation event will be sent to each room and this method will - * be called multiple times. Check to see if the bot has already - * joined the room it was invited to. - */ - var roomId = event.getRoomId(); - if (connection.isInRoom(roomId)) { - return; - } - - /* - * Ignore the invitation if the bot is connected to the maximum - * number of rooms allowed. We can't really post an error message - * because the invitation event is not linked to a specific chat - * room. - */ - var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); - if (maxRoomsExceeded) { - return; - } - - try { - joinRoom(roomId); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); - } - } - } - - private class CondenseMessageChore extends Chore { - private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); - private final PostedMessage postedMessage; - - public CondenseMessageChore(PostedMessage postedMessage) { - this.postedMessage = postedMessage; - } - - @Override - public void complete() { - var roomId = postedMessage.getRoomId(); - var room = connection.getRoom(roomId); - - var botIsNoLongerInTheRoom = (room == null); - if (botIsNoLongerInTheRoom) { - return; - } - - try { - List messagesToDelete; - if (postedMessage.isEphemeral()) { - messagesToDelete = postedMessage.getMessageIds(); - } else { - var condensedContent = postedMessage.getCondensedContent(); - var isAOneBox = (condensedContent == null); - if (isAOneBox) { - condensedContent = postedMessage.getOriginalContent(); - } - - var messageIds = postedMessage.getMessageIds(); - var quotedContent = quote(condensedContent); - room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); - - /* - * If the original content was split up into - * multiple messages due to length constraints, - * delete the additional messages. - */ - messagesToDelete = messageIds.subList(1, messageIds.size()); - } - - for (var id : messagesToDelete) { - room.deleteMessage(id); - } - } catch (Exception e) { - logError("Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]", e); - } - } - - @SuppressWarnings("deprecation") - private String quote(String content) { - var cb = new ChatBuilder(); - - var m = replyRegex.matcher(content); - if (m.find()) { - var id = Long.parseLong(m.group(1)); - content = m.group(2); - - cb.reply(id); - } - - return cb.quote(content).toString(); - } - } - - private class ScheduledTaskChore extends Chore { - private final ScheduledTask task; - - public ScheduledTaskChore(ScheduledTask task) { - this.task = task; - } - - @Override - public void complete() { - try { - task.run(Bot.this); - } catch (Exception e) { - logError("Problem running scheduled task.", e); - } - scheduleTask(task); - } - } - - private class InactivityTaskChore extends Chore { - private final InactivityTask task; - private final IRoom room; - - public InactivityTaskChore(InactivityTask task, IRoom room) { - this.task = task; - this.room = room; - } - - @Override - public void complete() { - try { - if (!connection.isInRoom(room.getRoomId())) { - return; - } - - var inactivityTime = task.getInactivityTime(room, Bot.this); - if (inactivityTime == null) { - return; - } - - var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); - var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); - var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); - if (runNow) { - try { - task.run(room, Bot.this); - } catch (Exception e) { - logError("Problem running inactivity task in room " + room.getRoomId() + ".", e); - } - } - - var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); - scheduleTask(task, room, nextCheck); - } finally { - inactivityTimerTasksByRoom.remove(room, this); - } - } - } - - private class DelayedMessageChore extends Chore { - private final int roomId; - private final PostMessage message; - - public DelayedMessageChore(int roomId, PostMessage message) { - this.roomId = roomId; - this.message = message; - } - - @Override - public void complete() { - try { - if (message.broadcast()) { - broadcastMessage(message); - } else { - sendMessage(roomId, message); - } - } catch (Exception e) { - logError("Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message(), e); - } - } - } - - /** - * Alters the username and content of a message if the message is a Botler - * Discord relay message. Otherwise, returns the message unaltered. - * @param message the original message - * @return the altered message or the same message if it's not a relay - * message - * @see example - */ - private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { - if (message.getUserId() != BOTLER_ID) { - return message; - } - - var content = message.getContent(); - if (content == null) { - return message; - } - - //Example message content: - //[realmichael] test - var html = content.getContent(); - var dom = Jsoup.parse(html); - var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); - if (element == null) { - return message; - } - var discordUsername = element.text(); - - var endBracket = html.indexOf(']'); - if (endBracket < 0) { - return message; - } - var discordMessage = html.substring(endBracket + 1).trim(); - - //@formatter:off + } + + @Override + public String getOriginalMessageContent(long messageId) throws IOException { + return connection.getOriginalMessageContent(messageId); + } + + @Override + public String uploadImage(String url) throws IOException { + return connection.uploadImage(url); + } + + @Override + public String uploadImage(byte[] data) throws IOException { + return connection.uploadImage(data); + } + + @Override + public void sendMessage(int roomId, PostMessage message) throws IOException { + var room = connection.getRoom(roomId); + if (room != null) { + sendMessage(room, message); + } + } + + private void sendMessage(IRoom room, String message) throws IOException { + sendMessage(room, new PostMessage(message)); + } + + private void sendMessage(IRoom room, PostMessage message) throws IOException { + final String filteredMessage; + if (message.bypassFilters()) { + filteredMessage = message.message(); + } else { + var messageText = message.message(); + for (var filter : responseFilters) { + if (filter.isEnabled(room.getRoomId())) { + messageText = filter.filter(messageText); + } + } + filteredMessage = messageText; + } + + logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); + + synchronized (postedMessages) { + var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); + var condensedMessage = message.condensedMessage(); + var ephemeral = message.ephemeral(); + + var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); + postedMessages.put(messageIds.get(0), postedMessage); + } + } + + @Override + public void join(int roomId) throws IOException { + joinRoom(roomId); + } + + /** + * Joins a room. + * + * @param roomId the room ID + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { + return joinRoom(roomId, false); + } + + /** + * Joins a room. + * + * @param roomId the room ID + * @param quiet true to not post an announcement message, false to post one + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { + var room = connection.getRoom(roomId); + if (room != null) { + return room; + } + + logger.atInfo().log(() -> "Joining room " + roomId + "..."); + + room = connection.joinRoom(roomId); + + room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + + if (!quiet && config.getGreeting() != null) { + try { + sendMessage(room, config.getGreeting()); + } catch (RoomPermissionException e) { + logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); + } + } + + rooms.add(roomId); + + for (var task : inactivityTasks) { + var nextRun = task.getInactivityTime(room, this); + if (nextRun == null) { + continue; + } + + scheduleTask(task, room, nextRun); + } + + return room; + } + + @Override + public void leave(int roomId) throws IOException { + logger.atInfo().log(() -> "Leaving room " + roomId + "..."); + + inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); + timeOfLastMessageByRoom.remove(roomId); + rooms.remove(roomId); + + var room = connection.getRoom(roomId); + if (room != null) { + room.leave(); + } + } + + @Override + public String getUsername() { + return config.getUserName(); + } + + @Override + public Integer getUserId() { + return config.getUserId(); + } + + @Override + public List getAdminUsers() { + return security.getAdmins(); + } + + private boolean isAdminUser(Integer userId) { + return security.isAdmin(userId); + } + + @Override + public boolean isRoomOwner(int roomId, int userId) throws IOException { + var userInfo = connection.getUserInfo(roomId, userId); + return (userInfo == null) ? false : userInfo.isOwner(); + } + + @Override + public String getTrigger() { + return config.getTrigger(); + } + + @Override + public List getRooms() { + return rooms.getRooms(); + } + + @Override + public IRoom getRoom(int roomId) { + return connection.getRoom(roomId); + } + + @Override + public List getHomeRooms() { + return rooms.getHomeRooms(); + } + + @Override + public List getQuietRooms() { + return rooms.getQuietRooms(); + } + + @Override + public Integer getMaxRooms() { + return maxRooms; + } + + @Override + public void broadcastMessage(PostMessage message) throws IOException { + for (var room : connection.getRooms()) { + if (!rooms.isQuietRoom(room.getRoomId())) { + sendMessage(room, message); + } + } + } + + @Override + public synchronized void timeout(Duration duration) { + if (timeout) { + timeoutTask.cancel(); + } else { + timeout = true; + } + + timeoutTask = new TimerTask() { + @Override + public void run() { + timeout = false; + } + }; + + timer.schedule(timeoutTask, duration.toMillis()); + } + + @Override + public synchronized void cancelTimeout() { + timeout = false; + if (timeoutTask != null) { + timeoutTask.cancel(); + } + } + + /** + * Sends a signal to immediately stop processing tasks. The bot thread will + * stop running once it is done processing the current task. + */ + public void stop() { + choreQueue.add(new StopChore()); + } + + /** + * Sends a signal to finish processing the tasks in the queue, and then + * terminate. + */ + public void finish() { + choreQueue.add(new FinishChore()); + } + + private TimerTask scheduleChore(long delay, Chore chore) { + var timerTask = new TimerTask() { + @Override + public void run() { + choreQueue.add(chore); + } + }; + timer.schedule(timerTask, delay); + + return timerTask; + } + + private TimerTask scheduleChore(Duration delay, Chore chore) { + return scheduleChore(delay.toMillis(), chore); + } + + /** + * Represents a message that was posted to the chat room. + * + * @author Michael Angstadt + */ + private static class PostedMessage { + private final Instant timePosted; + private final String originalContent; + private final String condensedContent; + private final boolean ephemeral; + private final int roomId; + private final long parentId; + private final List messageIds; + + /** + * @param timePosted the time the message was posted + * @param originalContent the original message that the bot sent to the + * chat room + * @param condensedContent the text that the message should be changed + * to after the amount of time specified in the "hideOneboxesAfter" + * setting + * @param ephemeral true to delete the message after the amount of time + * specified in the "hideOneboxesAfter" setting, false not to + * @param roomId the ID of the room the message was posted in + * @param parentId the ID of the message that this was a reply to + * @param messageIds the ID of each message that was actually posted to + * the room (the chat client may split up the original message due to + * length limitations) + */ + public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { + this.timePosted = timePosted; + this.originalContent = originalContent; + this.condensedContent = condensedContent; + this.ephemeral = ephemeral; + this.roomId = roomId; + this.parentId = parentId; + this.messageIds = messageIds; + } + + /** + * Gets the time the message was posted. + * + * @return the time the message was posted + */ + public Instant getTimePosted() { + return timePosted; + } + + /** + * Gets the content of the original message that the bot sent to the + * chat room. This is used for when a message was converted to a onebox. + * + * @return the original content + */ + public String getOriginalContent() { + return originalContent; + } + + /** + * Gets the text that the message should be changed to after the amount + * of time specified in the "hideOneboxesAfter" setting. + * + * @return the new content or null to leave the message alone + */ + public String getCondensedContent() { + return condensedContent; + } + + /** + * Gets the ID of each message that was actually posted to the room. The + * chat client may split up the original message due to length + * limitations. + * + * @return the message IDs + */ + public List getMessageIds() { + return messageIds; + } + + /** + * Gets the ID of the room the message was posted in. + * + * @return the room ID + */ + public int getRoomId() { + return roomId; + } + + /** + * Determines if the message has requested that it be condensed or + * deleted after the amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * + * @return true to condense or delete the message, false to leave it + * alone + */ + public boolean isCondensableOrEphemeral() { + return condensedContent != null || isEphemeral(); + } + + /** + * Determines if the message has requested that it be deleted after the + * amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * + * @return true to delete the message, false not to + */ + public boolean isEphemeral() { + return ephemeral; + } + + /** + * Gets the ID of the message that this was a reply to. + * + * @return the parent ID or 0 if it's not a reply + */ + public long getParentId() { + return parentId; + } + } + + private abstract class Chore implements Comparable { + private final long choreId; + + public Chore() { + choreId = choreIdCounter.getAndIncrement(); + } + + public abstract void complete(); + + @Override + public int compareTo(Chore that) { + /* + * The "lowest" value will be popped off the queue first. + */ + + if (isBothStopChore(that)) { + return 0; + } + if (isThisStopChore()) { + return -1; + } + if (isThatStopChore(that)) { + return 1; + } + + if (isBothCondenseMessageChore(that)) { + return Long.compare(this.choreId, that.choreId); + } + if (isThisCondenseMessageChore()) { + return -1; + } + 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 { + @Override + public void complete() { + //empty + } + } + + private class FinishChore extends Chore { + @Override + public void complete() { + //empty + } + } + + private class ChatEventChore extends Chore { + private final Event event; + + public ChatEventChore(Event event) { + this.event = event; + } + + @Override + public void complete() { + if (event instanceof MessagePostedEvent mpe) { + handleMessage(mpe.getMessage()); + return; + } + + if (event instanceof MessageEditedEvent mee) { + handleMessage(mee.getMessage()); + return; + } + + if (event instanceof InvitationEvent ie) { + var roomId = ie.getRoomId(); + var userId = ie.getUserId(); + var inviterIsAdmin = isAdminUser(userId); + + boolean acceptInvitation; + if (inviterIsAdmin) { + acceptInvitation = true; + } else { + try { + acceptInvitation = isRoomOwner(roomId, userId); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); + acceptInvitation = false; + } + } + + if (acceptInvitation) { + handleInvitation(ie); + } + + return; + } + + logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); + } + + private void handleMessage(ChatMessage message) { + var userId = message.getUserId(); + var isAdminUser = isAdminUser(userId); + var isBotInTimeout = timeout && !isAdminUser; + + if (isBotInTimeout) { + //bot is in timeout, ignore + return; + } + + var messageWasDeleted = message.getContent() == null; + if (messageWasDeleted) { + //user deleted their message, ignore + return; + } + + 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; + } + + var userIsBanned = security.isBanned(userId); + if (userIsBanned) { + //message was posted by a banned user, ignore + return; + } + + var room = connection.getRoom(message.getRoomId()); + if (room == null) { + //the bot is no longer in the room + return; + } + + if (message.getUserId() == userId) { + //message was posted by this bot + handleBotMessage(message); + return; + } + + message = convertFromBotlerRelayMessage(message); + + timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); + + var actions = handleListeners(message); + handleActions(message, actions); + } + + private void handleBotMessage(ChatMessage message) { + PostedMessage postedMessage; + synchronized (postedMessages) { + postedMessage = postedMessages.remove(message.getMessageId()); + } + + /* + * Check to see if the message should be edited for brevity + * after a short time so it doesn't spam the chat history. + * + * This could happen if (1) the bot posted something that Stack + * Overflow Chat converted to a onebox (e.g. an image) or (2) + * the message itself has asked to be edited (e.g. a javadoc + * description). + * + * ===What is a onebox?=== + * + * Stack Overflow Chat converts certain URLs to "oneboxes". + * Oneboxes can be fairly large and can spam the chat. For + * example, if the message is a URL to an image, the image + * itself will be displayed in the chat room. This is nice, but + * gets annoying if the image is large or if it's an animated + * GIF. + * + * After giving people some time to see the onebox, the bot will + * edit the message so that the onebox no longer displays, but + * the URL is still preserved. + */ + var messageIsOnebox = message.getContent().isOnebox(); + if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { + var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); + var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); + + logger.atInfo().log(() -> { + var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; + return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); + }); + + scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); + } + } + + private ChatActions handleListeners(ChatMessage message) { + var actions = new ChatActions(); + for (var listener : listeners) { + try { + actions.addAll(listener.onMessage(message, Bot.this)); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem running listener."); + } + } + return actions; + } + + private void handleActions(ChatMessage message, ChatActions actions) { + if (actions.isEmpty()) { + return; + } + + logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); + + if (stats != null) { + stats.incMessagesRespondedTo(); + } + + var queue = new LinkedList<>(actions.getActions()); + while (!queue.isEmpty()) { + var action = queue.removeFirst(); + processAction(action, message, queue); + } + } + + 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(this, message); + var response = action.execute(context); + queue.addAll(response.getActions()); + } + + private void handlePostMessageAction(PostMessage action, ChatMessage message) { + try { + if (action.delay() != null) { + scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); + } else { + if (action.broadcast()) { + broadcastMessage(action); + } else { + sendMessage(message.getRoomId(), action); + } + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); + } + } + + private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { + try { + var room = connection.getRoom(message.getRoomId()); + room.deleteMessage(action.messageId()); + return action.onSuccess().get(); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); + return action.onError().apply(e); + } + } + + private ChatActions handleJoinRoomAction(JoinRoom action) { + if (maxRooms != null && connection.getRooms().size() >= maxRooms) { + return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); + } + + try { + var joinedRoom = joinRoom(action.roomId()); + if (joinedRoom.canPost()) { + return action.onSuccess().get(); + } + + leaveRoomSafely(action.roomId(), "the bot can't post messages to it"); + return action.ifLackingPermissionToPost().get(); + } catch (PrivateRoomException | RoomPermissionException e) { + leaveRoomSafely(action.roomId(), "the bot can't join or post messages to it"); + return action.ifLackingPermissionToPost().get(); + } catch (RoomNotFoundException e) { + return action.ifRoomDoesNotExist().get(); + } catch (Exception e) { + return action.onError().apply(e); + } + } + + /** + * Attempts to leave a room and logs any errors that occur. + * + * @param roomId the room ID to leave + * @param reason the reason for leaving (used in error message) + */ + private void leaveRoomSafely(int roomId, String reason) { + try { + leave(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem leaving room " + roomId + " after it was found that " + reason + "."); + } + } + + private void handleLeaveRoomAction(LeaveRoom action) { + try { + leave(action.roomId()); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); + } + } + + private void handleInvitation(InvitationEvent event) { + /* + * If the bot is currently connected to multiple rooms, the + * invitation event will be sent to each room and this method will + * be called multiple times. Check to see if the bot has already + * joined the room it was invited to. + */ + var roomId = event.getRoomId(); + if (connection.isInRoom(roomId)) { + return; + } + + /* + * Ignore the invitation if the bot is connected to the maximum + * number of rooms allowed. We can't really post an error message + * because the invitation event is not linked to a specific chat + * room. + */ + var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); + if (maxRoomsExceeded) { + return; + } + + try { + joinRoom(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); + } + } + } + + private class CondenseMessageChore extends Chore { + private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); + private final PostedMessage postedMessage; + + public CondenseMessageChore(PostedMessage postedMessage) { + this.postedMessage = postedMessage; + } + + @Override + public void complete() { + var roomId = postedMessage.getRoomId(); + var room = connection.getRoom(roomId); + + var botIsNoLongerInTheRoom = (room == null); + if (botIsNoLongerInTheRoom) { + return; + } + + try { + List messagesToDelete; + if (postedMessage.isEphemeral()) { + messagesToDelete = postedMessage.getMessageIds(); + } else { + var condensedContent = postedMessage.getCondensedContent(); + var isAOneBox = (condensedContent == null); + if (isAOneBox) { + condensedContent = postedMessage.getOriginalContent(); + } + + var messageIds = postedMessage.getMessageIds(); + var quotedContent = quote(condensedContent); + room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); + + /* + * If the original content was split up into + * multiple messages due to length constraints, + * delete the additional messages. + */ + messagesToDelete = messageIds.subList(1, messageIds.size()); + } + + for (var id : messagesToDelete) { + room.deleteMessage(id); + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]"); + } + } + + @SuppressWarnings("deprecation") + private String quote(String content) { + var cb = new ChatBuilder(); + + var m = replyRegex.matcher(content); + if (m.find()) { + var id = Long.parseLong(m.group(1)); + content = m.group(2); + + cb.reply(id); + } + + return cb.quote(content).toString(); + } + } + + private class ScheduledTaskChore extends Chore { + private final ScheduledTask task; + + public ScheduledTaskChore(ScheduledTask task) { + this.task = task; + } + + @Override + public void complete() { + try { + task.run(Bot.this); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem running scheduled task."); + } + scheduleTask(task); + } + } + + private class InactivityTaskChore extends Chore { + private final InactivityTask task; + private final IRoom room; + + public InactivityTaskChore(InactivityTask task, IRoom room) { + this.task = task; + this.room = room; + } + + @Override + public void complete() { + try { + if (!connection.isInRoom(room.getRoomId())) { + return; + } + + var inactivityTime = task.getInactivityTime(room, Bot.this); + if (inactivityTime == null) { + return; + } + + var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); + var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); + var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); + if (runNow) { + try { + task.run(room, Bot.this); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem running inactivity task in room " + room.getRoomId() + "."); + } + } + + var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); + scheduleTask(task, room, nextCheck); + } finally { + inactivityTimerTasksByRoom.remove(room, this); + } + } + } + + private class DelayedMessageChore extends Chore { + private final int roomId; + private final PostMessage message; + + public DelayedMessageChore(int roomId, PostMessage message) { + this.roomId = roomId; + this.message = message; + } + + @Override + public void complete() { + try { + if (message.broadcast()) { + broadcastMessage(message); + } else { + sendMessage(roomId, message); + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message()); + } + } + } + + /** + * Alters the username and content of a message if the message is a Botler + * Discord relay message. Otherwise, returns the message unaltered. + * + * @param message the original message + * @return the altered message or the same message if it's not a relay + * message + * @see example + */ + private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { + if (message.getUserId() != BOTLER_ID) { + return message; + } + + var content = message.getContent(); + if (content == null) { + return message; + } + + //Example message content: + //[realmichael] test + var html = content.getContent(); + var dom = Jsoup.parse(html); + var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); + if (element == null) { + return message; + } + var discordUsername = element.text(); + + var endBracket = html.indexOf(']'); + if (endBracket < 0) { + return message; + } + var discordMessage = html.substring(endBracket + 1).trim(); + + //@formatter:off return new ChatMessage.Builder(message) .username(discordUsername) .content(discordMessage) .build(); //@formatter:on - } - - /** - * Builds {@link Bot} instances. - * @author Michael Angstadt - */ - public static class Builder { - private IChatClient connection; - private String userName; - private String trigger = "="; - private String greeting; - private Integer userId; - private Duration hideOneboxesAfter; - private Integer maxRooms; - private List roomsHome = List.of(1); - private List roomsQuiet = List.of(); - private List admins = List.of(); - private List bannedUsers = List.of(); - private List allowedUsers = List.of(); - private List listeners = List.of(); - private List tasks = List.of(); - private List inactivityTasks = List.of(); - private List responseFilters = List.of(); - private Statistics stats; - private Database database; - - public Builder connection(IChatClient connection) { - this.connection = connection; - return this; - } - - public Builder user(String userName, Integer userId) { - this.userName = (userName == null || userName.isEmpty()) ? null : userName; - this.userId = userId; - return this; - } - - public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { - this.hideOneboxesAfter = hideOneboxesAfter; - return this; - } - - public Builder trigger(String trigger) { - this.trigger = trigger; - return this; - } - - public Builder greeting(String greeting) { - this.greeting = greeting; - return this; - } - - public Builder roomsHome(Integer... roomIds) { - roomsHome = List.of(roomIds); - return this; - } - - public Builder roomsHome(Collection roomIds) { - roomsHome = List.copyOf(roomIds); - return this; - } - - public Builder roomsQuiet(Integer... roomIds) { - roomsQuiet = List.of(roomIds); - return this; - } - - public Builder roomsQuiet(Collection roomIds) { - roomsQuiet = List.copyOf(roomIds); - return this; - } - - public Builder maxRooms(Integer maxRooms) { - this.maxRooms = maxRooms; - return this; - } - - public Builder admins(Integer... admins) { - this.admins = List.of(admins); - return this; - } - - public Builder admins(Collection admins) { - this.admins = List.copyOf(admins); - return this; - } - - public Builder bannedUsers(Integer... bannedUsers) { - this.bannedUsers = List.of(bannedUsers); - return this; - } - - public Builder bannedUsers(Collection bannedUsers) { - this.bannedUsers = List.copyOf(bannedUsers); - return this; - } - - public Builder allowedUsers(Integer... allowedUsers) { - this.allowedUsers = List.of(allowedUsers); - return this; - } - - public Builder allowedUsers(Collection allowedUsers) { - this.allowedUsers = List.copyOf(allowedUsers); - return this; - } - - public Builder listeners(Listener... listeners) { - this.listeners = List.of(listeners); - return this; - } - - public Builder listeners(Collection listeners) { - this.listeners = List.copyOf(listeners); - return this; - } - - public Builder tasks(ScheduledTask... tasks) { - this.tasks = List.of(tasks); - return this; - } - - public Builder tasks(Collection tasks) { - this.tasks = List.copyOf(tasks); - return this; - } - - public Builder inactivityTasks(InactivityTask... tasks) { - inactivityTasks = List.of(tasks); - return this; - } - - public Builder inactivityTasks(Collection tasks) { - inactivityTasks = List.copyOf(tasks); - return this; - } - - public Builder responseFilters(ChatResponseFilter... filters) { - responseFilters = List.of(filters); - return this; - } - - public Builder responseFilters(Collection filters) { - responseFilters = List.copyOf(filters); - return this; - } - - public Builder stats(Statistics stats) { - this.stats = stats; - return this; - } - - public Builder database(Database database) { - this.database = database; - return this; - } - - public Bot build() { - if (connection == null) { - throw new IllegalStateException("No ChatConnection given."); - } - - if (connection.getUsername() == null && this.userName == null) { - throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - if (connection.getUserId() == null && this.userId == null) { - throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - return new Bot(this); - } - } + } + + /** + * Builds {@link Bot} instances. + * + * @author Michael Angstadt + */ + public static class Builder { + private IChatClient connection; + private String userName; + private String trigger = "="; + private String greeting; + private Integer userId; + private Duration hideOneboxesAfter; + private Integer maxRooms; + private List roomsHome = List.of(1); + private List roomsQuiet = List.of(); + private List admins = List.of(); + private List bannedUsers = List.of(); + private List allowedUsers = List.of(); + private List listeners = List.of(); + private List tasks = List.of(); + private List inactivityTasks = List.of(); + private List responseFilters = List.of(); + private Statistics stats; + private Database database; + + public Builder connection(IChatClient connection) { + this.connection = connection; + return this; + } + + public Builder user(String userName, Integer userId) { + this.userName = (userName == null || userName.isEmpty()) ? null : userName; + this.userId = userId; + return this; + } + + public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { + this.hideOneboxesAfter = hideOneboxesAfter; + return this; + } + + public Builder trigger(String trigger) { + this.trigger = trigger; + return this; + } + + public Builder greeting(String greeting) { + this.greeting = greeting; + return this; + } + + public Builder roomsHome(Integer... roomIds) { + roomsHome = List.of(roomIds); + return this; + } + + public Builder roomsHome(Collection roomIds) { + roomsHome = List.copyOf(roomIds); + return this; + } + + public Builder roomsQuiet(Integer... roomIds) { + roomsQuiet = List.of(roomIds); + return this; + } + + public Builder roomsQuiet(Collection roomIds) { + roomsQuiet = List.copyOf(roomIds); + return this; + } + + public Builder maxRooms(Integer maxRooms) { + this.maxRooms = maxRooms; + return this; + } + + public Builder admins(Integer... admins) { + this.admins = List.of(admins); + return this; + } + + public Builder admins(Collection admins) { + this.admins = List.copyOf(admins); + return this; + } + + public Builder bannedUsers(Integer... bannedUsers) { + this.bannedUsers = List.of(bannedUsers); + return this; + } + + public Builder bannedUsers(Collection bannedUsers) { + this.bannedUsers = List.copyOf(bannedUsers); + return this; + } + + public Builder allowedUsers(Integer... allowedUsers) { + this.allowedUsers = List.of(allowedUsers); + return this; + } + + public Builder allowedUsers(Collection allowedUsers) { + this.allowedUsers = List.copyOf(allowedUsers); + return this; + } + + public Builder listeners(Listener... listeners) { + this.listeners = List.of(listeners); + return this; + } + + public Builder listeners(Collection listeners) { + this.listeners = List.copyOf(listeners); + return this; + } + + public Builder tasks(ScheduledTask... tasks) { + this.tasks = List.of(tasks); + return this; + } + + public Builder tasks(Collection tasks) { + this.tasks = List.copyOf(tasks); + return this; + } + + public Builder inactivityTasks(InactivityTask... tasks) { + inactivityTasks = List.of(tasks); + return this; + } + + public Builder inactivityTasks(Collection tasks) { + inactivityTasks = List.copyOf(tasks); + return this; + } + + public Builder responseFilters(ChatResponseFilter... filters) { + responseFilters = List.of(filters); + return this; + } + + public Builder responseFilters(Collection filters) { + responseFilters = List.copyOf(filters); + return this; + } + + public Builder stats(Statistics stats) { + this.stats = stats; + return this; + } + + public Builder database(Database database) { + this.database = database; + return this; + } + + public Bot build() { + if (connection == null) { + throw new IllegalStateException("No ChatConnection given."); + } + + if (connection.getUsername() == null && this.userName == null) { + throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + if (connection.getUserId() == null && this.userId == null) { + throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + return new Bot(this); + } + } } From 443bb21eeee2af3456b48de6c251f9fd1c1b3198 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Fri, 28 Nov 2025 20:01:36 -0400 Subject: [PATCH 07/17] Edited leaveRoomSafely method to accept Supplier --- src/main/java/oakbot/bot/Bot.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 2bd340b3..8b47a9cd 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -879,10 +879,10 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { return action.onSuccess().get(); } - leaveRoomSafely(action.roomId(), "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) { - leaveRoomSafely(action.roomId(), "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(); @@ -895,13 +895,13 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { * Attempts to leave a room and logs any errors that occur. * * @param roomId the room ID to leave - * @param reason the reason for leaving (used in error message) + * @param logMessage supplier for the complete log message (evaluated only if an error occurs) */ - private void leaveRoomSafely(int roomId, String reason) { + private void leaveRoomSafely(int roomId, Supplier logMessage) { try { leave(roomId); } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem leaving room " + roomId + " after it was found that " + reason + "."); + logger.atError().setCause(e).log(logMessage); } } From 52c1e7d30c65fbf913f74c86af3574cae890303f Mon Sep 17 00:00:00 2001 From: Sanif Ali Momin <66715576+sanifalimomin@users.noreply.github.com> Date: Sat, 29 Nov 2025 12:37:32 -0400 Subject: [PATCH 08/17] Fix long diff (#1) fixx long diff --- src/main/java/oakbot/bot/Bot.java | 2516 ++++++++++++++--------------- 1 file changed, 1257 insertions(+), 1259 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 8b47a9cd..df2edbea 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -1,6 +1,34 @@ package oakbot.bot; -import com.github.mangstadt.sochat4j.*; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Timer; +import java.util.TimerTask; +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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.mangstadt.sochat4j.ChatMessage; +import com.github.mangstadt.sochat4j.IChatClient; +import com.github.mangstadt.sochat4j.IRoom; +import com.github.mangstadt.sochat4j.PrivateRoomException; +import com.github.mangstadt.sochat4j.RoomNotFoundException; +import com.github.mangstadt.sochat4j.RoomPermissionException; import com.github.mangstadt.sochat4j.event.Event; import com.github.mangstadt.sochat4j.event.InvitationEvent; import com.github.mangstadt.sochat4j.event.MessageEditedEvent; @@ -8,6 +36,7 @@ import com.github.mangstadt.sochat4j.util.Sleeper; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; + import oakbot.Database; import oakbot.MemoryDatabase; import oakbot.Rooms; @@ -17,1293 +46,1262 @@ import oakbot.listener.Listener; import oakbot.task.ScheduledTask; import oakbot.util.ChatBuilder; -import org.jsoup.Jsoup; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.*; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.PriorityBlockingQueue; -import java.util.concurrent.atomic.AtomicLong; -import java.util.regex.Pattern; /** * A Stackoverflow chat bot. - * * @author Michael Angstadt */ public class Bot implements IBot { - private static final Logger logger = LoggerFactory.getLogger(Bot.class); - static final int BOTLER_ID = 13750349; - + 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 BotConfiguration config; - private final SecurityConfiguration security; - private final IChatClient connection; - private final AtomicLong choreIdCounter = new AtomicLong(); - private final BlockingQueue choreQueue = new PriorityBlockingQueue<>(); - private final Rooms rooms; - private final Integer maxRooms; - private final List listeners; - private final List responseFilters; - private final List scheduledTasks; - private final List inactivityTasks; - private final Map timeOfLastMessageByRoom = new HashMap<>(); - private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); - private final Statistics stats; - private final Database database; - private final Timer timer = new Timer(); - private TimerTask timeoutTask; - private volatile boolean timeout = false; - - /** - *

- * A collection of messages that the bot posted, but have not been "echoed" - * back yet in the chat room. When a message is echoed back, it is removed - * from this map. - *

- *

- * This is used to determine whether something the bot posted was converted - * to a onebox. It is then used to edit the message in order to hide the - * onebox. - *

- *
    - *
  • Key = The message ID.
  • - *
  • Value = The raw message content that was sent to the chat room by the - * bot (which can be different from what was echoed back).
  • - *
- */ - private final Map postedMessages = new HashMap<>(); - - private Bot(Builder builder) { - connection = Objects.requireNonNull(builder.connection); - - 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; - stats = builder.stats; - database = (builder.database == null) ? new MemoryDatabase() : builder.database; - rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); - listeners = builder.listeners; - scheduledTasks = builder.tasks; - inactivityTasks = builder.inactivityTasks; - responseFilters = builder.responseFilters; - } - - private void scheduleTask(ScheduledTask task) { - var nextRun = task.nextRun(); - if (nextRun <= 0) { - return; - } - - scheduleChore(nextRun, new ScheduledTaskChore(task)); - } - - private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { - var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); - inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); - } - - /** - * Starts the chat bot. The bot will join the rooms in the current thread - * before launching its own thread. - * @param quiet true to start the bot without broadcasting the greeting - * message, false to broadcast the greeting message - * @return the thread that the bot is running in. This thread will terminate - * when the bot terminates - * @throws IOException if there's a network problem - */ - public Thread connect(boolean quiet) throws IOException { - joinRoomsOnStart(quiet); - - var thread = new ChoreThread(); - thread.start(); - return thread; - } - - private void joinRoomsOnStart(boolean quiet) { - var first = true; - var roomsCopy = new ArrayList<>(rooms.getRooms()); - for (var room : roomsCopy) { - if (!first) { - /* - * Insert a pause between joining each room in an attempt to - * resolve an issue where the bot chooses to ignore all messages - * in certain rooms. - */ - Sleeper.sleep(ROOM_JOIN_DELAY); - } - - try { - joinRoom(room, quiet); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); - rooms.remove(room); - } - - first = false; - } - } - - private class ChoreThread extends Thread { - @Override - public void run() { - try { - scheduledTasks.forEach(Bot.this::scheduleTask); - - while (true) { - Chore chore; - try { - chore = choreQueue.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); - break; - } - - if (chore instanceof StopChore || chore instanceof FinishChore) { - break; - } - - chore.complete(); - database.commit(); - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); - } finally { - try { - connection.close(); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); - } - - database.commit(); - timer.cancel(); - } - } - } - - @Override - public List getLatestMessages(int roomId, int count) throws IOException { - var room = connection.getRoom(roomId); - var notInRoom = (room == null); - if (notInRoom) { - return List.of(); - } - - //@formatter:off + 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 Rooms rooms; + private final Integer maxRooms; + private final List listeners; + private final List responseFilters; + private final List scheduledTasks; + private final List inactivityTasks; + private final Map timeOfLastMessageByRoom = new HashMap<>(); + private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); + private final Statistics stats; + private final Database database; + private final Timer timer = new Timer(); + private TimerTask timeoutTask; + private volatile boolean timeout = false; + + /** + *

+ * A collection of messages that the bot posted, but have not been "echoed" + * back yet in the chat room. When a message is echoed back, it is removed + * from this map. + *

+ *

+ * This is used to determine whether something the bot posted was converted + * to a onebox. It is then used to edit the message in order to hide the + * onebox. + *

+ *
    + *
  • Key = The message ID.
  • + *
  • Value = The raw message content that was sent to the chat room by the + * bot (which can be different from what was echoed back).
  • + *
+ */ + private final Map postedMessages = new HashMap<>(); + + private Bot(Builder builder) { + connection = Objects.requireNonNull(builder.connection); + + 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; + stats = builder.stats; + database = (builder.database == null) ? new MemoryDatabase() : builder.database; + rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); + listeners = builder.listeners; + scheduledTasks = builder.tasks; + inactivityTasks = builder.inactivityTasks; + responseFilters = builder.responseFilters; + } + + private void scheduleTask(ScheduledTask task) { + var nextRun = task.nextRun(); + if (nextRun <= 0) { + return; + } + + scheduleChore(nextRun, new ScheduledTaskChore(task)); + } + + private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { + var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); + inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); + } + + /** + * Starts the chat bot. The bot will join the rooms in the current thread + * before launching its own thread. + * @param quiet true to start the bot without broadcasting the greeting + * message, false to broadcast the greeting message + * @return the thread that the bot is running in. This thread will terminate + * when the bot terminates + * @throws IOException if there's a network problem + */ + public Thread connect(boolean quiet) throws IOException { + joinRoomsOnStart(quiet); + + var thread = new ChoreThread(); + thread.start(); + return thread; + } + + private void joinRoomsOnStart(boolean quiet) { + var first = true; + var roomsCopy = new ArrayList<>(rooms.getRooms()); + for (var room : roomsCopy) { + if (!first) { + /* + * Insert a pause between joining each room in an attempt to + * resolve an issue where the bot chooses to ignore all messages + * in certain rooms. + */ + Sleeper.sleep(ROOM_JOIN_DELAY); + } + + try { + joinRoom(room, quiet); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); + rooms.remove(room); + } + + first = false; + } + } + + private class ChoreThread extends Thread { + @Override + public void run() { + try { + scheduledTasks.forEach(Bot.this::scheduleTask); + + while (true) { + Chore chore; + try { + chore = choreQueue.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); + break; + } + + if (chore instanceof StopChore || chore instanceof FinishChore) { + break; + } + + chore.complete(); + database.commit(); + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); + } finally { + try { + connection.close(); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); + } + + database.commit(); + timer.cancel(); + } + } + } + + @Override + public List getLatestMessages(int roomId, int count) throws IOException { + var room = connection.getRoom(roomId); + var notInRoom = (room == null); + if (notInRoom) { + return List.of(); + } + + //@formatter:off return room.getMessages(count).stream() .map(this::convertFromBotlerRelayMessage) .toList(); //@formatter:on - } - - @Override - public String getOriginalMessageContent(long messageId) throws IOException { - return connection.getOriginalMessageContent(messageId); - } - - @Override - public String uploadImage(String url) throws IOException { - return connection.uploadImage(url); - } - - @Override - public String uploadImage(byte[] data) throws IOException { - return connection.uploadImage(data); - } - - @Override - public void sendMessage(int roomId, PostMessage message) throws IOException { - var room = connection.getRoom(roomId); - if (room != null) { - sendMessage(room, message); - } - } - - private void sendMessage(IRoom room, String message) throws IOException { - sendMessage(room, new PostMessage(message)); - } - - private void sendMessage(IRoom room, PostMessage message) throws IOException { - final String filteredMessage; - if (message.bypassFilters()) { - filteredMessage = message.message(); - } else { - var messageText = message.message(); - for (var filter : responseFilters) { - if (filter.isEnabled(room.getRoomId())) { - messageText = filter.filter(messageText); - } - } - filteredMessage = messageText; - } - - logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); - - synchronized (postedMessages) { - var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); - var condensedMessage = message.condensedMessage(); - var ephemeral = message.ephemeral(); - - var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); - postedMessages.put(messageIds.get(0), postedMessage); - } - } - - @Override - public void join(int roomId) throws IOException { - joinRoom(roomId); - } - - /** - * Joins a room. - * - * @param roomId the room ID - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { - return joinRoom(roomId, false); - } - - /** - * Joins a room. - * - * @param roomId the room ID - * @param quiet true to not post an announcement message, false to post one - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { - var room = connection.getRoom(roomId); - if (room != null) { - return room; - } - - logger.atInfo().log(() -> "Joining room " + roomId + "..."); - - room = connection.joinRoom(roomId); - - room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - - if (!quiet && config.getGreeting() != null) { - try { - sendMessage(room, config.getGreeting()); - } catch (RoomPermissionException e) { - logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); - } - } - - rooms.add(roomId); - - for (var task : inactivityTasks) { - var nextRun = task.getInactivityTime(room, this); - if (nextRun == null) { - continue; - } - - scheduleTask(task, room, nextRun); - } - - return room; - } - - @Override - public void leave(int roomId) throws IOException { - logger.atInfo().log(() -> "Leaving room " + roomId + "..."); - - inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); - timeOfLastMessageByRoom.remove(roomId); - rooms.remove(roomId); - - var room = connection.getRoom(roomId); - if (room != null) { - room.leave(); - } - } - - @Override - public String getUsername() { - return config.getUserName(); - } - - @Override - public Integer getUserId() { - return config.getUserId(); - } - - @Override - public List getAdminUsers() { - return security.getAdmins(); - } - - private boolean isAdminUser(Integer userId) { - return security.isAdmin(userId); - } - - @Override - public boolean isRoomOwner(int roomId, int userId) throws IOException { - var userInfo = connection.getUserInfo(roomId, userId); - return (userInfo == null) ? false : userInfo.isOwner(); - } - - @Override - public String getTrigger() { - return config.getTrigger(); - } - - @Override - public List getRooms() { - return rooms.getRooms(); - } - - @Override - public IRoom getRoom(int roomId) { - return connection.getRoom(roomId); - } - - @Override - public List getHomeRooms() { - return rooms.getHomeRooms(); - } - - @Override - public List getQuietRooms() { - return rooms.getQuietRooms(); - } - - @Override - public Integer getMaxRooms() { - return maxRooms; - } - - @Override - public void broadcastMessage(PostMessage message) throws IOException { - for (var room : connection.getRooms()) { - if (!rooms.isQuietRoom(room.getRoomId())) { - sendMessage(room, message); - } - } - } - - @Override - public synchronized void timeout(Duration duration) { - if (timeout) { - timeoutTask.cancel(); - } else { - timeout = true; - } - - timeoutTask = new TimerTask() { - @Override - public void run() { - timeout = false; - } - }; - - timer.schedule(timeoutTask, duration.toMillis()); - } - - @Override - public synchronized void cancelTimeout() { - timeout = false; - if (timeoutTask != null) { - timeoutTask.cancel(); - } - } - - /** - * Sends a signal to immediately stop processing tasks. The bot thread will - * stop running once it is done processing the current task. - */ - public void stop() { - choreQueue.add(new StopChore()); - } - - /** - * Sends a signal to finish processing the tasks in the queue, and then - * terminate. - */ - public void finish() { - choreQueue.add(new FinishChore()); - } - - private TimerTask scheduleChore(long delay, Chore chore) { - var timerTask = new TimerTask() { - @Override - public void run() { - choreQueue.add(chore); - } - }; - timer.schedule(timerTask, delay); - - return timerTask; - } - - private TimerTask scheduleChore(Duration delay, Chore chore) { - return scheduleChore(delay.toMillis(), chore); - } - - /** - * Represents a message that was posted to the chat room. - * - * @author Michael Angstadt - */ - private static class PostedMessage { - private final Instant timePosted; - private final String originalContent; - private final String condensedContent; - private final boolean ephemeral; - private final int roomId; - private final long parentId; - private final List messageIds; - - /** - * @param timePosted the time the message was posted - * @param originalContent the original message that the bot sent to the - * chat room - * @param condensedContent the text that the message should be changed - * to after the amount of time specified in the "hideOneboxesAfter" - * setting - * @param ephemeral true to delete the message after the amount of time - * specified in the "hideOneboxesAfter" setting, false not to - * @param roomId the ID of the room the message was posted in - * @param parentId the ID of the message that this was a reply to - * @param messageIds the ID of each message that was actually posted to - * the room (the chat client may split up the original message due to - * length limitations) - */ - public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { - this.timePosted = timePosted; - this.originalContent = originalContent; - this.condensedContent = condensedContent; - this.ephemeral = ephemeral; - this.roomId = roomId; - this.parentId = parentId; - this.messageIds = messageIds; - } - - /** - * Gets the time the message was posted. - * - * @return the time the message was posted - */ - public Instant getTimePosted() { - return timePosted; - } - - /** - * Gets the content of the original message that the bot sent to the - * chat room. This is used for when a message was converted to a onebox. - * - * @return the original content - */ - public String getOriginalContent() { - return originalContent; - } - - /** - * Gets the text that the message should be changed to after the amount - * of time specified in the "hideOneboxesAfter" setting. - * - * @return the new content or null to leave the message alone - */ - public String getCondensedContent() { - return condensedContent; - } - - /** - * Gets the ID of each message that was actually posted to the room. The - * chat client may split up the original message due to length - * limitations. - * - * @return the message IDs - */ - public List getMessageIds() { - return messageIds; - } - - /** - * Gets the ID of the room the message was posted in. - * - * @return the room ID - */ - public int getRoomId() { - return roomId; - } - - /** - * Determines if the message has requested that it be condensed or - * deleted after the amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * - * @return true to condense or delete the message, false to leave it - * alone - */ - public boolean isCondensableOrEphemeral() { - return condensedContent != null || isEphemeral(); - } - - /** - * Determines if the message has requested that it be deleted after the - * amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * - * @return true to delete the message, false not to - */ - public boolean isEphemeral() { - return ephemeral; - } - - /** - * Gets the ID of the message that this was a reply to. - * - * @return the parent ID or 0 if it's not a reply - */ - public long getParentId() { - return parentId; - } - } - - private abstract class Chore implements Comparable { - private final long choreId; - - public Chore() { - choreId = choreIdCounter.getAndIncrement(); - } - - public abstract void complete(); - - @Override - public int compareTo(Chore that) { - /* - * The "lowest" value will be popped off the queue first. - */ - - if (isBothStopChore(that)) { - return 0; - } - if (isThisStopChore()) { - return -1; - } - if (isThatStopChore(that)) { - return 1; - } - - if (isBothCondenseMessageChore(that)) { - return Long.compare(this.choreId, that.choreId); - } - if (isThisCondenseMessageChore()) { - return -1; - } - 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 { - @Override - public void complete() { - //empty - } - } - - private class FinishChore extends Chore { - @Override - public void complete() { - //empty - } - } - - private class ChatEventChore extends Chore { - private final Event event; - - public ChatEventChore(Event event) { - this.event = event; - } - - @Override - public void complete() { - if (event instanceof MessagePostedEvent mpe) { - handleMessage(mpe.getMessage()); - return; - } - - if (event instanceof MessageEditedEvent mee) { - handleMessage(mee.getMessage()); - return; - } - - if (event instanceof InvitationEvent ie) { - var roomId = ie.getRoomId(); - var userId = ie.getUserId(); - var inviterIsAdmin = isAdminUser(userId); - - boolean acceptInvitation; - if (inviterIsAdmin) { - acceptInvitation = true; - } else { - try { - acceptInvitation = isRoomOwner(roomId, userId); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); - acceptInvitation = false; - } - } - - if (acceptInvitation) { - handleInvitation(ie); - } - - return; - } - - logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); - } - - private void handleMessage(ChatMessage message) { - var userId = message.getUserId(); - var isAdminUser = isAdminUser(userId); - var isBotInTimeout = timeout && !isAdminUser; - - if (isBotInTimeout) { - //bot is in timeout, ignore - return; - } - - var messageWasDeleted = message.getContent() == null; - if (messageWasDeleted) { - //user deleted their message, ignore - return; - } - - 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; - } - - var userIsBanned = security.isBanned(userId); - if (userIsBanned) { - //message was posted by a banned user, ignore - return; - } - - var room = connection.getRoom(message.getRoomId()); - if (room == null) { - //the bot is no longer in the room - return; - } - - if (message.getUserId() == userId) { - //message was posted by this bot - handleBotMessage(message); - return; - } - - message = convertFromBotlerRelayMessage(message); - - timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); - - var actions = handleListeners(message); - handleActions(message, actions); - } - - private void handleBotMessage(ChatMessage message) { - PostedMessage postedMessage; - synchronized (postedMessages) { - postedMessage = postedMessages.remove(message.getMessageId()); - } - - /* - * Check to see if the message should be edited for brevity - * after a short time so it doesn't spam the chat history. - * - * This could happen if (1) the bot posted something that Stack - * Overflow Chat converted to a onebox (e.g. an image) or (2) - * the message itself has asked to be edited (e.g. a javadoc - * description). - * - * ===What is a onebox?=== - * - * Stack Overflow Chat converts certain URLs to "oneboxes". - * Oneboxes can be fairly large and can spam the chat. For - * example, if the message is a URL to an image, the image - * itself will be displayed in the chat room. This is nice, but - * gets annoying if the image is large or if it's an animated - * GIF. - * - * After giving people some time to see the onebox, the bot will - * edit the message so that the onebox no longer displays, but - * the URL is still preserved. - */ - var messageIsOnebox = message.getContent().isOnebox(); - if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { - var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); - var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); - - logger.atInfo().log(() -> { - var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; - return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); - }); - - scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); - } - } - - private ChatActions handleListeners(ChatMessage message) { - var actions = new ChatActions(); - for (var listener : listeners) { - try { - actions.addAll(listener.onMessage(message, Bot.this)); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running listener."); - } - } - return actions; - } - - private void handleActions(ChatMessage message, ChatActions actions) { - if (actions.isEmpty()) { - return; - } - - logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); - - if (stats != null) { - stats.incMessagesRespondedTo(); - } - - var queue = new LinkedList<>(actions.getActions()); - while (!queue.isEmpty()) { - var action = queue.removeFirst(); - processAction(action, message, queue); - } - } - - 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(this, message); - var response = action.execute(context); - queue.addAll(response.getActions()); - } - - private void handlePostMessageAction(PostMessage action, ChatMessage message) { - try { - if (action.delay() != null) { - scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); - } else { - if (action.broadcast()) { - broadcastMessage(action); - } else { - sendMessage(message.getRoomId(), action); - } - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); - } - } - - private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { - try { - var room = connection.getRoom(message.getRoomId()); - room.deleteMessage(action.messageId()); - return action.onSuccess().get(); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); - return action.onError().apply(e); - } - } - - private ChatActions handleJoinRoomAction(JoinRoom action) { - if (maxRooms != null && connection.getRooms().size() >= maxRooms) { - return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); - } - - try { - var joinedRoom = joinRoom(action.roomId()); - if (joinedRoom.canPost()) { - return action.onSuccess().get(); - } - - 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) { - 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(); - } catch (Exception e) { - return action.onError().apply(e); - } - } - - /** - * Attempts to leave a room and logs any errors that occur. - * - * @param roomId the room ID to leave + } + + @Override + public String getOriginalMessageContent(long messageId) throws IOException { + return connection.getOriginalMessageContent(messageId); + } + + @Override + public String uploadImage(String url) throws IOException { + return connection.uploadImage(url); + } + + @Override + public String uploadImage(byte[] data) throws IOException { + return connection.uploadImage(data); + } + + @Override + public void sendMessage(int roomId, PostMessage message) throws IOException { + var room = connection.getRoom(roomId); + if (room != null) { + sendMessage(room, message); + } + } + + private void sendMessage(IRoom room, String message) throws IOException { + sendMessage(room, new PostMessage(message)); + } + + private void sendMessage(IRoom room, PostMessage message) throws IOException { + final String filteredMessage; + if (message.bypassFilters()) { + filteredMessage = message.message(); + } else { + var messageText = message.message(); + for (var filter : responseFilters) { + if (filter.isEnabled(room.getRoomId())) { + messageText = filter.filter(messageText); + } + } + filteredMessage = messageText; + } + + logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); + + synchronized (postedMessages) { + var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); + var condensedMessage = message.condensedMessage(); + var ephemeral = message.ephemeral(); + + var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); + postedMessages.put(messageIds.get(0), postedMessage); + } + } + + @Override + public void join(int roomId) throws IOException { + joinRoom(roomId); + } + + /** + * Joins a room. + * @param roomId the room ID + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { + return joinRoom(roomId, false); + } + + /** + * Joins a room. + * @param roomId the room ID + * @param quiet true to not post an announcement message, false to post one + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { + var room = connection.getRoom(roomId); + if (room != null) { + return room; + } + + logger.atInfo().log(() -> "Joining room " + roomId + "..."); + + room = connection.joinRoom(roomId); + + room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + + if (!quiet && config.getGreeting() != null) { + try { + sendMessage(room, config.getGreeting()); + } catch (RoomPermissionException e) { + logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); + } + } + + rooms.add(roomId); + + for (var task : inactivityTasks) { + var nextRun = task.getInactivityTime(room, this); + if (nextRun == null) { + continue; + } + + scheduleTask(task, room, nextRun); + } + + return room; + } + + @Override + public void leave(int roomId) throws IOException { + logger.atInfo().log(() -> "Leaving room " + roomId + "..."); + + inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); + timeOfLastMessageByRoom.remove(roomId); + rooms.remove(roomId); + + var room = connection.getRoom(roomId); + if (room != null) { + room.leave(); + } + } + + @Override + public String getUsername() { + return config.getUserName(); + } + + @Override + public Integer getUserId() { + return config.getUserId(); + } + + @Override + public List getAdminUsers() { + return security.getAdmins(); + } + + private boolean isAdminUser(Integer userId) { + return security.isAdmin(userId); + } + + @Override + public boolean isRoomOwner(int roomId, int userId) throws IOException { + var userInfo = connection.getUserInfo(roomId, userId); + return (userInfo == null) ? false : userInfo.isOwner(); + } + + @Override + public String getTrigger() { + return config.getTrigger(); + } + + @Override + public List getRooms() { + return rooms.getRooms(); + } + + @Override + public IRoom getRoom(int roomId) { + return connection.getRoom(roomId); + } + + @Override + public List getHomeRooms() { + return rooms.getHomeRooms(); + } + + @Override + public List getQuietRooms() { + return rooms.getQuietRooms(); + } + + @Override + public Integer getMaxRooms() { + return maxRooms; + } + + @Override + public void broadcastMessage(PostMessage message) throws IOException { + for (var room : connection.getRooms()) { + if (!rooms.isQuietRoom(room.getRoomId())) { + sendMessage(room, message); + } + } + } + + @Override + public synchronized void timeout(Duration duration) { + if (timeout) { + timeoutTask.cancel(); + } else { + timeout = true; + } + + timeoutTask = new TimerTask() { + @Override + public void run() { + timeout = false; + } + }; + + timer.schedule(timeoutTask, duration.toMillis()); + } + + @Override + public synchronized void cancelTimeout() { + timeout = false; + if (timeoutTask != null) { + timeoutTask.cancel(); + } + } + + /** + * Sends a signal to immediately stop processing tasks. The bot thread will + * stop running once it is done processing the current task. + */ + public void stop() { + choreQueue.add(new StopChore()); + } + + /** + * Sends a signal to finish processing the tasks in the queue, and then + * terminate. + */ + public void finish() { + choreQueue.add(new FinishChore()); + } + + private TimerTask scheduleChore(long delay, Chore chore) { + var timerTask = new TimerTask() { + @Override + public void run() { + choreQueue.add(chore); + } + }; + timer.schedule(timerTask, delay); + + return timerTask; + } + + private TimerTask scheduleChore(Duration delay, Chore chore) { + return scheduleChore(delay.toMillis(), chore); + } + + /** + * Represents a message that was posted to the chat room. + * @author Michael Angstadt + */ + private static class PostedMessage { + private final Instant timePosted; + private final String originalContent; + private final String condensedContent; + private final boolean ephemeral; + private final int roomId; + private final long parentId; + private final List messageIds; + + /** + * @param timePosted the time the message was posted + * @param originalContent the original message that the bot sent to the + * chat room + * @param condensedContent the text that the message should be changed + * to after the amount of time specified in the "hideOneboxesAfter" + * setting + * @param ephemeral true to delete the message after the amount of time + * specified in the "hideOneboxesAfter" setting, false not to + * @param roomId the ID of the room the message was posted in + * @param parentId the ID of the message that this was a reply to + * @param messageIds the ID of each message that was actually posted to + * the room (the chat client may split up the original message due to + * length limitations) + */ + public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { + this.timePosted = timePosted; + this.originalContent = originalContent; + this.condensedContent = condensedContent; + this.ephemeral = ephemeral; + this.roomId = roomId; + this.parentId = parentId; + this.messageIds = messageIds; + } + + /** + * Gets the time the message was posted. + * @return the time the message was posted + */ + public Instant getTimePosted() { + return timePosted; + } + + /** + * Gets the content of the original message that the bot sent to the + * chat room. This is used for when a message was converted to a onebox. + * @return the original content + */ + public String getOriginalContent() { + return originalContent; + } + + /** + * Gets the text that the message should be changed to after the amount + * of time specified in the "hideOneboxesAfter" setting. + * @return the new content or null to leave the message alone + */ + public String getCondensedContent() { + return condensedContent; + } + + /** + * Gets the ID of each message that was actually posted to the room. The + * chat client may split up the original message due to length + * limitations. + * @return the message IDs + */ + public List getMessageIds() { + return messageIds; + } + + /** + * Gets the ID of the room the message was posted in. + * @return the room ID + */ + public int getRoomId() { + return roomId; + } + + /** + * Determines if the message has requested that it be condensed or + * deleted after the amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * @return true to condense or delete the message, false to leave it + * alone + */ + public boolean isCondensableOrEphemeral() { + return condensedContent != null || isEphemeral(); + } + + /** + * Determines if the message has requested that it be deleted after the + * amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * @return true to delete the message, false not to + */ + public boolean isEphemeral() { + return ephemeral; + } + + /** + * Gets the ID of the message that this was a reply to. + * @return the parent ID or 0 if it's not a reply + */ + public long getParentId() { + return parentId; + } + } + + private abstract class Chore implements Comparable { + private final long choreId; + + public Chore() { + choreId = choreIdCounter.getAndIncrement(); + } + + public abstract void complete(); + + @Override + public int compareTo(Chore that) { + /* + * The "lowest" value will be popped off the queue first. + */ + + if (isBothStopChore(that)) { + return 0; + } + if (isThisStopChore()) { + return -1; + } + if (isThatStopChore(that)) { + return 1; + } + + if (isBothCondenseMessageChore(that)) { + return Long.compare(this.choreId, that.choreId); + } + if (isThisCondenseMessageChore()) { + return -1; + } + 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 { + @Override + public void complete() { + //empty + } + } + + private class FinishChore extends Chore { + @Override + public void complete() { + //empty + } + } + + private class ChatEventChore extends Chore { + private final Event event; + + public ChatEventChore(Event event) { + this.event = event; + } + + @Override + public void complete() { + if (event instanceof MessagePostedEvent mpe) { + handleMessage(mpe.getMessage()); + return; + } + + if (event instanceof MessageEditedEvent mee) { + handleMessage(mee.getMessage()); + return; + } + + if (event instanceof InvitationEvent ie) { + var roomId = ie.getRoomId(); + var userId = ie.getUserId(); + var inviterIsAdmin = isAdminUser(userId); + + boolean acceptInvitation; + if (inviterIsAdmin) { + acceptInvitation = true; + } else { + try { + acceptInvitation = isRoomOwner(roomId, userId); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); + acceptInvitation = false; + } + } + + if (acceptInvitation) { + handleInvitation(ie); + } + + return; + } + + logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); + } + + private void handleMessage(ChatMessage message) { + var userId = message.getUserId(); + var isAdminUser = isAdminUser(userId); + var isBotInTimeout = timeout && !isAdminUser; + + if (isBotInTimeout) { + //bot is in timeout, ignore + return; + } + + var messageWasDeleted = message.getContent() == null; + if (messageWasDeleted) { + //user deleted their message, ignore + return; + } + + 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; + } + + var userIsBanned = security.isBanned(userId); + if (userIsBanned) { + //message was posted by a banned user, ignore + return; + } + + var room = connection.getRoom(message.getRoomId()); + if (room == null) { + //the bot is no longer in the room + return; + } + + if (message.getUserId() == userId) { + //message was posted by this bot + handleBotMessage(message); + return; + } + + message = convertFromBotlerRelayMessage(message); + + timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); + + var actions = handleListeners(message); + handleActions(message, actions); + } + + private void handleBotMessage(ChatMessage message) { + PostedMessage postedMessage; + synchronized (postedMessages) { + postedMessage = postedMessages.remove(message.getMessageId()); + } + + /* + * Check to see if the message should be edited for brevity + * after a short time so it doesn't spam the chat history. + * + * This could happen if (1) the bot posted something that Stack + * Overflow Chat converted to a onebox (e.g. an image) or (2) + * the message itself has asked to be edited (e.g. a javadoc + * description). + * + * ===What is a onebox?=== + * + * Stack Overflow Chat converts certain URLs to "oneboxes". + * Oneboxes can be fairly large and can spam the chat. For + * example, if the message is a URL to an image, the image + * itself will be displayed in the chat room. This is nice, but + * gets annoying if the image is large or if it's an animated + * GIF. + * + * After giving people some time to see the onebox, the bot will + * edit the message so that the onebox no longer displays, but + * the URL is still preserved. + */ + var messageIsOnebox = message.getContent().isOnebox(); + if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { + var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); + var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); + + logger.atInfo().log(() -> { + var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; + return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); + }); + + scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); + } + } + + private ChatActions handleListeners(ChatMessage message) { + var actions = new ChatActions(); + for (var listener : listeners) { + try { + actions.addAll(listener.onMessage(message, Bot.this)); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem running listener."); + } + } + return actions; + } + + private void handleActions(ChatMessage message, ChatActions actions) { + if (actions.isEmpty()) { + return; + } + + logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); + + if (stats != null) { + stats.incMessagesRespondedTo(); + } + + var queue = new LinkedList<>(actions.getActions()); + while (!queue.isEmpty()) { + var action = queue.removeFirst(); + processAction(action, message, queue); + } + } + + 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(this, message); + var response = action.execute(context); + queue.addAll(response.getActions()); + } + + private void handlePostMessageAction(PostMessage action, ChatMessage message) { + try { + if (action.delay() != null) { + scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); + } else { + if (action.broadcast()) { + broadcastMessage(action); + } else { + sendMessage(message.getRoomId(), action); + } + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); + } + } + + private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { + try { + var room = connection.getRoom(message.getRoomId()); + room.deleteMessage(action.messageId()); + return action.onSuccess().get(); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); + return action.onError().apply(e); + } + } + + private ChatActions handleJoinRoomAction(JoinRoom action) { + if (maxRooms != null && connection.getRooms().size() >= maxRooms) { + return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); + } + + try { + var joinedRoom = joinRoom(action.roomId()); + if (joinedRoom.canPost()) { + return action.onSuccess().get(); + } + + 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) { + 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(); + } catch (Exception e) { + return action.onError().apply(e); + } + } + + /** + * 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()); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); - } - } - - private void handleInvitation(InvitationEvent event) { - /* - * If the bot is currently connected to multiple rooms, the - * invitation event will be sent to each room and this method will - * be called multiple times. Check to see if the bot has already - * joined the room it was invited to. - */ - var roomId = event.getRoomId(); - if (connection.isInRoom(roomId)) { - return; - } - - /* - * Ignore the invitation if the bot is connected to the maximum - * number of rooms allowed. We can't really post an error message - * because the invitation event is not linked to a specific chat - * room. - */ - var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); - if (maxRoomsExceeded) { - return; - } - - try { - joinRoom(roomId); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); - } - } - } - - private class CondenseMessageChore extends Chore { - private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); - private final PostedMessage postedMessage; - - public CondenseMessageChore(PostedMessage postedMessage) { - this.postedMessage = postedMessage; - } - - @Override - public void complete() { - var roomId = postedMessage.getRoomId(); - var room = connection.getRoom(roomId); - - var botIsNoLongerInTheRoom = (room == null); - if (botIsNoLongerInTheRoom) { - return; - } - - try { - List messagesToDelete; - if (postedMessage.isEphemeral()) { - messagesToDelete = postedMessage.getMessageIds(); - } else { - var condensedContent = postedMessage.getCondensedContent(); - var isAOneBox = (condensedContent == null); - if (isAOneBox) { - condensedContent = postedMessage.getOriginalContent(); - } - - var messageIds = postedMessage.getMessageIds(); - var quotedContent = quote(condensedContent); - room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); - - /* - * If the original content was split up into - * multiple messages due to length constraints, - * delete the additional messages. - */ - messagesToDelete = messageIds.subList(1, messageIds.size()); - } - - for (var id : messagesToDelete) { - room.deleteMessage(id); - } - } catch (Exception e) { + leave(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(logMessage); } + } + + private void handleLeaveRoomAction(LeaveRoom action) { + try { + leave(action.roomId()); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); + } + } + + private void handleInvitation(InvitationEvent event) { + /* + * If the bot is currently connected to multiple rooms, the + * invitation event will be sent to each room and this method will + * be called multiple times. Check to see if the bot has already + * joined the room it was invited to. + */ + var roomId = event.getRoomId(); + if (connection.isInRoom(roomId)) { + return; + } + + /* + * Ignore the invitation if the bot is connected to the maximum + * number of rooms allowed. We can't really post an error message + * because the invitation event is not linked to a specific chat + * room. + */ + var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); + if (maxRoomsExceeded) { + return; + } + + try { + joinRoom(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); + } + } + } + + private class CondenseMessageChore extends Chore { + private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); + private final PostedMessage postedMessage; + + public CondenseMessageChore(PostedMessage postedMessage) { + this.postedMessage = postedMessage; + } + + @Override + public void complete() { + var roomId = postedMessage.getRoomId(); + var room = connection.getRoom(roomId); + + var botIsNoLongerInTheRoom = (room == null); + if (botIsNoLongerInTheRoom) { + return; + } + + try { + List messagesToDelete; + if (postedMessage.isEphemeral()) { + messagesToDelete = postedMessage.getMessageIds(); + } else { + var condensedContent = postedMessage.getCondensedContent(); + var isAOneBox = (condensedContent == null); + if (isAOneBox) { + condensedContent = postedMessage.getOriginalContent(); + } + + var messageIds = postedMessage.getMessageIds(); + var quotedContent = quote(condensedContent); + room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); + + /* + * If the original content was split up into + * multiple messages due to length constraints, + * delete the additional messages. + */ + messagesToDelete = messageIds.subList(1, messageIds.size()); + } + + for (var id : messagesToDelete) { + room.deleteMessage(id); + } + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]"); - } - } + } + } - @SuppressWarnings("deprecation") - private String quote(String content) { - var cb = new ChatBuilder(); + @SuppressWarnings("deprecation") + private String quote(String content) { + var cb = new ChatBuilder(); - var m = replyRegex.matcher(content); - if (m.find()) { - var id = Long.parseLong(m.group(1)); - content = m.group(2); + var m = replyRegex.matcher(content); + if (m.find()) { + var id = Long.parseLong(m.group(1)); + content = m.group(2); - cb.reply(id); - } + cb.reply(id); + } - return cb.quote(content).toString(); - } - } + return cb.quote(content).toString(); + } + } - private class ScheduledTaskChore extends Chore { - private final ScheduledTask task; + private class ScheduledTaskChore extends Chore { + private final ScheduledTask task; - public ScheduledTaskChore(ScheduledTask task) { - this.task = task; - } + public ScheduledTaskChore(ScheduledTask task) { + this.task = task; + } - @Override - public void complete() { - try { - task.run(Bot.this); - } catch (Exception e) { + @Override + public void complete() { + try { + task.run(Bot.this); + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem running scheduled task."); - } - scheduleTask(task); - } - } - - private class InactivityTaskChore extends Chore { - private final InactivityTask task; - private final IRoom room; - - public InactivityTaskChore(InactivityTask task, IRoom room) { - this.task = task; - this.room = room; - } - - @Override - public void complete() { - try { - if (!connection.isInRoom(room.getRoomId())) { - return; - } - - var inactivityTime = task.getInactivityTime(room, Bot.this); - if (inactivityTime == null) { - return; - } - - var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); - var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); - var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); - if (runNow) { - try { - task.run(room, Bot.this); - } catch (Exception e) { + } + scheduleTask(task); + } + } + + private class InactivityTaskChore extends Chore { + private final InactivityTask task; + private final IRoom room; + + public InactivityTaskChore(InactivityTask task, IRoom room) { + this.task = task; + this.room = room; + } + + @Override + public void complete() { + try { + if (!connection.isInRoom(room.getRoomId())) { + return; + } + + var inactivityTime = task.getInactivityTime(room, Bot.this); + if (inactivityTime == null) { + return; + } + + var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); + var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); + var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); + if (runNow) { + try { + task.run(room, Bot.this); + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem running inactivity task in room " + room.getRoomId() + "."); - } - } - - var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); - scheduleTask(task, room, nextCheck); - } finally { - inactivityTimerTasksByRoom.remove(room, this); - } - } - } - - private class DelayedMessageChore extends Chore { - private final int roomId; - private final PostMessage message; - - public DelayedMessageChore(int roomId, PostMessage message) { - this.roomId = roomId; - this.message = message; - } - - @Override - public void complete() { - try { - if (message.broadcast()) { - broadcastMessage(message); - } else { - sendMessage(roomId, message); - } - } catch (Exception e) { + } + } + + var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); + scheduleTask(task, room, nextCheck); + } finally { + inactivityTimerTasksByRoom.remove(room, this); + } + } + } + + private class DelayedMessageChore extends Chore { + private final int roomId; + private final PostMessage message; + + public DelayedMessageChore(int roomId, PostMessage message) { + this.roomId = roomId; + this.message = message; + } + + @Override + public void complete() { + try { + if (message.broadcast()) { + broadcastMessage(message); + } else { + sendMessage(roomId, message); + } + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message()); - } - } - } - - /** - * Alters the username and content of a message if the message is a Botler - * Discord relay message. Otherwise, returns the message unaltered. - * - * @param message the original message - * @return the altered message or the same message if it's not a relay - * message - * @see example - */ - private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { - if (message.getUserId() != BOTLER_ID) { - return message; - } - - var content = message.getContent(); - if (content == null) { - return message; - } - - //Example message content: - //[realmichael] test - var html = content.getContent(); - var dom = Jsoup.parse(html); - var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); - if (element == null) { - return message; - } - var discordUsername = element.text(); - - var endBracket = html.indexOf(']'); - if (endBracket < 0) { - return message; - } - var discordMessage = html.substring(endBracket + 1).trim(); - - //@formatter:off + } + } + } + + /** + * Alters the username and content of a message if the message is a Botler + * Discord relay message. Otherwise, returns the message unaltered. + * @param message the original message + * @return the altered message or the same message if it's not a relay + * message + * @see example + */ + private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { + if (message.getUserId() != BOTLER_ID) { + return message; + } + + var content = message.getContent(); + if (content == null) { + return message; + } + + //Example message content: + //[realmichael] test + var html = content.getContent(); + var dom = Jsoup.parse(html); + var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); + if (element == null) { + return message; + } + var discordUsername = element.text(); + + var endBracket = html.indexOf(']'); + if (endBracket < 0) { + return message; + } + var discordMessage = html.substring(endBracket + 1).trim(); + + //@formatter:off return new ChatMessage.Builder(message) .username(discordUsername) .content(discordMessage) .build(); //@formatter:on - } - - /** - * Builds {@link Bot} instances. - * - * @author Michael Angstadt - */ - public static class Builder { - private IChatClient connection; - private String userName; - private String trigger = "="; - private String greeting; - private Integer userId; - private Duration hideOneboxesAfter; - private Integer maxRooms; - private List roomsHome = List.of(1); - private List roomsQuiet = List.of(); - private List admins = List.of(); - private List bannedUsers = List.of(); - private List allowedUsers = List.of(); - private List listeners = List.of(); - private List tasks = List.of(); - private List inactivityTasks = List.of(); - private List responseFilters = List.of(); - private Statistics stats; - private Database database; - - public Builder connection(IChatClient connection) { - this.connection = connection; - return this; - } - - public Builder user(String userName, Integer userId) { - this.userName = (userName == null || userName.isEmpty()) ? null : userName; - this.userId = userId; - return this; - } - - public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { - this.hideOneboxesAfter = hideOneboxesAfter; - return this; - } - - public Builder trigger(String trigger) { - this.trigger = trigger; - return this; - } - - public Builder greeting(String greeting) { - this.greeting = greeting; - return this; - } - - public Builder roomsHome(Integer... roomIds) { - roomsHome = List.of(roomIds); - return this; - } - - public Builder roomsHome(Collection roomIds) { - roomsHome = List.copyOf(roomIds); - return this; - } - - public Builder roomsQuiet(Integer... roomIds) { - roomsQuiet = List.of(roomIds); - return this; - } - - public Builder roomsQuiet(Collection roomIds) { - roomsQuiet = List.copyOf(roomIds); - return this; - } - - public Builder maxRooms(Integer maxRooms) { - this.maxRooms = maxRooms; - return this; - } - - public Builder admins(Integer... admins) { - this.admins = List.of(admins); - return this; - } - - public Builder admins(Collection admins) { - this.admins = List.copyOf(admins); - return this; - } - - public Builder bannedUsers(Integer... bannedUsers) { - this.bannedUsers = List.of(bannedUsers); - return this; - } - - public Builder bannedUsers(Collection bannedUsers) { - this.bannedUsers = List.copyOf(bannedUsers); - return this; - } - - public Builder allowedUsers(Integer... allowedUsers) { - this.allowedUsers = List.of(allowedUsers); - return this; - } - - public Builder allowedUsers(Collection allowedUsers) { - this.allowedUsers = List.copyOf(allowedUsers); - return this; - } - - public Builder listeners(Listener... listeners) { - this.listeners = List.of(listeners); - return this; - } - - public Builder listeners(Collection listeners) { - this.listeners = List.copyOf(listeners); - return this; - } - - public Builder tasks(ScheduledTask... tasks) { - this.tasks = List.of(tasks); - return this; - } - - public Builder tasks(Collection tasks) { - this.tasks = List.copyOf(tasks); - return this; - } - - public Builder inactivityTasks(InactivityTask... tasks) { - inactivityTasks = List.of(tasks); - return this; - } - - public Builder inactivityTasks(Collection tasks) { - inactivityTasks = List.copyOf(tasks); - return this; - } - - public Builder responseFilters(ChatResponseFilter... filters) { - responseFilters = List.of(filters); - return this; - } - - public Builder responseFilters(Collection filters) { - responseFilters = List.copyOf(filters); - return this; - } - - public Builder stats(Statistics stats) { - this.stats = stats; - return this; - } - - public Builder database(Database database) { - this.database = database; - return this; - } - - public Bot build() { - if (connection == null) { - throw new IllegalStateException("No ChatConnection given."); - } - - if (connection.getUsername() == null && this.userName == null) { - throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - if (connection.getUserId() == null && this.userId == null) { - throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - return new Bot(this); - } - } + } + + /** + * Builds {@link Bot} instances. + * @author Michael Angstadt + */ + public static class Builder { + private IChatClient connection; + private String userName; + private String trigger = "="; + private String greeting; + private Integer userId; + private Duration hideOneboxesAfter; + private Integer maxRooms; + private List roomsHome = List.of(1); + private List roomsQuiet = List.of(); + private List admins = List.of(); + private List bannedUsers = List.of(); + private List allowedUsers = List.of(); + private List listeners = List.of(); + private List tasks = List.of(); + private List inactivityTasks = List.of(); + private List responseFilters = List.of(); + private Statistics stats; + private Database database; + + public Builder connection(IChatClient connection) { + this.connection = connection; + return this; + } + + public Builder user(String userName, Integer userId) { + this.userName = (userName == null || userName.isEmpty()) ? null : userName; + this.userId = userId; + return this; + } + + public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { + this.hideOneboxesAfter = hideOneboxesAfter; + return this; + } + + public Builder trigger(String trigger) { + this.trigger = trigger; + return this; + } + + public Builder greeting(String greeting) { + this.greeting = greeting; + return this; + } + + public Builder roomsHome(Integer... roomIds) { + roomsHome = List.of(roomIds); + return this; + } + + public Builder roomsHome(Collection roomIds) { + roomsHome = List.copyOf(roomIds); + return this; + } + + public Builder roomsQuiet(Integer... roomIds) { + roomsQuiet = List.of(roomIds); + return this; + } + + public Builder roomsQuiet(Collection roomIds) { + roomsQuiet = List.copyOf(roomIds); + return this; + } + + public Builder maxRooms(Integer maxRooms) { + this.maxRooms = maxRooms; + return this; + } + + public Builder admins(Integer... admins) { + this.admins = List.of(admins); + return this; + } + + public Builder admins(Collection admins) { + this.admins = List.copyOf(admins); + return this; + } + + public Builder bannedUsers(Integer... bannedUsers) { + this.bannedUsers = List.of(bannedUsers); + return this; + } + + public Builder bannedUsers(Collection bannedUsers) { + this.bannedUsers = List.copyOf(bannedUsers); + return this; + } + + public Builder allowedUsers(Integer... allowedUsers) { + this.allowedUsers = List.of(allowedUsers); + return this; + } + + public Builder allowedUsers(Collection allowedUsers) { + this.allowedUsers = List.copyOf(allowedUsers); + return this; + } + + public Builder listeners(Listener... listeners) { + this.listeners = List.of(listeners); + return this; + } + + public Builder listeners(Collection listeners) { + this.listeners = List.copyOf(listeners); + return this; + } + + public Builder tasks(ScheduledTask... tasks) { + this.tasks = List.of(tasks); + return this; + } + + public Builder tasks(Collection tasks) { + this.tasks = List.copyOf(tasks); + return this; + } + + public Builder inactivityTasks(InactivityTask... tasks) { + inactivityTasks = List.of(tasks); + return this; + } + + public Builder inactivityTasks(Collection tasks) { + inactivityTasks = List.copyOf(tasks); + return this; + } + + public Builder responseFilters(ChatResponseFilter... filters) { + responseFilters = List.of(filters); + return this; + } + + public Builder responseFilters(Collection filters) { + responseFilters = List.copyOf(filters); + return this; + } + + public Builder stats(Statistics stats) { + this.stats = stats; + return this; + } + + public Builder database(Database database) { + this.database = database; + return this; + } + + public Bot build() { + if (connection == null) { + throw new IllegalStateException("No ChatConnection given."); + } + + if (connection.getUsername() == null && this.userName == null) { + throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + if (connection.getUserId() == null && this.userId == null) { + throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + return new Bot(this); + } + } } From 0aa8969a40d3cadc08e970f79212780af2eb244f Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Sat, 29 Nov 2025 18:46:43 -0400 Subject: [PATCH 09/17] Chore: Changes requested --- src/main/java/oakbot/bot/Bot.java | 2515 +++++++++++++++-------------- 1 file changed, 1259 insertions(+), 1256 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index df2edbea..8f3f1e2b 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -1,34 +1,6 @@ package oakbot.bot; -import java.io.IOException; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Timer; -import java.util.TimerTask; -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; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.github.mangstadt.sochat4j.ChatMessage; -import com.github.mangstadt.sochat4j.IChatClient; -import com.github.mangstadt.sochat4j.IRoom; -import com.github.mangstadt.sochat4j.PrivateRoomException; -import com.github.mangstadt.sochat4j.RoomNotFoundException; -import com.github.mangstadt.sochat4j.RoomPermissionException; +import com.github.mangstadt.sochat4j.*; import com.github.mangstadt.sochat4j.event.Event; import com.github.mangstadt.sochat4j.event.InvitationEvent; import com.github.mangstadt.sochat4j.event.MessageEditedEvent; @@ -36,7 +8,6 @@ import com.github.mangstadt.sochat4j.util.Sleeper; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; - import oakbot.Database; import oakbot.MemoryDatabase; import oakbot.Rooms; @@ -46,1262 +17,1294 @@ import oakbot.listener.Listener; import oakbot.task.ScheduledTask; import oakbot.util.ChatBuilder; +import org.jsoup.Jsoup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.*; +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; /** * A Stackoverflow chat bot. + * * @author Michael Angstadt */ public class Bot implements IBot { - private static final Logger logger = LoggerFactory.getLogger(Bot.class); - static final int BOTLER_ID = 13750349; - + 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 BotConfiguration config; - private final SecurityConfiguration security; - private final IChatClient connection; - private final AtomicLong choreIdCounter = new AtomicLong(); - private final BlockingQueue choreQueue = new PriorityBlockingQueue<>(); - private final Rooms rooms; - private final Integer maxRooms; - private final List listeners; - private final List responseFilters; - private final List scheduledTasks; - private final List inactivityTasks; - private final Map timeOfLastMessageByRoom = new HashMap<>(); - private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); - private final Statistics stats; - private final Database database; - private final Timer timer = new Timer(); - private TimerTask timeoutTask; - private volatile boolean timeout = false; - - /** - *

- * A collection of messages that the bot posted, but have not been "echoed" - * back yet in the chat room. When a message is echoed back, it is removed - * from this map. - *

- *

- * This is used to determine whether something the bot posted was converted - * to a onebox. It is then used to edit the message in order to hide the - * onebox. - *

- *
    - *
  • Key = The message ID.
  • - *
  • Value = The raw message content that was sent to the chat room by the - * bot (which can be different from what was echoed back).
  • - *
- */ - private final Map postedMessages = new HashMap<>(); - - private Bot(Builder builder) { - connection = Objects.requireNonNull(builder.connection); - - 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; - stats = builder.stats; - database = (builder.database == null) ? new MemoryDatabase() : builder.database; - rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); - listeners = builder.listeners; - scheduledTasks = builder.tasks; - inactivityTasks = builder.inactivityTasks; - responseFilters = builder.responseFilters; - } - - private void scheduleTask(ScheduledTask task) { - var nextRun = task.nextRun(); - if (nextRun <= 0) { - return; - } - - scheduleChore(nextRun, new ScheduledTaskChore(task)); - } - - private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { - var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); - inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); - } - - /** - * Starts the chat bot. The bot will join the rooms in the current thread - * before launching its own thread. - * @param quiet true to start the bot without broadcasting the greeting - * message, false to broadcast the greeting message - * @return the thread that the bot is running in. This thread will terminate - * when the bot terminates - * @throws IOException if there's a network problem - */ - public Thread connect(boolean quiet) throws IOException { - joinRoomsOnStart(quiet); - - var thread = new ChoreThread(); - thread.start(); - return thread; - } - - private void joinRoomsOnStart(boolean quiet) { - var first = true; - var roomsCopy = new ArrayList<>(rooms.getRooms()); - for (var room : roomsCopy) { - if (!first) { - /* - * Insert a pause between joining each room in an attempt to - * resolve an issue where the bot chooses to ignore all messages - * in certain rooms. - */ - Sleeper.sleep(ROOM_JOIN_DELAY); - } - - try { - joinRoom(room, quiet); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); - rooms.remove(room); - } - - first = false; - } - } - - private class ChoreThread extends Thread { - @Override - public void run() { - try { - scheduledTasks.forEach(Bot.this::scheduleTask); - - while (true) { - Chore chore; - try { - chore = choreQueue.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); - break; - } - - if (chore instanceof StopChore || chore instanceof FinishChore) { - break; - } - - chore.complete(); - database.commit(); - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); - } finally { - try { - connection.close(); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); - } - - database.commit(); - timer.cancel(); - } - } - } - - @Override - public List getLatestMessages(int roomId, int count) throws IOException { - var room = connection.getRoom(roomId); - var notInRoom = (room == null); - if (notInRoom) { - return List.of(); - } - - //@formatter:off + 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 Rooms rooms; + private final Integer maxRooms; + private final List listeners; + private final List responseFilters; + private final List scheduledTasks; + private final List inactivityTasks; + private final Map timeOfLastMessageByRoom = new HashMap<>(); + private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); + private final Statistics stats; + private final Database database; + private final Timer timer = new Timer(); + private TimerTask timeoutTask; + private volatile boolean timeout = false; + + /** + *

+ * A collection of messages that the bot posted, but have not been "echoed" + * back yet in the chat room. When a message is echoed back, it is removed + * from this map. + *

+ *

+ * This is used to determine whether something the bot posted was converted + * to a onebox. It is then used to edit the message in order to hide the + * onebox. + *

+ *
    + *
  • Key = The message ID.
  • + *
  • Value = The raw message content that was sent to the chat room by the + * bot (which can be different from what was echoed back).
  • + *
+ */ + private final Map postedMessages = new HashMap<>(); + + private Bot(Builder builder) { + connection = Objects.requireNonNull(builder.connection); + + 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; + stats = builder.stats; + database = (builder.database == null) ? new MemoryDatabase() : builder.database; + rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); + listeners = builder.listeners; + scheduledTasks = builder.tasks; + inactivityTasks = builder.inactivityTasks; + responseFilters = builder.responseFilters; + } + + private void scheduleTask(ScheduledTask task) { + var nextRun = task.nextRun(); + if (nextRun <= 0) { + return; + } + + scheduleChore(nextRun, new ScheduledTaskChore(task)); + } + + private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { + var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); + inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); + } + + /** + * Starts the chat bot. The bot will join the rooms in the current thread + * before launching its own thread. + * @param quiet true to start the bot without broadcasting the greeting + * message, false to broadcast the greeting message + * @return the thread that the bot is running in. This thread will terminate + * when the bot terminates + * @throws IOException if there's a network problem + */ + public Thread connect(boolean quiet) throws IOException { + joinRoomsOnStart(quiet); + + var thread = new ChoreThread(); + thread.start(); + return thread; + } + + private void joinRoomsOnStart(boolean quiet) { + var first = true; + var roomsCopy = new ArrayList<>(rooms.getRooms()); + for (var room : roomsCopy) { + if (!first) { + /* + * Insert a pause between joining each room in an attempt to + * resolve an issue where the bot chooses to ignore all messages + * in certain rooms. + */ + Sleeper.sleep(ROOM_JOIN_DELAY); + } + + try { + joinRoom(room, quiet); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); + rooms.remove(room); + } + + first = false; + } + } + + private class ChoreThread extends Thread { + @Override + public void run() { + try { + scheduledTasks.forEach(Bot.this::scheduleTask); + + while (true) { + Chore chore; + try { + chore = choreQueue.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); + break; + } + + if (chore instanceof StopChore || chore instanceof FinishChore) { + break; + } + + chore.complete(); + database.commit(); + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); + } finally { + try { + connection.close(); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); + } + + database.commit(); + timer.cancel(); + } + } + } + + @Override + public List getLatestMessages(int roomId, int count) throws IOException { + var room = connection.getRoom(roomId); + var notInRoom = (room == null); + if (notInRoom) { + return List.of(); + } + + //@formatter:off return room.getMessages(count).stream() .map(this::convertFromBotlerRelayMessage) .toList(); //@formatter:on - } - - @Override - public String getOriginalMessageContent(long messageId) throws IOException { - return connection.getOriginalMessageContent(messageId); - } - - @Override - public String uploadImage(String url) throws IOException { - return connection.uploadImage(url); - } - - @Override - public String uploadImage(byte[] data) throws IOException { - return connection.uploadImage(data); - } - - @Override - public void sendMessage(int roomId, PostMessage message) throws IOException { - var room = connection.getRoom(roomId); - if (room != null) { - sendMessage(room, message); - } - } - - private void sendMessage(IRoom room, String message) throws IOException { - sendMessage(room, new PostMessage(message)); - } - - private void sendMessage(IRoom room, PostMessage message) throws IOException { - final String filteredMessage; - if (message.bypassFilters()) { - filteredMessage = message.message(); - } else { - var messageText = message.message(); - for (var filter : responseFilters) { - if (filter.isEnabled(room.getRoomId())) { - messageText = filter.filter(messageText); - } - } - filteredMessage = messageText; - } - - logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); - - synchronized (postedMessages) { - var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); - var condensedMessage = message.condensedMessage(); - var ephemeral = message.ephemeral(); - - var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); - postedMessages.put(messageIds.get(0), postedMessage); - } - } - - @Override - public void join(int roomId) throws IOException { - joinRoom(roomId); - } - - /** - * Joins a room. - * @param roomId the room ID - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { - return joinRoom(roomId, false); - } - - /** - * Joins a room. - * @param roomId the room ID - * @param quiet true to not post an announcement message, false to post one - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { - var room = connection.getRoom(roomId); - if (room != null) { - return room; - } - - logger.atInfo().log(() -> "Joining room " + roomId + "..."); - - room = connection.joinRoom(roomId); - - room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - - if (!quiet && config.getGreeting() != null) { - try { - sendMessage(room, config.getGreeting()); - } catch (RoomPermissionException e) { - logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); - } - } - - rooms.add(roomId); - - for (var task : inactivityTasks) { - var nextRun = task.getInactivityTime(room, this); - if (nextRun == null) { - continue; - } - - scheduleTask(task, room, nextRun); - } - - return room; - } - - @Override - public void leave(int roomId) throws IOException { - logger.atInfo().log(() -> "Leaving room " + roomId + "..."); - - inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); - timeOfLastMessageByRoom.remove(roomId); - rooms.remove(roomId); - - var room = connection.getRoom(roomId); - if (room != null) { - room.leave(); - } - } - - @Override - public String getUsername() { - return config.getUserName(); - } - - @Override - public Integer getUserId() { - return config.getUserId(); - } - - @Override - public List getAdminUsers() { - return security.getAdmins(); - } - - private boolean isAdminUser(Integer userId) { - return security.isAdmin(userId); - } - - @Override - public boolean isRoomOwner(int roomId, int userId) throws IOException { - var userInfo = connection.getUserInfo(roomId, userId); - return (userInfo == null) ? false : userInfo.isOwner(); - } - - @Override - public String getTrigger() { - return config.getTrigger(); - } - - @Override - public List getRooms() { - return rooms.getRooms(); - } - - @Override - public IRoom getRoom(int roomId) { - return connection.getRoom(roomId); - } - - @Override - public List getHomeRooms() { - return rooms.getHomeRooms(); - } - - @Override - public List getQuietRooms() { - return rooms.getQuietRooms(); - } - - @Override - public Integer getMaxRooms() { - return maxRooms; - } - - @Override - public void broadcastMessage(PostMessage message) throws IOException { - for (var room : connection.getRooms()) { - if (!rooms.isQuietRoom(room.getRoomId())) { - sendMessage(room, message); - } - } - } - - @Override - public synchronized void timeout(Duration duration) { - if (timeout) { - timeoutTask.cancel(); - } else { - timeout = true; - } - - timeoutTask = new TimerTask() { - @Override - public void run() { - timeout = false; - } - }; - - timer.schedule(timeoutTask, duration.toMillis()); - } - - @Override - public synchronized void cancelTimeout() { - timeout = false; - if (timeoutTask != null) { - timeoutTask.cancel(); - } - } - - /** - * Sends a signal to immediately stop processing tasks. The bot thread will - * stop running once it is done processing the current task. - */ - public void stop() { - choreQueue.add(new StopChore()); - } - - /** - * Sends a signal to finish processing the tasks in the queue, and then - * terminate. - */ - public void finish() { - choreQueue.add(new FinishChore()); - } - - private TimerTask scheduleChore(long delay, Chore chore) { - var timerTask = new TimerTask() { - @Override - public void run() { - choreQueue.add(chore); - } - }; - timer.schedule(timerTask, delay); - - return timerTask; - } - - private TimerTask scheduleChore(Duration delay, Chore chore) { - return scheduleChore(delay.toMillis(), chore); - } - - /** - * Represents a message that was posted to the chat room. - * @author Michael Angstadt - */ - private static class PostedMessage { - private final Instant timePosted; - private final String originalContent; - private final String condensedContent; - private final boolean ephemeral; - private final int roomId; - private final long parentId; - private final List messageIds; - - /** - * @param timePosted the time the message was posted - * @param originalContent the original message that the bot sent to the - * chat room - * @param condensedContent the text that the message should be changed - * to after the amount of time specified in the "hideOneboxesAfter" - * setting - * @param ephemeral true to delete the message after the amount of time - * specified in the "hideOneboxesAfter" setting, false not to - * @param roomId the ID of the room the message was posted in - * @param parentId the ID of the message that this was a reply to - * @param messageIds the ID of each message that was actually posted to - * the room (the chat client may split up the original message due to - * length limitations) - */ - public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { - this.timePosted = timePosted; - this.originalContent = originalContent; - this.condensedContent = condensedContent; - this.ephemeral = ephemeral; - this.roomId = roomId; - this.parentId = parentId; - this.messageIds = messageIds; - } - - /** - * Gets the time the message was posted. - * @return the time the message was posted - */ - public Instant getTimePosted() { - return timePosted; - } - - /** - * Gets the content of the original message that the bot sent to the - * chat room. This is used for when a message was converted to a onebox. - * @return the original content - */ - public String getOriginalContent() { - return originalContent; - } - - /** - * Gets the text that the message should be changed to after the amount - * of time specified in the "hideOneboxesAfter" setting. - * @return the new content or null to leave the message alone - */ - public String getCondensedContent() { - return condensedContent; - } - - /** - * Gets the ID of each message that was actually posted to the room. The - * chat client may split up the original message due to length - * limitations. - * @return the message IDs - */ - public List getMessageIds() { - return messageIds; - } - - /** - * Gets the ID of the room the message was posted in. - * @return the room ID - */ - public int getRoomId() { - return roomId; - } - - /** - * Determines if the message has requested that it be condensed or - * deleted after the amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * @return true to condense or delete the message, false to leave it - * alone - */ - public boolean isCondensableOrEphemeral() { - return condensedContent != null || isEphemeral(); - } - - /** - * Determines if the message has requested that it be deleted after the - * amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * @return true to delete the message, false not to - */ - public boolean isEphemeral() { - return ephemeral; - } - - /** - * Gets the ID of the message that this was a reply to. - * @return the parent ID or 0 if it's not a reply - */ - public long getParentId() { - return parentId; - } - } - - private abstract class Chore implements Comparable { - private final long choreId; - - public Chore() { - choreId = choreIdCounter.getAndIncrement(); - } - - public abstract void complete(); - - @Override - public int compareTo(Chore that) { - /* - * The "lowest" value will be popped off the queue first. - */ - - if (isBothStopChore(that)) { - return 0; - } - if (isThisStopChore()) { - return -1; - } - if (isThatStopChore(that)) { - return 1; - } - - if (isBothCondenseMessageChore(that)) { - return Long.compare(this.choreId, that.choreId); - } - if (isThisCondenseMessageChore()) { - return -1; - } - 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 { - @Override - public void complete() { - //empty - } - } - - private class FinishChore extends Chore { - @Override - public void complete() { - //empty - } - } - - private class ChatEventChore extends Chore { - private final Event event; - - public ChatEventChore(Event event) { - this.event = event; - } - - @Override - public void complete() { - if (event instanceof MessagePostedEvent mpe) { - handleMessage(mpe.getMessage()); - return; - } - - if (event instanceof MessageEditedEvent mee) { - handleMessage(mee.getMessage()); - return; - } - - if (event instanceof InvitationEvent ie) { - var roomId = ie.getRoomId(); - var userId = ie.getUserId(); - var inviterIsAdmin = isAdminUser(userId); - - boolean acceptInvitation; - if (inviterIsAdmin) { - acceptInvitation = true; - } else { - try { - acceptInvitation = isRoomOwner(roomId, userId); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); - acceptInvitation = false; - } - } - - if (acceptInvitation) { - handleInvitation(ie); - } - - return; - } - - logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); - } - - private void handleMessage(ChatMessage message) { - var userId = message.getUserId(); - var isAdminUser = isAdminUser(userId); - var isBotInTimeout = timeout && !isAdminUser; - - if (isBotInTimeout) { - //bot is in timeout, ignore - return; - } - - var messageWasDeleted = message.getContent() == null; - if (messageWasDeleted) { - //user deleted their message, ignore - return; - } - - 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; - } - - var userIsBanned = security.isBanned(userId); - if (userIsBanned) { - //message was posted by a banned user, ignore - return; - } - - var room = connection.getRoom(message.getRoomId()); - if (room == null) { - //the bot is no longer in the room - return; - } - - if (message.getUserId() == userId) { - //message was posted by this bot - handleBotMessage(message); - return; - } - - message = convertFromBotlerRelayMessage(message); - - timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); - - var actions = handleListeners(message); - handleActions(message, actions); - } - - private void handleBotMessage(ChatMessage message) { - PostedMessage postedMessage; - synchronized (postedMessages) { - postedMessage = postedMessages.remove(message.getMessageId()); - } - - /* - * Check to see if the message should be edited for brevity - * after a short time so it doesn't spam the chat history. - * - * This could happen if (1) the bot posted something that Stack - * Overflow Chat converted to a onebox (e.g. an image) or (2) - * the message itself has asked to be edited (e.g. a javadoc - * description). - * - * ===What is a onebox?=== - * - * Stack Overflow Chat converts certain URLs to "oneboxes". - * Oneboxes can be fairly large and can spam the chat. For - * example, if the message is a URL to an image, the image - * itself will be displayed in the chat room. This is nice, but - * gets annoying if the image is large or if it's an animated - * GIF. - * - * After giving people some time to see the onebox, the bot will - * edit the message so that the onebox no longer displays, but - * the URL is still preserved. - */ - var messageIsOnebox = message.getContent().isOnebox(); - if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { - var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); - var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); - - logger.atInfo().log(() -> { - var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; - return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); - }); - - scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); - } - } - - private ChatActions handleListeners(ChatMessage message) { - var actions = new ChatActions(); - for (var listener : listeners) { - try { - actions.addAll(listener.onMessage(message, Bot.this)); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running listener."); - } - } - return actions; - } - - private void handleActions(ChatMessage message, ChatActions actions) { - if (actions.isEmpty()) { - return; - } - - logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); - - if (stats != null) { - stats.incMessagesRespondedTo(); - } - - var queue = new LinkedList<>(actions.getActions()); - while (!queue.isEmpty()) { - var action = queue.removeFirst(); - processAction(action, message, queue); - } - } - - 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(this, message); - var response = action.execute(context); - queue.addAll(response.getActions()); - } - - private void handlePostMessageAction(PostMessage action, ChatMessage message) { - try { - if (action.delay() != null) { - scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); - } else { - if (action.broadcast()) { - broadcastMessage(action); - } else { - sendMessage(message.getRoomId(), action); - } - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); - } - } - - private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { - try { - var room = connection.getRoom(message.getRoomId()); - room.deleteMessage(action.messageId()); - return action.onSuccess().get(); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); - return action.onError().apply(e); - } - } - - private ChatActions handleJoinRoomAction(JoinRoom action) { - if (maxRooms != null && connection.getRooms().size() >= maxRooms) { - return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); - } - - try { - var joinedRoom = joinRoom(action.roomId()); - if (joinedRoom.canPost()) { - return action.onSuccess().get(); - } - - 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) { - 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(); - } catch (Exception e) { - return action.onError().apply(e); - } - } - - /** - * Attempts to leave a room and logs any errors that occur. - * @param roomId the room ID to leave + } + + @Override + public String getOriginalMessageContent(long messageId) throws IOException { + return connection.getOriginalMessageContent(messageId); + } + + @Override + public String uploadImage(String url) throws IOException { + return connection.uploadImage(url); + } + + @Override + public String uploadImage(byte[] data) throws IOException { + return connection.uploadImage(data); + } + + @Override + public void sendMessage(int roomId, PostMessage message) throws IOException { + var room = connection.getRoom(roomId); + if (room != null) { + sendMessage(room, message); + } + } + + private void sendMessage(IRoom room, String message) throws IOException { + sendMessage(room, new PostMessage(message)); + } + + private void sendMessage(IRoom room, PostMessage message) throws IOException { + final String filteredMessage; + if (message.bypassFilters()) { + filteredMessage = message.message(); + } else { + var messageText = message.message(); + for (var filter : responseFilters) { + if (filter.isEnabled(room.getRoomId())) { + messageText = filter.filter(messageText); + } + } + filteredMessage = messageText; + } + + logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); + + synchronized (postedMessages) { + var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); + var condensedMessage = message.condensedMessage(); + var ephemeral = message.ephemeral(); + + var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); + postedMessages.put(messageIds.get(0), postedMessage); + } + } + + @Override + public void join(int roomId) throws IOException { + joinRoom(roomId); + } + + /** + * Joins a room. + * + * @param roomId the room ID + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { + return joinRoom(roomId, false); + } + + /** + * Joins a room. + * + * @param roomId the room ID + * @param quiet true to not post an announcement message, false to post one + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { + var room = connection.getRoom(roomId); + if (room != null) { + return room; + } + + logger.atInfo().log(() -> "Joining room " + roomId + "..."); + + room = connection.joinRoom(roomId); + + room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + + if (!quiet && config.getGreeting() != null) { + try { + sendMessage(room, config.getGreeting()); + } catch (RoomPermissionException e) { + logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); + } + } + + rooms.add(roomId); + + for (var task : inactivityTasks) { + var nextRun = task.getInactivityTime(room, this); + if (nextRun == null) { + continue; + } + + scheduleTask(task, room, nextRun); + } + + return room; + } + + @Override + public void leave(int roomId) throws IOException { + logger.atInfo().log(() -> "Leaving room " + roomId + "..."); + + inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); + timeOfLastMessageByRoom.remove(roomId); + rooms.remove(roomId); + + var room = connection.getRoom(roomId); + if (room != null) { + room.leave(); + } + } + + @Override + public String getUsername() { + return config.getUserName(); + } + + @Override + public Integer getUserId() { + return config.getUserId(); + } + + @Override + public List getAdminUsers() { + return security.getAdmins(); + } + + private boolean isAdminUser(Integer userId) { + return security.isAdmin(userId); + } + + @Override + public boolean isRoomOwner(int roomId, int userId) throws IOException { + var userInfo = connection.getUserInfo(roomId, userId); + return (userInfo == null) ? false : userInfo.isOwner(); + } + + @Override + public String getTrigger() { + return config.getTrigger(); + } + + @Override + public List getRooms() { + return rooms.getRooms(); + } + + @Override + public IRoom getRoom(int roomId) { + return connection.getRoom(roomId); + } + + @Override + public List getHomeRooms() { + return rooms.getHomeRooms(); + } + + @Override + public List getQuietRooms() { + return rooms.getQuietRooms(); + } + + @Override + public Integer getMaxRooms() { + return maxRooms; + } + + @Override + public void broadcastMessage(PostMessage message) throws IOException { + for (var room : connection.getRooms()) { + if (!rooms.isQuietRoom(room.getRoomId())) { + sendMessage(room, message); + } + } + } + + @Override + public synchronized void timeout(Duration duration) { + if (timeout) { + timeoutTask.cancel(); + } else { + timeout = true; + } + + timeoutTask = new TimerTask() { + @Override + public void run() { + timeout = false; + } + }; + + timer.schedule(timeoutTask, duration.toMillis()); + } + + @Override + public synchronized void cancelTimeout() { + timeout = false; + if (timeoutTask != null) { + timeoutTask.cancel(); + } + } + + /** + * Sends a signal to immediately stop processing tasks. The bot thread will + * stop running once it is done processing the current task. + */ + public void stop() { + choreQueue.add(new StopChore()); + } + + /** + * Sends a signal to finish processing the tasks in the queue, and then + * terminate. + */ + public void finish() { + choreQueue.add(new FinishChore()); + } + + private TimerTask scheduleChore(long delay, Chore chore) { + var timerTask = new TimerTask() { + @Override + public void run() { + choreQueue.add(chore); + } + }; + timer.schedule(timerTask, delay); + + return timerTask; + } + + private TimerTask scheduleChore(Duration delay, Chore chore) { + return scheduleChore(delay.toMillis(), chore); + } + + /** + * Represents a message that was posted to the chat room. + * + * @author Michael Angstadt + */ + private static class PostedMessage { + private final Instant timePosted; + private final String originalContent; + private final String condensedContent; + private final boolean ephemeral; + private final int roomId; + private final long parentId; + private final List messageIds; + + /** + * @param timePosted the time the message was posted + * @param originalContent the original message that the bot sent to the + * chat room + * @param condensedContent the text that the message should be changed + * to after the amount of time specified in the "hideOneboxesAfter" + * setting + * @param ephemeral true to delete the message after the amount of time + * specified in the "hideOneboxesAfter" setting, false not to + * @param roomId the ID of the room the message was posted in + * @param parentId the ID of the message that this was a reply to + * @param messageIds the ID of each message that was actually posted to + * the room (the chat client may split up the original message due to + * length limitations) + */ + public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { + this.timePosted = timePosted; + this.originalContent = originalContent; + this.condensedContent = condensedContent; + this.ephemeral = ephemeral; + this.roomId = roomId; + this.parentId = parentId; + this.messageIds = messageIds; + } + + /** + * Gets the time the message was posted. + * + * @return the time the message was posted + */ + public Instant getTimePosted() { + return timePosted; + } + + /** + * Gets the content of the original message that the bot sent to the + * chat room. This is used for when a message was converted to a onebox. + * + * @return the original content + */ + public String getOriginalContent() { + return originalContent; + } + + /** + * Gets the text that the message should be changed to after the amount + * of time specified in the "hideOneboxesAfter" setting. + * + * @return the new content or null to leave the message alone + */ + public String getCondensedContent() { + return condensedContent; + } + + /** + * Gets the ID of each message that was actually posted to the room. The + * chat client may split up the original message due to length + * limitations. + * + * @return the message IDs + */ + public List getMessageIds() { + return messageIds; + } + + /** + * Gets the ID of the room the message was posted in. + * + * @return the room ID + */ + public int getRoomId() { + return roomId; + } + + /** + * Determines if the message has requested that it be condensed or + * deleted after the amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * + * @return true to condense or delete the message, false to leave it + * alone + */ + public boolean isCondensableOrEphemeral() { + return condensedContent != null || isEphemeral(); + } + + /** + * Determines if the message has requested that it be deleted after the + * amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * + * @return true to delete the message, false not to + */ + public boolean isEphemeral() { + return ephemeral; + } + + /** + * Gets the ID of the message that this was a reply to. + * + * @return the parent ID or 0 if it's not a reply + */ + public long getParentId() { + return parentId; + } + } + + private abstract class Chore implements Comparable { + private final long choreId; + + public Chore() { + choreId = choreIdCounter.getAndIncrement(); + } + + public abstract void complete(); + + @Override + public int compareTo(Chore that) { + /* + * The "lowest" value will be popped off the queue first. + */ + + if (isBothStopChore(that)) { + return 0; + } + if (isThisStopChore()) { + return -1; + } + if (isThatStopChore(that)) { + return 1; + } + + if (isBothCondenseMessageChore(that)) { + return Long.compare(this.choreId, that.choreId); + } + if (isThisCondenseMessageChore()) { + return -1; + } + 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 { + @Override + public void complete() { + //empty + } + } + + private class FinishChore extends Chore { + @Override + public void complete() { + //empty + } + } + + private class ChatEventChore extends Chore { + private final Event event; + + public ChatEventChore(Event event) { + this.event = event; + } + + @Override + public void complete() { + if (event instanceof MessagePostedEvent mpe) { + handleMessage(mpe.getMessage()); + return; + } + + if (event instanceof MessageEditedEvent mee) { + handleMessage(mee.getMessage()); + return; + } + + if (event instanceof InvitationEvent ie) { + var roomId = ie.getRoomId(); + var userId = ie.getUserId(); + var inviterIsAdmin = isAdminUser(userId); + + boolean acceptInvitation; + if (inviterIsAdmin) { + acceptInvitation = true; + } else { + try { + acceptInvitation = isRoomOwner(roomId, userId); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); + acceptInvitation = false; + } + } + + if (acceptInvitation) { + handleInvitation(ie); + } + + return; + } + + logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); + } + + private void handleMessage(ChatMessage message) { + var userId = message.getUserId(); + var isAdminUser = isAdminUser(userId); + var isBotInTimeout = timeout && !isAdminUser; + + if (isBotInTimeout) { + //bot is in timeout, ignore + return; + } + + var messageWasDeleted = message.getContent() == null; + if (messageWasDeleted) { + //user deleted their message, ignore + return; + } + + 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; + } + + var userIsBanned = security.isBanned(userId); + if (userIsBanned) { + //message was posted by a banned user, ignore + return; + } + + var room = connection.getRoom(message.getRoomId()); + if (room == null) { + //the bot is no longer in the room + return; + } + + if (message.getUserId() == userId) { + //message was posted by this bot + handleBotMessage(message); + return; + } + + message = convertFromBotlerRelayMessage(message); + + timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); + + var actions = handleListeners(message); + handleActions(message, actions); + } + + private void handleBotMessage(ChatMessage message) { + PostedMessage postedMessage; + synchronized (postedMessages) { + postedMessage = postedMessages.remove(message.getMessageId()); + } + + /* + * Check to see if the message should be edited for brevity + * after a short time so it doesn't spam the chat history. + * + * This could happen if (1) the bot posted something that Stack + * Overflow Chat converted to a onebox (e.g. an image) or (2) + * the message itself has asked to be edited (e.g. a javadoc + * description). + * + * ===What is a onebox?=== + * + * Stack Overflow Chat converts certain URLs to "oneboxes". + * Oneboxes can be fairly large and can spam the chat. For + * example, if the message is a URL to an image, the image + * itself will be displayed in the chat room. This is nice, but + * gets annoying if the image is large or if it's an animated + * GIF. + * + * After giving people some time to see the onebox, the bot will + * edit the message so that the onebox no longer displays, but + * the URL is still preserved. + */ + var messageIsOnebox = message.getContent().isOnebox(); + if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { + var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); + var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); + + logger.atInfo().log(() -> { + var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; + return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); + }); + + scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); + } + } + + private ChatActions handleListeners(ChatMessage message) { + var actions = new ChatActions(); + for (var listener : listeners) { + try { + actions.addAll(listener.onMessage(message, Bot.this)); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem running listener."); + } + } + return actions; + } + + private void handleActions(ChatMessage message, ChatActions actions) { + if (actions.isEmpty()) { + return; + } + + logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); + + if (stats != null) { + stats.incMessagesRespondedTo(); + } + + var queue = new LinkedList<>(actions.getActions()); + while (!queue.isEmpty()) { + var action = queue.removeFirst(); + processAction(action, message, queue); + } + } + + 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) { + try { + if (action.delay() != null) { + scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); + } else { + if (action.broadcast()) { + broadcastMessage(action); + } else { + sendMessage(message.getRoomId(), action); + } + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); + } + } + + private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { + try { + var room = connection.getRoom(message.getRoomId()); + room.deleteMessage(action.messageId()); + return action.onSuccess().get(); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); + return action.onError().apply(e); + } + } + + private ChatActions handleJoinRoomAction(JoinRoom action) { + if (maxRooms != null && connection.getRooms().size() >= maxRooms) { + return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); + } + + try { + var joinedRoom = joinRoom(action.roomId()); + if (joinedRoom.canPost()) { + return action.onSuccess().get(); + } + + 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) { + 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(); + } catch (Exception e) { + return action.onError().apply(e); + } + } + + /** + * 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()); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); - } - } - - private void handleInvitation(InvitationEvent event) { - /* - * If the bot is currently connected to multiple rooms, the - * invitation event will be sent to each room and this method will - * be called multiple times. Check to see if the bot has already - * joined the room it was invited to. - */ - var roomId = event.getRoomId(); - if (connection.isInRoom(roomId)) { - return; - } - - /* - * Ignore the invitation if the bot is connected to the maximum - * number of rooms allowed. We can't really post an error message - * because the invitation event is not linked to a specific chat - * room. - */ - var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); - if (maxRoomsExceeded) { - return; - } - - try { - joinRoom(roomId); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); - } - } - } - - private class CondenseMessageChore extends Chore { - private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); - private final PostedMessage postedMessage; - - public CondenseMessageChore(PostedMessage postedMessage) { - this.postedMessage = postedMessage; - } - - @Override - public void complete() { - var roomId = postedMessage.getRoomId(); - var room = connection.getRoom(roomId); - - var botIsNoLongerInTheRoom = (room == null); - if (botIsNoLongerInTheRoom) { - return; - } - - try { - List messagesToDelete; - if (postedMessage.isEphemeral()) { - messagesToDelete = postedMessage.getMessageIds(); - } else { - var condensedContent = postedMessage.getCondensedContent(); - var isAOneBox = (condensedContent == null); - if (isAOneBox) { - condensedContent = postedMessage.getOriginalContent(); - } - - var messageIds = postedMessage.getMessageIds(); - var quotedContent = quote(condensedContent); - room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); - - /* - * If the original content was split up into - * multiple messages due to length constraints, - * delete the additional messages. - */ - messagesToDelete = messageIds.subList(1, messageIds.size()); - } - - for (var id : messagesToDelete) { - room.deleteMessage(id); - } - } catch (Exception e) { + leave(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(logMessage); + } + } + + private void handleLeaveRoomAction(LeaveRoom action) { + try { + leave(action.roomId()); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); + } + } + + private void handleInvitation(InvitationEvent event) { + /* + * If the bot is currently connected to multiple rooms, the + * invitation event will be sent to each room and this method will + * be called multiple times. Check to see if the bot has already + * joined the room it was invited to. + */ + var roomId = event.getRoomId(); + if (connection.isInRoom(roomId)) { + return; + } + + /* + * Ignore the invitation if the bot is connected to the maximum + * number of rooms allowed. We can't really post an error message + * because the invitation event is not linked to a specific chat + * room. + */ + var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); + if (maxRoomsExceeded) { + return; + } + + try { + joinRoom(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); + } + } + } + + private class CondenseMessageChore extends Chore { + private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); + private final PostedMessage postedMessage; + + public CondenseMessageChore(PostedMessage postedMessage) { + this.postedMessage = postedMessage; + } + + @Override + public void complete() { + var roomId = postedMessage.getRoomId(); + var room = connection.getRoom(roomId); + + var botIsNoLongerInTheRoom = (room == null); + if (botIsNoLongerInTheRoom) { + return; + } + + try { + List messagesToDelete; + if (postedMessage.isEphemeral()) { + messagesToDelete = postedMessage.getMessageIds(); + } else { + var condensedContent = postedMessage.getCondensedContent(); + var isAOneBox = (condensedContent == null); + if (isAOneBox) { + condensedContent = postedMessage.getOriginalContent(); + } + + var messageIds = postedMessage.getMessageIds(); + var quotedContent = quote(condensedContent); + room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); + + /* + * If the original content was split up into + * multiple messages due to length constraints, + * delete the additional messages. + */ + messagesToDelete = messageIds.subList(1, messageIds.size()); + } + + for (var id : messagesToDelete) { + room.deleteMessage(id); + } + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]"); - } - } + } + } - @SuppressWarnings("deprecation") - private String quote(String content) { - var cb = new ChatBuilder(); + @SuppressWarnings("deprecation") + private String quote(String content) { + var cb = new ChatBuilder(); - var m = replyRegex.matcher(content); - if (m.find()) { - var id = Long.parseLong(m.group(1)); - content = m.group(2); + var m = replyRegex.matcher(content); + if (m.find()) { + var id = Long.parseLong(m.group(1)); + content = m.group(2); - cb.reply(id); - } + cb.reply(id); + } - return cb.quote(content).toString(); - } - } + return cb.quote(content).toString(); + } + } - private class ScheduledTaskChore extends Chore { - private final ScheduledTask task; + private class ScheduledTaskChore extends Chore { + private final ScheduledTask task; - public ScheduledTaskChore(ScheduledTask task) { - this.task = task; - } + public ScheduledTaskChore(ScheduledTask task) { + this.task = task; + } - @Override - public void complete() { - try { - task.run(Bot.this); - } catch (Exception e) { + @Override + public void complete() { + try { + task.run(Bot.this); + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem running scheduled task."); - } - scheduleTask(task); - } - } - - private class InactivityTaskChore extends Chore { - private final InactivityTask task; - private final IRoom room; - - public InactivityTaskChore(InactivityTask task, IRoom room) { - this.task = task; - this.room = room; - } - - @Override - public void complete() { - try { - if (!connection.isInRoom(room.getRoomId())) { - return; - } - - var inactivityTime = task.getInactivityTime(room, Bot.this); - if (inactivityTime == null) { - return; - } - - var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); - var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); - var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); - if (runNow) { - try { - task.run(room, Bot.this); - } catch (Exception e) { + } + scheduleTask(task); + } + } + + private class InactivityTaskChore extends Chore { + private final InactivityTask task; + private final IRoom room; + + public InactivityTaskChore(InactivityTask task, IRoom room) { + this.task = task; + this.room = room; + } + + @Override + public void complete() { + try { + if (!connection.isInRoom(room.getRoomId())) { + return; + } + + var inactivityTime = task.getInactivityTime(room, Bot.this); + if (inactivityTime == null) { + return; + } + + var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); + var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); + var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); + if (runNow) { + try { + task.run(room, Bot.this); + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem running inactivity task in room " + room.getRoomId() + "."); - } - } - - var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); - scheduleTask(task, room, nextCheck); - } finally { - inactivityTimerTasksByRoom.remove(room, this); - } - } - } - - private class DelayedMessageChore extends Chore { - private final int roomId; - private final PostMessage message; - - public DelayedMessageChore(int roomId, PostMessage message) { - this.roomId = roomId; - this.message = message; - } - - @Override - public void complete() { - try { - if (message.broadcast()) { - broadcastMessage(message); - } else { - sendMessage(roomId, message); - } - } catch (Exception e) { + } + } + + var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); + scheduleTask(task, room, nextCheck); + } finally { + inactivityTimerTasksByRoom.remove(room, this); + } + } + } + + private class DelayedMessageChore extends Chore { + private final int roomId; + private final PostMessage message; + + public DelayedMessageChore(int roomId, PostMessage message) { + this.roomId = roomId; + this.message = message; + } + + @Override + public void complete() { + try { + if (message.broadcast()) { + broadcastMessage(message); + } else { + sendMessage(roomId, message); + } + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message()); - } - } - } - - /** - * Alters the username and content of a message if the message is a Botler - * Discord relay message. Otherwise, returns the message unaltered. - * @param message the original message - * @return the altered message or the same message if it's not a relay - * message - * @see example - */ - private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { - if (message.getUserId() != BOTLER_ID) { - return message; - } - - var content = message.getContent(); - if (content == null) { - return message; - } - - //Example message content: - //[realmichael] test - var html = content.getContent(); - var dom = Jsoup.parse(html); - var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); - if (element == null) { - return message; - } - var discordUsername = element.text(); - - var endBracket = html.indexOf(']'); - if (endBracket < 0) { - return message; - } - var discordMessage = html.substring(endBracket + 1).trim(); - - //@formatter:off + } + } + } + + /** + * Alters the username and content of a message if the message is a Botler + * Discord relay message. Otherwise, returns the message unaltered. + * + * @param message the original message + * @return the altered message or the same message if it's not a relay + * message + * @see example + */ + private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { + if (message.getUserId() != BOTLER_ID) { + return message; + } + + var content = message.getContent(); + if (content == null) { + return message; + } + + //Example message content: + //[realmichael] test + var html = content.getContent(); + var dom = Jsoup.parse(html); + var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); + if (element == null) { + return message; + } + var discordUsername = element.text(); + + var endBracket = html.indexOf(']'); + if (endBracket < 0) { + return message; + } + var discordMessage = html.substring(endBracket + 1).trim(); + + //@formatter:off return new ChatMessage.Builder(message) .username(discordUsername) .content(discordMessage) .build(); //@formatter:on - } - - /** - * Builds {@link Bot} instances. - * @author Michael Angstadt - */ - public static class Builder { - private IChatClient connection; - private String userName; - private String trigger = "="; - private String greeting; - private Integer userId; - private Duration hideOneboxesAfter; - private Integer maxRooms; - private List roomsHome = List.of(1); - private List roomsQuiet = List.of(); - private List admins = List.of(); - private List bannedUsers = List.of(); - private List allowedUsers = List.of(); - private List listeners = List.of(); - private List tasks = List.of(); - private List inactivityTasks = List.of(); - private List responseFilters = List.of(); - private Statistics stats; - private Database database; - - public Builder connection(IChatClient connection) { - this.connection = connection; - return this; - } - - public Builder user(String userName, Integer userId) { - this.userName = (userName == null || userName.isEmpty()) ? null : userName; - this.userId = userId; - return this; - } - - public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { - this.hideOneboxesAfter = hideOneboxesAfter; - return this; - } - - public Builder trigger(String trigger) { - this.trigger = trigger; - return this; - } - - public Builder greeting(String greeting) { - this.greeting = greeting; - return this; - } - - public Builder roomsHome(Integer... roomIds) { - roomsHome = List.of(roomIds); - return this; - } - - public Builder roomsHome(Collection roomIds) { - roomsHome = List.copyOf(roomIds); - return this; - } - - public Builder roomsQuiet(Integer... roomIds) { - roomsQuiet = List.of(roomIds); - return this; - } - - public Builder roomsQuiet(Collection roomIds) { - roomsQuiet = List.copyOf(roomIds); - return this; - } - - public Builder maxRooms(Integer maxRooms) { - this.maxRooms = maxRooms; - return this; - } - - public Builder admins(Integer... admins) { - this.admins = List.of(admins); - return this; - } - - public Builder admins(Collection admins) { - this.admins = List.copyOf(admins); - return this; - } - - public Builder bannedUsers(Integer... bannedUsers) { - this.bannedUsers = List.of(bannedUsers); - return this; - } - - public Builder bannedUsers(Collection bannedUsers) { - this.bannedUsers = List.copyOf(bannedUsers); - return this; - } - - public Builder allowedUsers(Integer... allowedUsers) { - this.allowedUsers = List.of(allowedUsers); - return this; - } - - public Builder allowedUsers(Collection allowedUsers) { - this.allowedUsers = List.copyOf(allowedUsers); - return this; - } - - public Builder listeners(Listener... listeners) { - this.listeners = List.of(listeners); - return this; - } - - public Builder listeners(Collection listeners) { - this.listeners = List.copyOf(listeners); - return this; - } - - public Builder tasks(ScheduledTask... tasks) { - this.tasks = List.of(tasks); - return this; - } - - public Builder tasks(Collection tasks) { - this.tasks = List.copyOf(tasks); - return this; - } - - public Builder inactivityTasks(InactivityTask... tasks) { - inactivityTasks = List.of(tasks); - return this; - } - - public Builder inactivityTasks(Collection tasks) { - inactivityTasks = List.copyOf(tasks); - return this; - } - - public Builder responseFilters(ChatResponseFilter... filters) { - responseFilters = List.of(filters); - return this; - } - - public Builder responseFilters(Collection filters) { - responseFilters = List.copyOf(filters); - return this; - } - - public Builder stats(Statistics stats) { - this.stats = stats; - return this; - } - - public Builder database(Database database) { - this.database = database; - return this; - } - - public Bot build() { - if (connection == null) { - throw new IllegalStateException("No ChatConnection given."); - } - - if (connection.getUsername() == null && this.userName == null) { - throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - if (connection.getUserId() == null && this.userId == null) { - throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - return new Bot(this); - } - } + } + + /** + * Builds {@link Bot} instances. + * + * @author Michael Angstadt + */ + public static class Builder { + private IChatClient connection; + private String userName; + private String trigger = "="; + private String greeting; + private Integer userId; + private Duration hideOneboxesAfter; + private Integer maxRooms; + private List roomsHome = List.of(1); + private List roomsQuiet = List.of(); + private List admins = List.of(); + private List bannedUsers = List.of(); + private List allowedUsers = List.of(); + private List listeners = List.of(); + private List tasks = List.of(); + private List inactivityTasks = List.of(); + private List responseFilters = List.of(); + private Statistics stats; + private Database database; + + public Builder connection(IChatClient connection) { + this.connection = connection; + return this; + } + + public Builder user(String userName, Integer userId) { + this.userName = (userName == null || userName.isEmpty()) ? null : userName; + this.userId = userId; + return this; + } + + public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { + this.hideOneboxesAfter = hideOneboxesAfter; + return this; + } + + public Builder trigger(String trigger) { + this.trigger = trigger; + return this; + } + + public Builder greeting(String greeting) { + this.greeting = greeting; + return this; + } + + public Builder roomsHome(Integer... roomIds) { + roomsHome = List.of(roomIds); + return this; + } + + public Builder roomsHome(Collection roomIds) { + roomsHome = List.copyOf(roomIds); + return this; + } + + public Builder roomsQuiet(Integer... roomIds) { + roomsQuiet = List.of(roomIds); + return this; + } + + public Builder roomsQuiet(Collection roomIds) { + roomsQuiet = List.copyOf(roomIds); + return this; + } + + public Builder maxRooms(Integer maxRooms) { + this.maxRooms = maxRooms; + return this; + } + + public Builder admins(Integer... admins) { + this.admins = List.of(admins); + return this; + } + + public Builder admins(Collection admins) { + this.admins = List.copyOf(admins); + return this; + } + + public Builder bannedUsers(Integer... bannedUsers) { + this.bannedUsers = List.of(bannedUsers); + return this; + } + + public Builder bannedUsers(Collection bannedUsers) { + this.bannedUsers = List.copyOf(bannedUsers); + return this; + } + + public Builder allowedUsers(Integer... allowedUsers) { + this.allowedUsers = List.of(allowedUsers); + return this; + } + + public Builder allowedUsers(Collection allowedUsers) { + this.allowedUsers = List.copyOf(allowedUsers); + return this; + } + + public Builder listeners(Listener... listeners) { + this.listeners = List.of(listeners); + return this; + } + + public Builder listeners(Collection listeners) { + this.listeners = List.copyOf(listeners); + return this; + } + + public Builder tasks(ScheduledTask... tasks) { + this.tasks = List.of(tasks); + return this; + } + + public Builder tasks(Collection tasks) { + this.tasks = List.copyOf(tasks); + return this; + } + + public Builder inactivityTasks(InactivityTask... tasks) { + inactivityTasks = List.of(tasks); + return this; + } + + public Builder inactivityTasks(Collection tasks) { + inactivityTasks = List.copyOf(tasks); + return this; + } + + public Builder responseFilters(ChatResponseFilter... filters) { + responseFilters = List.of(filters); + return this; + } + + public Builder responseFilters(Collection filters) { + responseFilters = List.copyOf(filters); + return this; + } + + public Builder stats(Statistics stats) { + this.stats = stats; + return this; + } + + public Builder database(Database database) { + this.database = database; + return this; + } + + public Bot build() { + if (connection == null) { + throw new IllegalStateException("No ChatConnection given."); + } + + if (connection.getUsername() == null && this.userName == null) { + throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + if (connection.getUserId() == null && this.userId == null) { + throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + return new Bot(this); + } + } } From 720a71392d10a935decb97304aa94356995837d2 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Sat, 29 Nov 2025 18:49:12 -0400 Subject: [PATCH 10/17] Revert "Chore: Changes requested" This reverts commit 0aa8969a40d3cadc08e970f79212780af2eb244f. --- src/main/java/oakbot/bot/Bot.java | 2515 ++++++++++++++--------------- 1 file changed, 1256 insertions(+), 1259 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 8f3f1e2b..df2edbea 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -1,6 +1,34 @@ package oakbot.bot; -import com.github.mangstadt.sochat4j.*; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Timer; +import java.util.TimerTask; +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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.mangstadt.sochat4j.ChatMessage; +import com.github.mangstadt.sochat4j.IChatClient; +import com.github.mangstadt.sochat4j.IRoom; +import com.github.mangstadt.sochat4j.PrivateRoomException; +import com.github.mangstadt.sochat4j.RoomNotFoundException; +import com.github.mangstadt.sochat4j.RoomPermissionException; import com.github.mangstadt.sochat4j.event.Event; import com.github.mangstadt.sochat4j.event.InvitationEvent; import com.github.mangstadt.sochat4j.event.MessageEditedEvent; @@ -8,6 +36,7 @@ import com.github.mangstadt.sochat4j.util.Sleeper; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; + import oakbot.Database; import oakbot.MemoryDatabase; import oakbot.Rooms; @@ -17,1294 +46,1262 @@ import oakbot.listener.Listener; import oakbot.task.ScheduledTask; import oakbot.util.ChatBuilder; -import org.jsoup.Jsoup; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.*; -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; /** * A Stackoverflow chat bot. - * * @author Michael Angstadt */ public class Bot implements IBot { - private static final Logger logger = LoggerFactory.getLogger(Bot.class); - static final int BOTLER_ID = 13750349; - + 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 BotConfiguration config; - private final SecurityConfiguration security; - private final IChatClient connection; - private final AtomicLong choreIdCounter = new AtomicLong(); - private final BlockingQueue choreQueue = new PriorityBlockingQueue<>(); - private final Rooms rooms; - private final Integer maxRooms; - private final List listeners; - private final List responseFilters; - private final List scheduledTasks; - private final List inactivityTasks; - private final Map timeOfLastMessageByRoom = new HashMap<>(); - private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); - private final Statistics stats; - private final Database database; - private final Timer timer = new Timer(); - private TimerTask timeoutTask; - private volatile boolean timeout = false; - - /** - *

- * A collection of messages that the bot posted, but have not been "echoed" - * back yet in the chat room. When a message is echoed back, it is removed - * from this map. - *

- *

- * This is used to determine whether something the bot posted was converted - * to a onebox. It is then used to edit the message in order to hide the - * onebox. - *

- *
    - *
  • Key = The message ID.
  • - *
  • Value = The raw message content that was sent to the chat room by the - * bot (which can be different from what was echoed back).
  • - *
- */ - private final Map postedMessages = new HashMap<>(); - - private Bot(Builder builder) { - connection = Objects.requireNonNull(builder.connection); - - 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; - stats = builder.stats; - database = (builder.database == null) ? new MemoryDatabase() : builder.database; - rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); - listeners = builder.listeners; - scheduledTasks = builder.tasks; - inactivityTasks = builder.inactivityTasks; - responseFilters = builder.responseFilters; - } - - private void scheduleTask(ScheduledTask task) { - var nextRun = task.nextRun(); - if (nextRun <= 0) { - return; - } - - scheduleChore(nextRun, new ScheduledTaskChore(task)); - } - - private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { - var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); - inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); - } - - /** - * Starts the chat bot. The bot will join the rooms in the current thread - * before launching its own thread. - * @param quiet true to start the bot without broadcasting the greeting - * message, false to broadcast the greeting message - * @return the thread that the bot is running in. This thread will terminate - * when the bot terminates - * @throws IOException if there's a network problem - */ - public Thread connect(boolean quiet) throws IOException { - joinRoomsOnStart(quiet); - - var thread = new ChoreThread(); - thread.start(); - return thread; - } - - private void joinRoomsOnStart(boolean quiet) { - var first = true; - var roomsCopy = new ArrayList<>(rooms.getRooms()); - for (var room : roomsCopy) { - if (!first) { - /* - * Insert a pause between joining each room in an attempt to - * resolve an issue where the bot chooses to ignore all messages - * in certain rooms. - */ - Sleeper.sleep(ROOM_JOIN_DELAY); - } - - try { - joinRoom(room, quiet); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); - rooms.remove(room); - } - - first = false; - } - } - - private class ChoreThread extends Thread { - @Override - public void run() { - try { - scheduledTasks.forEach(Bot.this::scheduleTask); - - while (true) { - Chore chore; - try { - chore = choreQueue.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); - break; - } - - if (chore instanceof StopChore || chore instanceof FinishChore) { - break; - } - - chore.complete(); - database.commit(); - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); - } finally { - try { - connection.close(); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); - } - - database.commit(); - timer.cancel(); - } - } - } - - @Override - public List getLatestMessages(int roomId, int count) throws IOException { - var room = connection.getRoom(roomId); - var notInRoom = (room == null); - if (notInRoom) { - return List.of(); - } - - //@formatter:off + 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 Rooms rooms; + private final Integer maxRooms; + private final List listeners; + private final List responseFilters; + private final List scheduledTasks; + private final List inactivityTasks; + private final Map timeOfLastMessageByRoom = new HashMap<>(); + private final Multimap inactivityTimerTasksByRoom = ArrayListMultimap.create(); + private final Statistics stats; + private final Database database; + private final Timer timer = new Timer(); + private TimerTask timeoutTask; + private volatile boolean timeout = false; + + /** + *

+ * A collection of messages that the bot posted, but have not been "echoed" + * back yet in the chat room. When a message is echoed back, it is removed + * from this map. + *

+ *

+ * This is used to determine whether something the bot posted was converted + * to a onebox. It is then used to edit the message in order to hide the + * onebox. + *

+ *
    + *
  • Key = The message ID.
  • + *
  • Value = The raw message content that was sent to the chat room by the + * bot (which can be different from what was echoed back).
  • + *
+ */ + private final Map postedMessages = new HashMap<>(); + + private Bot(Builder builder) { + connection = Objects.requireNonNull(builder.connection); + + 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; + stats = builder.stats; + database = (builder.database == null) ? new MemoryDatabase() : builder.database; + rooms = new Rooms(database, builder.roomsHome, builder.roomsQuiet); + listeners = builder.listeners; + scheduledTasks = builder.tasks; + inactivityTasks = builder.inactivityTasks; + responseFilters = builder.responseFilters; + } + + private void scheduleTask(ScheduledTask task) { + var nextRun = task.nextRun(); + if (nextRun <= 0) { + return; + } + + scheduleChore(nextRun, new ScheduledTaskChore(task)); + } + + private void scheduleTask(InactivityTask task, IRoom room, Duration nextRun) { + var timerTask = scheduleChore(nextRun, new InactivityTaskChore(task, room)); + inactivityTimerTasksByRoom.put(room.getRoomId(), timerTask); + } + + /** + * Starts the chat bot. The bot will join the rooms in the current thread + * before launching its own thread. + * @param quiet true to start the bot without broadcasting the greeting + * message, false to broadcast the greeting message + * @return the thread that the bot is running in. This thread will terminate + * when the bot terminates + * @throws IOException if there's a network problem + */ + public Thread connect(boolean quiet) throws IOException { + joinRoomsOnStart(quiet); + + var thread = new ChoreThread(); + thread.start(); + return thread; + } + + private void joinRoomsOnStart(boolean quiet) { + var first = true; + var roomsCopy = new ArrayList<>(rooms.getRooms()); + for (var room : roomsCopy) { + if (!first) { + /* + * Insert a pause between joining each room in an attempt to + * resolve an issue where the bot chooses to ignore all messages + * in certain rooms. + */ + Sleeper.sleep(ROOM_JOIN_DELAY); + } + + try { + joinRoom(room, quiet); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Could not join room " + room + ". Removing from rooms list."); + rooms.remove(room); + } + + first = false; + } + } + + private class ChoreThread extends Thread { + @Override + public void run() { + try { + scheduledTasks.forEach(Bot.this::scheduleTask); + + while (true) { + Chore chore; + try { + chore = choreQueue.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.atError().setCause(e).log(() -> "Thread interrupted while waiting for new chores."); + break; + } + + if (chore instanceof StopChore || chore instanceof FinishChore) { + break; + } + + chore.complete(); + database.commit(); + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot terminated due to unexpected exception."); + } finally { + try { + connection.close(); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Problem closing ChatClient connection."); + } + + database.commit(); + timer.cancel(); + } + } + } + + @Override + public List getLatestMessages(int roomId, int count) throws IOException { + var room = connection.getRoom(roomId); + var notInRoom = (room == null); + if (notInRoom) { + return List.of(); + } + + //@formatter:off return room.getMessages(count).stream() .map(this::convertFromBotlerRelayMessage) .toList(); //@formatter:on - } - - @Override - public String getOriginalMessageContent(long messageId) throws IOException { - return connection.getOriginalMessageContent(messageId); - } - - @Override - public String uploadImage(String url) throws IOException { - return connection.uploadImage(url); - } - - @Override - public String uploadImage(byte[] data) throws IOException { - return connection.uploadImage(data); - } - - @Override - public void sendMessage(int roomId, PostMessage message) throws IOException { - var room = connection.getRoom(roomId); - if (room != null) { - sendMessage(room, message); - } - } - - private void sendMessage(IRoom room, String message) throws IOException { - sendMessage(room, new PostMessage(message)); - } - - private void sendMessage(IRoom room, PostMessage message) throws IOException { - final String filteredMessage; - if (message.bypassFilters()) { - filteredMessage = message.message(); - } else { - var messageText = message.message(); - for (var filter : responseFilters) { - if (filter.isEnabled(room.getRoomId())) { - messageText = filter.filter(messageText); - } - } - filteredMessage = messageText; - } - - logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); - - synchronized (postedMessages) { - var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); - var condensedMessage = message.condensedMessage(); - var ephemeral = message.ephemeral(); - - var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); - postedMessages.put(messageIds.get(0), postedMessage); - } - } - - @Override - public void join(int roomId) throws IOException { - joinRoom(roomId); - } - - /** - * Joins a room. - * - * @param roomId the room ID - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { - return joinRoom(roomId, false); - } - - /** - * Joins a room. - * - * @param roomId the room ID - * @param quiet true to not post an announcement message, false to post one - * @return the connection to the room - * @throws RoomNotFoundException if the room does not exist - * @throws PrivateRoomException if the room can't be joined because it is - * private - * @throws IOException if there's a problem connecting to the room - */ - private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { - var room = connection.getRoom(roomId); - if (room != null) { - return room; - } - - logger.atInfo().log(() -> "Joining room " + roomId + "..."); - - room = connection.joinRoom(roomId); - - room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); - - if (!quiet && config.getGreeting() != null) { - try { - sendMessage(room, config.getGreeting()); - } catch (RoomPermissionException e) { - logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); - } - } - - rooms.add(roomId); - - for (var task : inactivityTasks) { - var nextRun = task.getInactivityTime(room, this); - if (nextRun == null) { - continue; - } - - scheduleTask(task, room, nextRun); - } - - return room; - } - - @Override - public void leave(int roomId) throws IOException { - logger.atInfo().log(() -> "Leaving room " + roomId + "..."); - - inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); - timeOfLastMessageByRoom.remove(roomId); - rooms.remove(roomId); - - var room = connection.getRoom(roomId); - if (room != null) { - room.leave(); - } - } - - @Override - public String getUsername() { - return config.getUserName(); - } - - @Override - public Integer getUserId() { - return config.getUserId(); - } - - @Override - public List getAdminUsers() { - return security.getAdmins(); - } - - private boolean isAdminUser(Integer userId) { - return security.isAdmin(userId); - } - - @Override - public boolean isRoomOwner(int roomId, int userId) throws IOException { - var userInfo = connection.getUserInfo(roomId, userId); - return (userInfo == null) ? false : userInfo.isOwner(); - } - - @Override - public String getTrigger() { - return config.getTrigger(); - } - - @Override - public List getRooms() { - return rooms.getRooms(); - } - - @Override - public IRoom getRoom(int roomId) { - return connection.getRoom(roomId); - } - - @Override - public List getHomeRooms() { - return rooms.getHomeRooms(); - } - - @Override - public List getQuietRooms() { - return rooms.getQuietRooms(); - } - - @Override - public Integer getMaxRooms() { - return maxRooms; - } - - @Override - public void broadcastMessage(PostMessage message) throws IOException { - for (var room : connection.getRooms()) { - if (!rooms.isQuietRoom(room.getRoomId())) { - sendMessage(room, message); - } - } - } - - @Override - public synchronized void timeout(Duration duration) { - if (timeout) { - timeoutTask.cancel(); - } else { - timeout = true; - } - - timeoutTask = new TimerTask() { - @Override - public void run() { - timeout = false; - } - }; - - timer.schedule(timeoutTask, duration.toMillis()); - } - - @Override - public synchronized void cancelTimeout() { - timeout = false; - if (timeoutTask != null) { - timeoutTask.cancel(); - } - } - - /** - * Sends a signal to immediately stop processing tasks. The bot thread will - * stop running once it is done processing the current task. - */ - public void stop() { - choreQueue.add(new StopChore()); - } - - /** - * Sends a signal to finish processing the tasks in the queue, and then - * terminate. - */ - public void finish() { - choreQueue.add(new FinishChore()); - } - - private TimerTask scheduleChore(long delay, Chore chore) { - var timerTask = new TimerTask() { - @Override - public void run() { - choreQueue.add(chore); - } - }; - timer.schedule(timerTask, delay); - - return timerTask; - } - - private TimerTask scheduleChore(Duration delay, Chore chore) { - return scheduleChore(delay.toMillis(), chore); - } - - /** - * Represents a message that was posted to the chat room. - * - * @author Michael Angstadt - */ - private static class PostedMessage { - private final Instant timePosted; - private final String originalContent; - private final String condensedContent; - private final boolean ephemeral; - private final int roomId; - private final long parentId; - private final List messageIds; - - /** - * @param timePosted the time the message was posted - * @param originalContent the original message that the bot sent to the - * chat room - * @param condensedContent the text that the message should be changed - * to after the amount of time specified in the "hideOneboxesAfter" - * setting - * @param ephemeral true to delete the message after the amount of time - * specified in the "hideOneboxesAfter" setting, false not to - * @param roomId the ID of the room the message was posted in - * @param parentId the ID of the message that this was a reply to - * @param messageIds the ID of each message that was actually posted to - * the room (the chat client may split up the original message due to - * length limitations) - */ - public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { - this.timePosted = timePosted; - this.originalContent = originalContent; - this.condensedContent = condensedContent; - this.ephemeral = ephemeral; - this.roomId = roomId; - this.parentId = parentId; - this.messageIds = messageIds; - } - - /** - * Gets the time the message was posted. - * - * @return the time the message was posted - */ - public Instant getTimePosted() { - return timePosted; - } - - /** - * Gets the content of the original message that the bot sent to the - * chat room. This is used for when a message was converted to a onebox. - * - * @return the original content - */ - public String getOriginalContent() { - return originalContent; - } - - /** - * Gets the text that the message should be changed to after the amount - * of time specified in the "hideOneboxesAfter" setting. - * - * @return the new content or null to leave the message alone - */ - public String getCondensedContent() { - return condensedContent; - } - - /** - * Gets the ID of each message that was actually posted to the room. The - * chat client may split up the original message due to length - * limitations. - * - * @return the message IDs - */ - public List getMessageIds() { - return messageIds; - } - - /** - * Gets the ID of the room the message was posted in. - * - * @return the room ID - */ - public int getRoomId() { - return roomId; - } - - /** - * Determines if the message has requested that it be condensed or - * deleted after the amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * - * @return true to condense or delete the message, false to leave it - * alone - */ - public boolean isCondensableOrEphemeral() { - return condensedContent != null || isEphemeral(); - } - - /** - * Determines if the message has requested that it be deleted after the - * amount of time specified in the "hideOneboxesAfter" - * setting. Does not include messages that were converted to oneboxes. - * - * @return true to delete the message, false not to - */ - public boolean isEphemeral() { - return ephemeral; - } - - /** - * Gets the ID of the message that this was a reply to. - * - * @return the parent ID or 0 if it's not a reply - */ - public long getParentId() { - return parentId; - } - } - - private abstract class Chore implements Comparable { - private final long choreId; - - public Chore() { - choreId = choreIdCounter.getAndIncrement(); - } - - public abstract void complete(); - - @Override - public int compareTo(Chore that) { - /* - * The "lowest" value will be popped off the queue first. - */ - - if (isBothStopChore(that)) { - return 0; - } - if (isThisStopChore()) { - return -1; - } - if (isThatStopChore(that)) { - return 1; - } - - if (isBothCondenseMessageChore(that)) { - return Long.compare(this.choreId, that.choreId); - } - if (isThisCondenseMessageChore()) { - return -1; - } - 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 { - @Override - public void complete() { - //empty - } - } - - private class FinishChore extends Chore { - @Override - public void complete() { - //empty - } - } - - private class ChatEventChore extends Chore { - private final Event event; - - public ChatEventChore(Event event) { - this.event = event; - } - - @Override - public void complete() { - if (event instanceof MessagePostedEvent mpe) { - handleMessage(mpe.getMessage()); - return; - } - - if (event instanceof MessageEditedEvent mee) { - handleMessage(mee.getMessage()); - return; - } - - if (event instanceof InvitationEvent ie) { - var roomId = ie.getRoomId(); - var userId = ie.getUserId(); - var inviterIsAdmin = isAdminUser(userId); - - boolean acceptInvitation; - if (inviterIsAdmin) { - acceptInvitation = true; - } else { - try { - acceptInvitation = isRoomOwner(roomId, userId); - } catch (IOException e) { - logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); - acceptInvitation = false; - } - } - - if (acceptInvitation) { - handleInvitation(ie); - } - - return; - } - - logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); - } - - private void handleMessage(ChatMessage message) { - var userId = message.getUserId(); - var isAdminUser = isAdminUser(userId); - var isBotInTimeout = timeout && !isAdminUser; - - if (isBotInTimeout) { - //bot is in timeout, ignore - return; - } - - var messageWasDeleted = message.getContent() == null; - if (messageWasDeleted) { - //user deleted their message, ignore - return; - } - - 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; - } - - var userIsBanned = security.isBanned(userId); - if (userIsBanned) { - //message was posted by a banned user, ignore - return; - } - - var room = connection.getRoom(message.getRoomId()); - if (room == null) { - //the bot is no longer in the room - return; - } - - if (message.getUserId() == userId) { - //message was posted by this bot - handleBotMessage(message); - return; - } - - message = convertFromBotlerRelayMessage(message); - - timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); - - var actions = handleListeners(message); - handleActions(message, actions); - } - - private void handleBotMessage(ChatMessage message) { - PostedMessage postedMessage; - synchronized (postedMessages) { - postedMessage = postedMessages.remove(message.getMessageId()); - } - - /* - * Check to see if the message should be edited for brevity - * after a short time so it doesn't spam the chat history. - * - * This could happen if (1) the bot posted something that Stack - * Overflow Chat converted to a onebox (e.g. an image) or (2) - * the message itself has asked to be edited (e.g. a javadoc - * description). - * - * ===What is a onebox?=== - * - * Stack Overflow Chat converts certain URLs to "oneboxes". - * Oneboxes can be fairly large and can spam the chat. For - * example, if the message is a URL to an image, the image - * itself will be displayed in the chat room. This is nice, but - * gets annoying if the image is large or if it's an animated - * GIF. - * - * After giving people some time to see the onebox, the bot will - * edit the message so that the onebox no longer displays, but - * the URL is still preserved. - */ - var messageIsOnebox = message.getContent().isOnebox(); - if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { - var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); - var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); - - logger.atInfo().log(() -> { - var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; - return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); - }); - - scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); - } - } - - private ChatActions handleListeners(ChatMessage message) { - var actions = new ChatActions(); - for (var listener : listeners) { - try { - actions.addAll(listener.onMessage(message, Bot.this)); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running listener."); - } - } - return actions; - } - - private void handleActions(ChatMessage message, ChatActions actions) { - if (actions.isEmpty()) { - return; - } - - logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); - - if (stats != null) { - stats.incMessagesRespondedTo(); - } - - var queue = new LinkedList<>(actions.getActions()); - while (!queue.isEmpty()) { - var action = queue.removeFirst(); - processAction(action, message, queue); - } - } - - 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) { - try { - if (action.delay() != null) { - scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); - } else { - if (action.broadcast()) { - broadcastMessage(action); - } else { - sendMessage(message.getRoomId(), action); - } - } - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); - } - } - - private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { - try { - var room = connection.getRoom(message.getRoomId()); - room.deleteMessage(action.messageId()); - return action.onSuccess().get(); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); - return action.onError().apply(e); - } - } - - private ChatActions handleJoinRoomAction(JoinRoom action) { - if (maxRooms != null && connection.getRooms().size() >= maxRooms) { - return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); - } - - try { - var joinedRoom = joinRoom(action.roomId()); - if (joinedRoom.canPost()) { - return action.onSuccess().get(); - } - - 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) { - 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(); - } catch (Exception e) { - return action.onError().apply(e); - } - } - - /** - * Attempts to leave a room and logs any errors that occur. - * - * @param roomId the room ID to leave + } + + @Override + public String getOriginalMessageContent(long messageId) throws IOException { + return connection.getOriginalMessageContent(messageId); + } + + @Override + public String uploadImage(String url) throws IOException { + return connection.uploadImage(url); + } + + @Override + public String uploadImage(byte[] data) throws IOException { + return connection.uploadImage(data); + } + + @Override + public void sendMessage(int roomId, PostMessage message) throws IOException { + var room = connection.getRoom(roomId); + if (room != null) { + sendMessage(room, message); + } + } + + private void sendMessage(IRoom room, String message) throws IOException { + sendMessage(room, new PostMessage(message)); + } + + private void sendMessage(IRoom room, PostMessage message) throws IOException { + final String filteredMessage; + if (message.bypassFilters()) { + filteredMessage = message.message(); + } else { + var messageText = message.message(); + for (var filter : responseFilters) { + if (filter.isEnabled(room.getRoomId())) { + messageText = filter.filter(messageText); + } + } + filteredMessage = messageText; + } + + logger.atInfo().log(() -> "Sending message [room=" + room.getRoomId() + "]: " + filteredMessage); + + synchronized (postedMessages) { + var messageIds = room.sendMessage(filteredMessage, message.parentId(), message.splitStrategy()); + var condensedMessage = message.condensedMessage(); + var ephemeral = message.ephemeral(); + + var postedMessage = new PostedMessage(Instant.now(), filteredMessage, condensedMessage, ephemeral, room.getRoomId(), message.parentId(), messageIds); + postedMessages.put(messageIds.get(0), postedMessage); + } + } + + @Override + public void join(int roomId) throws IOException { + joinRoom(roomId); + } + + /** + * Joins a room. + * @param roomId the room ID + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId) throws RoomNotFoundException, PrivateRoomException, IOException { + return joinRoom(roomId, false); + } + + /** + * Joins a room. + * @param roomId the room ID + * @param quiet true to not post an announcement message, false to post one + * @return the connection to the room + * @throws RoomNotFoundException if the room does not exist + * @throws PrivateRoomException if the room can't be joined because it is + * private + * @throws IOException if there's a problem connecting to the room + */ + private IRoom joinRoom(int roomId, boolean quiet) throws RoomNotFoundException, PrivateRoomException, IOException { + var room = connection.getRoom(roomId); + if (room != null) { + return room; + } + + logger.atInfo().log(() -> "Joining room " + roomId + "..."); + + room = connection.joinRoom(roomId); + + room.addEventListener(MessagePostedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(MessageEditedEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + room.addEventListener(InvitationEvent.class, event -> choreQueue.add(new ChatEventChore(event))); + + if (!quiet && config.getGreeting() != null) { + try { + sendMessage(room, config.getGreeting()); + } catch (RoomPermissionException e) { + logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); + } + } + + rooms.add(roomId); + + for (var task : inactivityTasks) { + var nextRun = task.getInactivityTime(room, this); + if (nextRun == null) { + continue; + } + + scheduleTask(task, room, nextRun); + } + + return room; + } + + @Override + public void leave(int roomId) throws IOException { + logger.atInfo().log(() -> "Leaving room " + roomId + "..."); + + inactivityTimerTasksByRoom.removeAll(roomId).forEach(TimerTask::cancel); + timeOfLastMessageByRoom.remove(roomId); + rooms.remove(roomId); + + var room = connection.getRoom(roomId); + if (room != null) { + room.leave(); + } + } + + @Override + public String getUsername() { + return config.getUserName(); + } + + @Override + public Integer getUserId() { + return config.getUserId(); + } + + @Override + public List getAdminUsers() { + return security.getAdmins(); + } + + private boolean isAdminUser(Integer userId) { + return security.isAdmin(userId); + } + + @Override + public boolean isRoomOwner(int roomId, int userId) throws IOException { + var userInfo = connection.getUserInfo(roomId, userId); + return (userInfo == null) ? false : userInfo.isOwner(); + } + + @Override + public String getTrigger() { + return config.getTrigger(); + } + + @Override + public List getRooms() { + return rooms.getRooms(); + } + + @Override + public IRoom getRoom(int roomId) { + return connection.getRoom(roomId); + } + + @Override + public List getHomeRooms() { + return rooms.getHomeRooms(); + } + + @Override + public List getQuietRooms() { + return rooms.getQuietRooms(); + } + + @Override + public Integer getMaxRooms() { + return maxRooms; + } + + @Override + public void broadcastMessage(PostMessage message) throws IOException { + for (var room : connection.getRooms()) { + if (!rooms.isQuietRoom(room.getRoomId())) { + sendMessage(room, message); + } + } + } + + @Override + public synchronized void timeout(Duration duration) { + if (timeout) { + timeoutTask.cancel(); + } else { + timeout = true; + } + + timeoutTask = new TimerTask() { + @Override + public void run() { + timeout = false; + } + }; + + timer.schedule(timeoutTask, duration.toMillis()); + } + + @Override + public synchronized void cancelTimeout() { + timeout = false; + if (timeoutTask != null) { + timeoutTask.cancel(); + } + } + + /** + * Sends a signal to immediately stop processing tasks. The bot thread will + * stop running once it is done processing the current task. + */ + public void stop() { + choreQueue.add(new StopChore()); + } + + /** + * Sends a signal to finish processing the tasks in the queue, and then + * terminate. + */ + public void finish() { + choreQueue.add(new FinishChore()); + } + + private TimerTask scheduleChore(long delay, Chore chore) { + var timerTask = new TimerTask() { + @Override + public void run() { + choreQueue.add(chore); + } + }; + timer.schedule(timerTask, delay); + + return timerTask; + } + + private TimerTask scheduleChore(Duration delay, Chore chore) { + return scheduleChore(delay.toMillis(), chore); + } + + /** + * Represents a message that was posted to the chat room. + * @author Michael Angstadt + */ + private static class PostedMessage { + private final Instant timePosted; + private final String originalContent; + private final String condensedContent; + private final boolean ephemeral; + private final int roomId; + private final long parentId; + private final List messageIds; + + /** + * @param timePosted the time the message was posted + * @param originalContent the original message that the bot sent to the + * chat room + * @param condensedContent the text that the message should be changed + * to after the amount of time specified in the "hideOneboxesAfter" + * setting + * @param ephemeral true to delete the message after the amount of time + * specified in the "hideOneboxesAfter" setting, false not to + * @param roomId the ID of the room the message was posted in + * @param parentId the ID of the message that this was a reply to + * @param messageIds the ID of each message that was actually posted to + * the room (the chat client may split up the original message due to + * length limitations) + */ + public PostedMessage(Instant timePosted, String originalContent, String condensedContent, boolean ephemeral, int roomId, long parentId, List messageIds) { + this.timePosted = timePosted; + this.originalContent = originalContent; + this.condensedContent = condensedContent; + this.ephemeral = ephemeral; + this.roomId = roomId; + this.parentId = parentId; + this.messageIds = messageIds; + } + + /** + * Gets the time the message was posted. + * @return the time the message was posted + */ + public Instant getTimePosted() { + return timePosted; + } + + /** + * Gets the content of the original message that the bot sent to the + * chat room. This is used for when a message was converted to a onebox. + * @return the original content + */ + public String getOriginalContent() { + return originalContent; + } + + /** + * Gets the text that the message should be changed to after the amount + * of time specified in the "hideOneboxesAfter" setting. + * @return the new content or null to leave the message alone + */ + public String getCondensedContent() { + return condensedContent; + } + + /** + * Gets the ID of each message that was actually posted to the room. The + * chat client may split up the original message due to length + * limitations. + * @return the message IDs + */ + public List getMessageIds() { + return messageIds; + } + + /** + * Gets the ID of the room the message was posted in. + * @return the room ID + */ + public int getRoomId() { + return roomId; + } + + /** + * Determines if the message has requested that it be condensed or + * deleted after the amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * @return true to condense or delete the message, false to leave it + * alone + */ + public boolean isCondensableOrEphemeral() { + return condensedContent != null || isEphemeral(); + } + + /** + * Determines if the message has requested that it be deleted after the + * amount of time specified in the "hideOneboxesAfter" + * setting. Does not include messages that were converted to oneboxes. + * @return true to delete the message, false not to + */ + public boolean isEphemeral() { + return ephemeral; + } + + /** + * Gets the ID of the message that this was a reply to. + * @return the parent ID or 0 if it's not a reply + */ + public long getParentId() { + return parentId; + } + } + + private abstract class Chore implements Comparable { + private final long choreId; + + public Chore() { + choreId = choreIdCounter.getAndIncrement(); + } + + public abstract void complete(); + + @Override + public int compareTo(Chore that) { + /* + * The "lowest" value will be popped off the queue first. + */ + + if (isBothStopChore(that)) { + return 0; + } + if (isThisStopChore()) { + return -1; + } + if (isThatStopChore(that)) { + return 1; + } + + if (isBothCondenseMessageChore(that)) { + return Long.compare(this.choreId, that.choreId); + } + if (isThisCondenseMessageChore()) { + return -1; + } + 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 { + @Override + public void complete() { + //empty + } + } + + private class FinishChore extends Chore { + @Override + public void complete() { + //empty + } + } + + private class ChatEventChore extends Chore { + private final Event event; + + public ChatEventChore(Event event) { + this.event = event; + } + + @Override + public void complete() { + if (event instanceof MessagePostedEvent mpe) { + handleMessage(mpe.getMessage()); + return; + } + + if (event instanceof MessageEditedEvent mee) { + handleMessage(mee.getMessage()); + return; + } + + if (event instanceof InvitationEvent ie) { + var roomId = ie.getRoomId(); + var userId = ie.getUserId(); + var inviterIsAdmin = isAdminUser(userId); + + boolean acceptInvitation; + if (inviterIsAdmin) { + acceptInvitation = true; + } else { + try { + acceptInvitation = isRoomOwner(roomId, userId); + } catch (IOException e) { + logger.atError().setCause(e).log(() -> "Unable to handle room invite. Error determining whether user is room owner."); + acceptInvitation = false; + } + } + + if (acceptInvitation) { + handleInvitation(ie); + } + + return; + } + + logger.atError().log(() -> "Ignoring event: " + event.getClass().getName()); + } + + private void handleMessage(ChatMessage message) { + var userId = message.getUserId(); + var isAdminUser = isAdminUser(userId); + var isBotInTimeout = timeout && !isAdminUser; + + if (isBotInTimeout) { + //bot is in timeout, ignore + return; + } + + var messageWasDeleted = message.getContent() == null; + if (messageWasDeleted) { + //user deleted their message, ignore + return; + } + + 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; + } + + var userIsBanned = security.isBanned(userId); + if (userIsBanned) { + //message was posted by a banned user, ignore + return; + } + + var room = connection.getRoom(message.getRoomId()); + if (room == null) { + //the bot is no longer in the room + return; + } + + if (message.getUserId() == userId) { + //message was posted by this bot + handleBotMessage(message); + return; + } + + message = convertFromBotlerRelayMessage(message); + + timeOfLastMessageByRoom.put(message.getRoomId(), message.getTimestamp()); + + var actions = handleListeners(message); + handleActions(message, actions); + } + + private void handleBotMessage(ChatMessage message) { + PostedMessage postedMessage; + synchronized (postedMessages) { + postedMessage = postedMessages.remove(message.getMessageId()); + } + + /* + * Check to see if the message should be edited for brevity + * after a short time so it doesn't spam the chat history. + * + * This could happen if (1) the bot posted something that Stack + * Overflow Chat converted to a onebox (e.g. an image) or (2) + * the message itself has asked to be edited (e.g. a javadoc + * description). + * + * ===What is a onebox?=== + * + * Stack Overflow Chat converts certain URLs to "oneboxes". + * Oneboxes can be fairly large and can spam the chat. For + * example, if the message is a URL to an image, the image + * itself will be displayed in the chat room. This is nice, but + * gets annoying if the image is large or if it's an animated + * GIF. + * + * After giving people some time to see the onebox, the bot will + * edit the message so that the onebox no longer displays, but + * the URL is still preserved. + */ + var messageIsOnebox = message.getContent().isOnebox(); + if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { + var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); + var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); + + logger.atInfo().log(() -> { + var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; + return action + " in " + hideIn.toMillis() + "ms [room=" + message.getRoomId() + ", id=" + message.getMessageId() + "]: " + message.getContent(); + }); + + scheduleChore(hideIn, new CondenseMessageChore(postedMessage)); + } + } + + private ChatActions handleListeners(ChatMessage message) { + var actions = new ChatActions(); + for (var listener : listeners) { + try { + actions.addAll(listener.onMessage(message, Bot.this)); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem running listener."); + } + } + return actions; + } + + private void handleActions(ChatMessage message, ChatActions actions) { + if (actions.isEmpty()) { + return; + } + + logger.atInfo().log(() -> "Responding to message [room=" + message.getRoomId() + ", user=" + message.getUsername() + ", id=" + message.getMessageId() + "]: " + message.getContent()); + + if (stats != null) { + stats.incMessagesRespondedTo(); + } + + var queue = new LinkedList<>(actions.getActions()); + while (!queue.isEmpty()) { + var action = queue.removeFirst(); + processAction(action, message, queue); + } + } + + 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(this, message); + var response = action.execute(context); + queue.addAll(response.getActions()); + } + + private void handlePostMessageAction(PostMessage action, ChatMessage message) { + try { + if (action.delay() != null) { + scheduleChore(action.delay(), new DelayedMessageChore(message.getRoomId(), action)); + } else { + if (action.broadcast()) { + broadcastMessage(action); + } else { + sendMessage(message.getRoomId(), action); + } + } + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem posting message [room=" + message.getRoomId() + "]: " + action.message()); + } + } + + private ChatActions handleDeleteMessageAction(DeleteMessage action, ChatMessage message) { + try { + var room = connection.getRoom(message.getRoomId()); + room.deleteMessage(action.messageId()); + return action.onSuccess().get(); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem deleting message [room=" + message.getRoomId() + ", messageId=" + action.messageId() + "]"); + return action.onError().apply(e); + } + } + + private ChatActions handleJoinRoomAction(JoinRoom action) { + if (maxRooms != null && connection.getRooms().size() >= maxRooms) { + return action.onError().apply(new IOException("Cannot join room. Max rooms reached.")); + } + + try { + var joinedRoom = joinRoom(action.roomId()); + if (joinedRoom.canPost()) { + return action.onSuccess().get(); + } + + 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) { + 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(); + } catch (Exception e) { + return action.onError().apply(e); + } + } + + /** + * 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()); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); - } - } - - private void handleInvitation(InvitationEvent event) { - /* - * If the bot is currently connected to multiple rooms, the - * invitation event will be sent to each room and this method will - * be called multiple times. Check to see if the bot has already - * joined the room it was invited to. - */ - var roomId = event.getRoomId(); - if (connection.isInRoom(roomId)) { - return; - } - - /* - * Ignore the invitation if the bot is connected to the maximum - * number of rooms allowed. We can't really post an error message - * because the invitation event is not linked to a specific chat - * room. - */ - var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); - if (maxRoomsExceeded) { - return; - } - - try { - joinRoom(roomId); - } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); - } - } - } - - private class CondenseMessageChore extends Chore { - private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); - private final PostedMessage postedMessage; - - public CondenseMessageChore(PostedMessage postedMessage) { - this.postedMessage = postedMessage; - } - - @Override - public void complete() { - var roomId = postedMessage.getRoomId(); - var room = connection.getRoom(roomId); - - var botIsNoLongerInTheRoom = (room == null); - if (botIsNoLongerInTheRoom) { - return; - } - - try { - List messagesToDelete; - if (postedMessage.isEphemeral()) { - messagesToDelete = postedMessage.getMessageIds(); - } else { - var condensedContent = postedMessage.getCondensedContent(); - var isAOneBox = (condensedContent == null); - if (isAOneBox) { - condensedContent = postedMessage.getOriginalContent(); - } - - var messageIds = postedMessage.getMessageIds(); - var quotedContent = quote(condensedContent); - room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); - - /* - * If the original content was split up into - * multiple messages due to length constraints, - * delete the additional messages. - */ - messagesToDelete = messageIds.subList(1, messageIds.size()); - } - - for (var id : messagesToDelete) { - room.deleteMessage(id); - } - } catch (Exception e) { + leave(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(logMessage); } + } + + private void handleLeaveRoomAction(LeaveRoom action) { + try { + leave(action.roomId()); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Problem leaving room " + action.roomId() + "."); + } + } + + private void handleInvitation(InvitationEvent event) { + /* + * If the bot is currently connected to multiple rooms, the + * invitation event will be sent to each room and this method will + * be called multiple times. Check to see if the bot has already + * joined the room it was invited to. + */ + var roomId = event.getRoomId(); + if (connection.isInRoom(roomId)) { + return; + } + + /* + * Ignore the invitation if the bot is connected to the maximum + * number of rooms allowed. We can't really post an error message + * because the invitation event is not linked to a specific chat + * room. + */ + var maxRoomsExceeded = (maxRooms != null && connection.getRooms().size() >= maxRooms); + if (maxRoomsExceeded) { + return; + } + + try { + joinRoom(roomId); + } catch (Exception e) { + logger.atError().setCause(e).log(() -> "Bot was invited to join room " + roomId + ", but couldn't join it."); + } + } + } + + private class CondenseMessageChore extends Chore { + private final Pattern replyRegex = Pattern.compile("^:(\\d+) (.*)", Pattern.DOTALL); + private final PostedMessage postedMessage; + + public CondenseMessageChore(PostedMessage postedMessage) { + this.postedMessage = postedMessage; + } + + @Override + public void complete() { + var roomId = postedMessage.getRoomId(); + var room = connection.getRoom(roomId); + + var botIsNoLongerInTheRoom = (room == null); + if (botIsNoLongerInTheRoom) { + return; + } + + try { + List messagesToDelete; + if (postedMessage.isEphemeral()) { + messagesToDelete = postedMessage.getMessageIds(); + } else { + var condensedContent = postedMessage.getCondensedContent(); + var isAOneBox = (condensedContent == null); + if (isAOneBox) { + condensedContent = postedMessage.getOriginalContent(); + } + + var messageIds = postedMessage.getMessageIds(); + var quotedContent = quote(condensedContent); + room.editMessage(messageIds.get(0), postedMessage.getParentId(), quotedContent); + + /* + * If the original content was split up into + * multiple messages due to length constraints, + * delete the additional messages. + */ + messagesToDelete = messageIds.subList(1, messageIds.size()); + } + + for (var id : messagesToDelete) { + room.deleteMessage(id); + } + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]"); - } - } + } + } - @SuppressWarnings("deprecation") - private String quote(String content) { - var cb = new ChatBuilder(); + @SuppressWarnings("deprecation") + private String quote(String content) { + var cb = new ChatBuilder(); - var m = replyRegex.matcher(content); - if (m.find()) { - var id = Long.parseLong(m.group(1)); - content = m.group(2); + var m = replyRegex.matcher(content); + if (m.find()) { + var id = Long.parseLong(m.group(1)); + content = m.group(2); - cb.reply(id); - } + cb.reply(id); + } - return cb.quote(content).toString(); - } - } + return cb.quote(content).toString(); + } + } - private class ScheduledTaskChore extends Chore { - private final ScheduledTask task; + private class ScheduledTaskChore extends Chore { + private final ScheduledTask task; - public ScheduledTaskChore(ScheduledTask task) { - this.task = task; - } + public ScheduledTaskChore(ScheduledTask task) { + this.task = task; + } - @Override - public void complete() { - try { - task.run(Bot.this); - } catch (Exception e) { + @Override + public void complete() { + try { + task.run(Bot.this); + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem running scheduled task."); - } - scheduleTask(task); - } - } - - private class InactivityTaskChore extends Chore { - private final InactivityTask task; - private final IRoom room; - - public InactivityTaskChore(InactivityTask task, IRoom room) { - this.task = task; - this.room = room; - } - - @Override - public void complete() { - try { - if (!connection.isInRoom(room.getRoomId())) { - return; - } - - var inactivityTime = task.getInactivityTime(room, Bot.this); - if (inactivityTime == null) { - return; - } - - var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); - var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); - var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); - if (runNow) { - try { - task.run(room, Bot.this); - } catch (Exception e) { + } + scheduleTask(task); + } + } + + private class InactivityTaskChore extends Chore { + private final InactivityTask task; + private final IRoom room; + + public InactivityTaskChore(InactivityTask task, IRoom room) { + this.task = task; + this.room = room; + } + + @Override + public void complete() { + try { + if (!connection.isInRoom(room.getRoomId())) { + return; + } + + var inactivityTime = task.getInactivityTime(room, Bot.this); + if (inactivityTime == null) { + return; + } + + var lastMessageTimestamp = timeOfLastMessageByRoom.get(room.getRoomId()); + var roomInactiveFor = (lastMessageTimestamp == null) ? inactivityTime : Duration.between(lastMessageTimestamp, LocalDateTime.now()); + var runNow = (roomInactiveFor.compareTo(inactivityTime) >= 0); + if (runNow) { + try { + task.run(room, Bot.this); + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem running inactivity task in room " + room.getRoomId() + "."); - } - } - - var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); - scheduleTask(task, room, nextCheck); - } finally { - inactivityTimerTasksByRoom.remove(room, this); - } - } - } - - private class DelayedMessageChore extends Chore { - private final int roomId; - private final PostMessage message; - - public DelayedMessageChore(int roomId, PostMessage message) { - this.roomId = roomId; - this.message = message; - } - - @Override - public void complete() { - try { - if (message.broadcast()) { - broadcastMessage(message); - } else { - sendMessage(roomId, message); - } - } catch (Exception e) { + } + } + + var nextCheck = runNow ? inactivityTime : inactivityTime.minus(roomInactiveFor); + scheduleTask(task, room, nextCheck); + } finally { + inactivityTimerTasksByRoom.remove(room, this); + } + } + } + + private class DelayedMessageChore extends Chore { + private final int roomId; + private final PostMessage message; + + public DelayedMessageChore(int roomId, PostMessage message) { + this.roomId = roomId; + this.message = message; + } + + @Override + public void complete() { + try { + if (message.broadcast()) { + broadcastMessage(message); + } else { + sendMessage(roomId, message); + } + } catch (Exception e) { logger.atError().setCause(e).log(() -> "Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message()); - } - } - } - - /** - * Alters the username and content of a message if the message is a Botler - * Discord relay message. Otherwise, returns the message unaltered. - * - * @param message the original message - * @return the altered message or the same message if it's not a relay - * message - * @see example - */ - private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { - if (message.getUserId() != BOTLER_ID) { - return message; - } - - var content = message.getContent(); - if (content == null) { - return message; - } - - //Example message content: - //[realmichael] test - var html = content.getContent(); - var dom = Jsoup.parse(html); - var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); - if (element == null) { - return message; - } - var discordUsername = element.text(); - - var endBracket = html.indexOf(']'); - if (endBracket < 0) { - return message; - } - var discordMessage = html.substring(endBracket + 1).trim(); - - //@formatter:off + } + } + } + + /** + * Alters the username and content of a message if the message is a Botler + * Discord relay message. Otherwise, returns the message unaltered. + * @param message the original message + * @return the altered message or the same message if it's not a relay + * message + * @see example + */ + private ChatMessage convertFromBotlerRelayMessage(ChatMessage message) { + if (message.getUserId() != BOTLER_ID) { + return message; + } + + var content = message.getContent(); + if (content == null) { + return message; + } + + //Example message content: + //[realmichael] test + var html = content.getContent(); + var dom = Jsoup.parse(html); + var element = dom.selectFirst("b a[href=\"https://discord.gg/PNMq3pBSUe\"]"); + if (element == null) { + return message; + } + var discordUsername = element.text(); + + var endBracket = html.indexOf(']'); + if (endBracket < 0) { + return message; + } + var discordMessage = html.substring(endBracket + 1).trim(); + + //@formatter:off return new ChatMessage.Builder(message) .username(discordUsername) .content(discordMessage) .build(); //@formatter:on - } - - /** - * Builds {@link Bot} instances. - * - * @author Michael Angstadt - */ - public static class Builder { - private IChatClient connection; - private String userName; - private String trigger = "="; - private String greeting; - private Integer userId; - private Duration hideOneboxesAfter; - private Integer maxRooms; - private List roomsHome = List.of(1); - private List roomsQuiet = List.of(); - private List admins = List.of(); - private List bannedUsers = List.of(); - private List allowedUsers = List.of(); - private List listeners = List.of(); - private List tasks = List.of(); - private List inactivityTasks = List.of(); - private List responseFilters = List.of(); - private Statistics stats; - private Database database; - - public Builder connection(IChatClient connection) { - this.connection = connection; - return this; - } - - public Builder user(String userName, Integer userId) { - this.userName = (userName == null || userName.isEmpty()) ? null : userName; - this.userId = userId; - return this; - } - - public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { - this.hideOneboxesAfter = hideOneboxesAfter; - return this; - } - - public Builder trigger(String trigger) { - this.trigger = trigger; - return this; - } - - public Builder greeting(String greeting) { - this.greeting = greeting; - return this; - } - - public Builder roomsHome(Integer... roomIds) { - roomsHome = List.of(roomIds); - return this; - } - - public Builder roomsHome(Collection roomIds) { - roomsHome = List.copyOf(roomIds); - return this; - } - - public Builder roomsQuiet(Integer... roomIds) { - roomsQuiet = List.of(roomIds); - return this; - } - - public Builder roomsQuiet(Collection roomIds) { - roomsQuiet = List.copyOf(roomIds); - return this; - } - - public Builder maxRooms(Integer maxRooms) { - this.maxRooms = maxRooms; - return this; - } - - public Builder admins(Integer... admins) { - this.admins = List.of(admins); - return this; - } - - public Builder admins(Collection admins) { - this.admins = List.copyOf(admins); - return this; - } - - public Builder bannedUsers(Integer... bannedUsers) { - this.bannedUsers = List.of(bannedUsers); - return this; - } - - public Builder bannedUsers(Collection bannedUsers) { - this.bannedUsers = List.copyOf(bannedUsers); - return this; - } - - public Builder allowedUsers(Integer... allowedUsers) { - this.allowedUsers = List.of(allowedUsers); - return this; - } - - public Builder allowedUsers(Collection allowedUsers) { - this.allowedUsers = List.copyOf(allowedUsers); - return this; - } - - public Builder listeners(Listener... listeners) { - this.listeners = List.of(listeners); - return this; - } - - public Builder listeners(Collection listeners) { - this.listeners = List.copyOf(listeners); - return this; - } - - public Builder tasks(ScheduledTask... tasks) { - this.tasks = List.of(tasks); - return this; - } - - public Builder tasks(Collection tasks) { - this.tasks = List.copyOf(tasks); - return this; - } - - public Builder inactivityTasks(InactivityTask... tasks) { - inactivityTasks = List.of(tasks); - return this; - } - - public Builder inactivityTasks(Collection tasks) { - inactivityTasks = List.copyOf(tasks); - return this; - } - - public Builder responseFilters(ChatResponseFilter... filters) { - responseFilters = List.of(filters); - return this; - } - - public Builder responseFilters(Collection filters) { - responseFilters = List.copyOf(filters); - return this; - } - - public Builder stats(Statistics stats) { - this.stats = stats; - return this; - } - - public Builder database(Database database) { - this.database = database; - return this; - } - - public Bot build() { - if (connection == null) { - throw new IllegalStateException("No ChatConnection given."); - } - - if (connection.getUsername() == null && this.userName == null) { - throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - if (connection.getUserId() == null && this.userId == null) { - throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); - } - - return new Bot(this); - } - } + } + + /** + * Builds {@link Bot} instances. + * @author Michael Angstadt + */ + public static class Builder { + private IChatClient connection; + private String userName; + private String trigger = "="; + private String greeting; + private Integer userId; + private Duration hideOneboxesAfter; + private Integer maxRooms; + private List roomsHome = List.of(1); + private List roomsQuiet = List.of(); + private List admins = List.of(); + private List bannedUsers = List.of(); + private List allowedUsers = List.of(); + private List listeners = List.of(); + private List tasks = List.of(); + private List inactivityTasks = List.of(); + private List responseFilters = List.of(); + private Statistics stats; + private Database database; + + public Builder connection(IChatClient connection) { + this.connection = connection; + return this; + } + + public Builder user(String userName, Integer userId) { + this.userName = (userName == null || userName.isEmpty()) ? null : userName; + this.userId = userId; + return this; + } + + public Builder hideOneboxesAfter(Duration hideOneboxesAfter) { + this.hideOneboxesAfter = hideOneboxesAfter; + return this; + } + + public Builder trigger(String trigger) { + this.trigger = trigger; + return this; + } + + public Builder greeting(String greeting) { + this.greeting = greeting; + return this; + } + + public Builder roomsHome(Integer... roomIds) { + roomsHome = List.of(roomIds); + return this; + } + + public Builder roomsHome(Collection roomIds) { + roomsHome = List.copyOf(roomIds); + return this; + } + + public Builder roomsQuiet(Integer... roomIds) { + roomsQuiet = List.of(roomIds); + return this; + } + + public Builder roomsQuiet(Collection roomIds) { + roomsQuiet = List.copyOf(roomIds); + return this; + } + + public Builder maxRooms(Integer maxRooms) { + this.maxRooms = maxRooms; + return this; + } + + public Builder admins(Integer... admins) { + this.admins = List.of(admins); + return this; + } + + public Builder admins(Collection admins) { + this.admins = List.copyOf(admins); + return this; + } + + public Builder bannedUsers(Integer... bannedUsers) { + this.bannedUsers = List.of(bannedUsers); + return this; + } + + public Builder bannedUsers(Collection bannedUsers) { + this.bannedUsers = List.copyOf(bannedUsers); + return this; + } + + public Builder allowedUsers(Integer... allowedUsers) { + this.allowedUsers = List.of(allowedUsers); + return this; + } + + public Builder allowedUsers(Collection allowedUsers) { + this.allowedUsers = List.copyOf(allowedUsers); + return this; + } + + public Builder listeners(Listener... listeners) { + this.listeners = List.of(listeners); + return this; + } + + public Builder listeners(Collection listeners) { + this.listeners = List.copyOf(listeners); + return this; + } + + public Builder tasks(ScheduledTask... tasks) { + this.tasks = List.of(tasks); + return this; + } + + public Builder tasks(Collection tasks) { + this.tasks = List.copyOf(tasks); + return this; + } + + public Builder inactivityTasks(InactivityTask... tasks) { + inactivityTasks = List.of(tasks); + return this; + } + + public Builder inactivityTasks(Collection tasks) { + inactivityTasks = List.copyOf(tasks); + return this; + } + + public Builder responseFilters(ChatResponseFilter... filters) { + responseFilters = List.of(filters); + return this; + } + + public Builder responseFilters(Collection filters) { + responseFilters = List.copyOf(filters); + return this; + } + + public Builder stats(Statistics stats) { + this.stats = stats; + return this; + } + + public Builder database(Database database) { + this.database = database; + return this; + } + + public Bot build() { + if (connection == null) { + throw new IllegalStateException("No ChatConnection given."); + } + + if (connection.getUsername() == null && this.userName == null) { + throw new IllegalStateException("Unable to parse username. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + if (connection.getUserId() == null && this.userId == null) { + throw new IllegalStateException("Unable to parse user ID. You'll need to manually set it in the properties section of the bot-context XML file."); + } + + return new Bot(this); + } + } } From 5cf7fd961f4955c524e34a84ccafa88a4fecab07 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Sat, 29 Nov 2025 18:50:01 -0400 Subject: [PATCH 11/17] Chore: Changes requested --- src/main/java/oakbot/bot/Bot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index df2edbea..2b40b2fb 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -840,7 +840,7 @@ private void processAction(ChatAction action, ChatMessage message, LinkedList Date: Sun, 30 Nov 2025 12:26:51 -0400 Subject: [PATCH 12/17] make config class a record --- src/main/java/oakbot/bot/Bot.java | 14 +++--- .../java/oakbot/bot/BotConfiguration.java | 47 +++++-------------- 2 files changed, 20 insertions(+), 41 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 2b40b2fb..fdc38182 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -317,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 && config.getGreeting() != null) { + if (!quiet && config.greeting() != null) { try { - sendMessage(room, config.getGreeting()); + sendMessage(room, config.greeting()); } catch (RoomPermissionException e) { logger.atWarn().setCause(e).log(() -> "Unable to post greeting when joining room " + roomId + "."); } @@ -355,12 +355,12 @@ public void leave(int roomId) throws IOException { @Override public String getUsername() { - return config.getUserName(); + return config.userName(); } @Override public Integer getUserId() { - return config.getUserId(); + return config.userId(); } @Override @@ -380,7 +380,7 @@ public boolean isRoomOwner(int roomId, int userId) throws IOException { @Override public String getTrigger() { - return config.getTrigger(); + return config.trigger(); } @Override @@ -788,9 +788,9 @@ private void handleBotMessage(ChatMessage message) { * the URL is still preserved. */ var messageIsOnebox = message.getContent().isOnebox(); - if (postedMessage != null && config.getHideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { + if (postedMessage != null && config.hideOneboxesAfter() != null && (messageIsOnebox || postedMessage.isCondensableOrEphemeral())) { var postedMessageAge = Duration.between(postedMessage.getTimePosted(), Instant.now()); - var hideIn = config.getHideOneboxesAfter().minus(postedMessageAge); + var hideIn = config.hideOneboxesAfter().minus(postedMessageAge); logger.atInfo().log(() -> { var action = messageIsOnebox ? "Hiding onebox" : "Condensing message"; diff --git a/src/main/java/oakbot/bot/BotConfiguration.java b/src/main/java/oakbot/bot/BotConfiguration.java index 48fbb72f..9bcfb687 100644 --- a/src/main/java/oakbot/bot/BotConfiguration.java +++ b/src/main/java/oakbot/bot/BotConfiguration.java @@ -5,39 +5,18 @@ /** * 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 class BotConfiguration { - private final String userName; - private final Integer userId; - private final String trigger; - private final String greeting; - private final Duration hideOneboxesAfter; - - public BotConfiguration(String userName, Integer userId, String trigger, String greeting, Duration hideOneboxesAfter) { - this.userName = userName; - this.userId = userId; - this.trigger = trigger; - this.greeting = greeting; - this.hideOneboxesAfter = hideOneboxesAfter; - } - - public String getUserName() { - return userName; - } - - public Integer getUserId() { - return userId; - } - - public String getTrigger() { - return trigger; - } - - public String getGreeting() { - return greeting; - } - - public Duration getHideOneboxesAfter() { - return hideOneboxesAfter; - } +public record BotConfiguration( + String userName, + Integer userId, + String trigger, + String greeting, + Duration hideOneboxesAfter +) { } From 5981966dce1f3248de0d5ee3b430f0eb1127ca34 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Mon, 1 Dec 2025 16:26:06 -0400 Subject: [PATCH 13/17] Remove tab spaces --- src/main/java/oakbot/bot/Bot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index fdc38182..f2bc267c 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -989,7 +989,7 @@ public void complete() { room.deleteMessage(id); } } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]"); + logger.atError().setCause(e).log(() -> "Problem editing chat message [room=" + roomId + ", id=" + postedMessage.getMessageIds().get(0) + "]"); } } From 86d2568b6a153c288c0960357c7f05ada694896d Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Mon, 1 Dec 2025 16:28:29 -0400 Subject: [PATCH 14/17] Remove tab spaces --- src/main/java/oakbot/bot/Bot.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index f2bc267c..a2b3e814 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -1021,7 +1021,7 @@ public void complete() { try { task.run(Bot.this); } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running scheduled task."); + logger.atError().setCause(e).log(() -> "Problem running scheduled task."); } scheduleTask(task); } @@ -1055,7 +1055,7 @@ public void complete() { try { task.run(room, Bot.this); } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem running inactivity task in room " + room.getRoomId() + "."); + logger.atError().setCause(e).log(() -> "Problem running inactivity task in room " + room.getRoomId() + "."); } } @@ -1085,7 +1085,7 @@ public void complete() { sendMessage(roomId, message); } } catch (Exception e) { - logger.atError().setCause(e).log(() -> "Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message()); + logger.atError().setCause(e).log(() -> "Problem posting delayed message [room=" + roomId + ", delay=" + message.delay() + "]: " + message.message()); } } } From 1da8e87642d4eb89df07c003834759b589b96c45 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Mon, 1 Dec 2025 16:47:51 -0400 Subject: [PATCH 15/17] Revert Polymorphic Action --- src/main/java/oakbot/bot/ActionContext.java | 29 --------------- src/main/java/oakbot/bot/Bot.java | 23 +++++++----- src/main/java/oakbot/bot/ChatAction.java | 9 ++--- src/main/java/oakbot/bot/DeleteMessage.java | 10 ------ src/main/java/oakbot/bot/JoinRoom.java | 39 --------------------- src/main/java/oakbot/bot/LeaveRoom.java | 9 ----- src/main/java/oakbot/bot/PostMessage.java | 23 ------------ src/main/java/oakbot/bot/Shutdown.java | 5 --- 8 files changed, 17 insertions(+), 130 deletions(-) delete mode 100644 src/main/java/oakbot/bot/ActionContext.java diff --git a/src/main/java/oakbot/bot/ActionContext.java b/src/main/java/oakbot/bot/ActionContext.java deleted file mode 100644 index df2ee470..00000000 --- a/src/main/java/oakbot/bot/ActionContext.java +++ /dev/null @@ -1,29 +0,0 @@ -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 a2b3e814..2d9de639 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -832,17 +832,22 @@ private void handleActions(ChatMessage message, ChatActions actions) { } 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 + // Conditional dispatch based on action type (replaces polymorphism) + if (action instanceof PostMessage pm) { handlePostMessageAction(pm, message); - return; + } else if (action instanceof DeleteMessage dm) { + var response = handleDeleteMessageAction(dm, message); + queue.addAll(response.getActions()); + } else if (action instanceof JoinRoom jr) { + var response = handleJoinRoomAction(jr); + queue.addAll(response.getActions()); + } else if (action instanceof LeaveRoom lr) { + handleLeaveRoomAction(lr); + } else if (action instanceof Shutdown) { + stop(); + } else { + logger.atWarn().log(() -> "Unknown action type: " + action.getClass().getName()); } - - var context = new ActionContext(Bot.this, message); - var response = action.execute(context); - queue.addAll(response.getActions()); } private void handlePostMessageAction(PostMessage action, ChatMessage message) { diff --git a/src/main/java/oakbot/bot/ChatAction.java b/src/main/java/oakbot/bot/ChatAction.java index af1279c7..2324cec9 100644 --- a/src/main/java/oakbot/bot/ChatAction.java +++ b/src/main/java/oakbot/bot/ChatAction.java @@ -3,13 +3,10 @@ /** * Represents an action to perform in response to a chat message. * Implementations define specific actions like posting messages, joining rooms, etc. + * Actions are processed by Bot using conditional logic based on their type. * @author Michael Angstadt */ public interface ChatAction { - /** - * 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); + // Marker interface - no methods required + // Action processing is handled by Bot.processAction() using instanceof checks } diff --git a/src/main/java/oakbot/bot/DeleteMessage.java b/src/main/java/oakbot/bot/DeleteMessage.java index ae56cb6d..1fc15369 100644 --- a/src/main/java/oakbot/bot/DeleteMessage.java +++ b/src/main/java/oakbot/bot/DeleteMessage.java @@ -76,14 +76,4 @@ public DeleteMessage onError(Function 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 e78e1899..ed216d87 100644 --- a/src/main/java/oakbot/bot/JoinRoom.java +++ b/src/main/java/oakbot/bot/JoinRoom.java @@ -119,43 +119,4 @@ public JoinRoom onError(Function 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 367bc765..81dc2dcb 100644 --- a/src/main/java/oakbot/bot/LeaveRoom.java +++ b/src/main/java/oakbot/bot/LeaveRoom.java @@ -32,13 +32,4 @@ public LeaveRoom roomId(int 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 59d44216..d92fb2dd 100644 --- a/src/main/java/oakbot/bot/PostMessage.java +++ b/src/main/java/oakbot/bot/PostMessage.java @@ -198,29 +198,6 @@ 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 - // For now, return empty actions and let Bot handle it - 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/Shutdown.java b/src/main/java/oakbot/bot/Shutdown.java index abfc327e..2ac3734e 100644 --- a/src/main/java/oakbot/bot/Shutdown.java +++ b/src/main/java/oakbot/bot/Shutdown.java @@ -5,9 +5,4 @@ * @author Michael Angstadt */ public class Shutdown implements ChatAction { - @Override - public ChatActions execute(ActionContext context) { - context.getBot().stop(); - return ChatActions.doNothing(); - } } From cb1181f310c5f6dd84a0ef80f1edcfc755ea33ea Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Mon, 1 Dec 2025 22:29:17 -0400 Subject: [PATCH 16/17] Requested changes --- src/main/java/oakbot/bot/Bot.java | 13 ++++++++----- src/main/java/oakbot/bot/ChatAction.java | 5 +---- src/main/java/oakbot/bot/DeleteMessage.java | 1 - src/main/java/oakbot/bot/JoinRoom.java | 1 - src/main/java/oakbot/bot/LeaveRoom.java | 1 - src/main/java/oakbot/bot/Shutdown.java | 1 + 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index 2d9de639..a7d50eff 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -887,10 +887,11 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { if (joinedRoom.canPost()) { return action.onSuccess().get(); } - - 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(); + 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) { - 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(); + 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(); } catch (Exception e) { @@ -906,8 +907,10 @@ private ChatActions handleJoinRoomAction(JoinRoom action) { private void leaveRoomSafely(int roomId, Supplier logMessage) { try { leave(roomId); - } catch (Exception e) { - logger.atError().setCause(e).log(logMessage); } + } + catch (Exception e) { + logger.atError().setCause(e).log(logMessage); + } } private void handleLeaveRoomAction(LeaveRoom action) { diff --git a/src/main/java/oakbot/bot/ChatAction.java b/src/main/java/oakbot/bot/ChatAction.java index 2324cec9..272bd152 100644 --- a/src/main/java/oakbot/bot/ChatAction.java +++ b/src/main/java/oakbot/bot/ChatAction.java @@ -2,11 +2,8 @@ /** * Represents an action to perform in response to a chat message. - * Implementations define specific actions like posting messages, joining rooms, etc. - * Actions are processed by Bot using conditional logic based on their type. * @author Michael Angstadt */ public interface ChatAction { - // Marker interface - no methods required - // Action processing is handled by Bot.processAction() using instanceof checks + //empty } diff --git a/src/main/java/oakbot/bot/DeleteMessage.java b/src/main/java/oakbot/bot/DeleteMessage.java index 1fc15369..9bdac016 100644 --- a/src/main/java/oakbot/bot/DeleteMessage.java +++ b/src/main/java/oakbot/bot/DeleteMessage.java @@ -75,5 +75,4 @@ public DeleteMessage onError(Function actions) { onError = actions; return this; } - } diff --git a/src/main/java/oakbot/bot/JoinRoom.java b/src/main/java/oakbot/bot/JoinRoom.java index ed216d87..1ff17e89 100644 --- a/src/main/java/oakbot/bot/JoinRoom.java +++ b/src/main/java/oakbot/bot/JoinRoom.java @@ -118,5 +118,4 @@ public JoinRoom onError(Function actions) { onError = actions; return this; } - } diff --git a/src/main/java/oakbot/bot/LeaveRoom.java b/src/main/java/oakbot/bot/LeaveRoom.java index 81dc2dcb..9dc7f841 100644 --- a/src/main/java/oakbot/bot/LeaveRoom.java +++ b/src/main/java/oakbot/bot/LeaveRoom.java @@ -31,5 +31,4 @@ public LeaveRoom roomId(int roomId) { this.roomId = roomId; return this; } - } diff --git a/src/main/java/oakbot/bot/Shutdown.java b/src/main/java/oakbot/bot/Shutdown.java index 2ac3734e..aa98a519 100644 --- a/src/main/java/oakbot/bot/Shutdown.java +++ b/src/main/java/oakbot/bot/Shutdown.java @@ -5,4 +5,5 @@ * @author Michael Angstadt */ public class Shutdown implements ChatAction { + //empty } From 2a32ca0bec8dacd1147585ee8224d29107900981 Mon Sep 17 00:00:00 2001 From: "SANIFALI\\Sanif" Date: Mon, 1 Dec 2025 23:38:30 -0400 Subject: [PATCH 17/17] Fix failing test cases --- src/main/java/oakbot/bot/Bot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/oakbot/bot/Bot.java b/src/main/java/oakbot/bot/Bot.java index a7d50eff..01222cd4 100644 --- a/src/main/java/oakbot/bot/Bot.java +++ b/src/main/java/oakbot/bot/Bot.java @@ -745,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;