diff --git a/Database Updates/009_mentions_wordfilter.sql b/Database Updates/009_mentions_wordfilter.sql index e7bad426..31383093 100644 --- a/Database Updates/009_mentions_wordfilter.sql +++ b/Database Updates/009_mentions_wordfilter.sql @@ -1,24 +1,74 @@ CREATE TABLE IF NOT EXISTS `habbo_mentions` ( - `id` INT NOT NULL AUTO_INCREMENT, - `target_user_id` INT NOT NULL, - `sender_user_id` INT NOT NULL, - `sender_username` VARCHAR(64) NOT NULL DEFAULT '', - `room_id` INT NOT NULL, - `room_name` VARCHAR(255) NOT NULL DEFAULT '', - `message` VARCHAR(255) NOT NULL DEFAULT '', - `mention_type` TINYINT NOT NULL DEFAULT 0, - `timestamp` INT NOT NULL DEFAULT 0, - `read` TINYINT NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - INDEX `idx_target_read` (`target_user_id`, `read`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `id` INT(11) NOT NULL AUTO_INCREMENT, + `target_user_id` INT(11) NOT NULL, + `sender_user_id` INT(11) NOT NULL, + `sender_username` VARCHAR(64) NOT NULL DEFAULT '', + `room_id` INT(11) NOT NULL DEFAULT 0, + `room_name` VARCHAR(64) NOT NULL DEFAULT '', + `message` VARCHAR(255) NOT NULL DEFAULT '', + `mention_type` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0 = direct (@nick), 1 = broadcast (@all/@friends/@room)', + `timestamp` INT(11) NOT NULL DEFAULT 0, + `read` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_target_id` (`target_user_id`, `id`), + KEY `idx_target_unread` (`target_user_id`, `read`), + KEY `idx_target_timestamp` (`target_user_id`, `timestamp`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -INSERT IGNORE INTO `emulator_settings` (`key`, `value`) VALUES - ('mentions.enabled', '1'), - ('mentions.room.aliases', 'amici,friends,all,everyone,tutti,room,stanza'), - ('mentions.max.targets', '50'), - ('mentions.cooldown.ms', '3000'), - ('mentions.store.limit', '50'); + + + +INSERT INTO `permission_definitions` + (`permission_key`, `max_value`, `comment`, + `rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`, `rank_8`) +VALUES + ('acc_mention_everyone', 1, + 'Allow sending @all / @everyone / @tutti broadcast mentions (hotel-wide).', + 0, 0, 0, 0, 1, 1, 1, 1), + ('acc_mention_friends', 1, + 'Allow sending @friends / @amici broadcast mentions (sender''s online buddies).', + 0, 0, 0, 0, 1, 1, 1, 1), + ('cmd_disablementions', 1, + 'Allow toggling :disablementions to stop receiving any @mention notifications.', + 1, 1, 1, 1, 1, 1, 1, 1), + ('cmd_disablemassmentions', 1, + 'Allow toggling :disablemassmentions to stop receiving broadcast mentions (direct @nick still works).', + 1, 1, 1, 1, 1, 1, 1, 1) +ON DUPLICATE KEY UPDATE + `comment` = VALUES(`comment`); + + +-- ---------------------------------------------------------------------------- +-- 3. Emulator settings: cooldowns, caps and alias lists +-- +-- Only inserted when missing - existing tuned values are preserved. +-- ---------------------------------------------------------------------------- + +INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES + ('mentions.enabled', '1', + 'Master switch. 1 = process @mentions, 0 = disable the feature entirely.'), + ('mentions.max.targets', '50', + 'Hard cap on how many users a single broadcast (@all / @friends / @room) can fan out to.'), + ('mentions.cooldown.ms', '3000', + 'Per-sender cooldown between any two mentions, in milliseconds.'), + ('mentions.room.cooldown.ms', '15000', + 'Extra per-sender cooldown for broadcast mentions (@all / @friends / @room) on top of mentions.cooldown.ms.'), + ('mentions.store.limit', '50', + 'Number of mentions returned in the initial RequestMentionsList response.'), + ('mentions.request.cooldown.ms', '2000', + 'Per-user cooldown between RequestMentionsList packets.'), + ('mentions.markread.cooldown.ms', '500', + 'Per-user cooldown between mark-single-as-read packets.'), + ('mentions.markall.cooldown.ms', '5000', + 'Per-user cooldown between mark-all-as-read packets (bulk DB update).'), + ('mentions.delete.cooldown.ms', '500', + 'Per-user cooldown between delete-mention packets.'), + ('mentions.everyone.aliases', 'all,everyone,tutti', + 'Comma-separated aliases that trigger an @everyone broadcast (requires acc_mention_everyone).'), + ('mentions.friends.aliases', 'friends,amici', + 'Comma-separated aliases that trigger an @friends broadcast (requires acc_mention_friends).'), + ('mentions.room.aliases', 'room,stanza', + 'Comma-separated aliases that trigger an @room broadcast (no permission required, room scope only).'); ALTER TABLE `wordfilter` diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java index d1c0a4e4..00cac56f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java @@ -191,6 +191,8 @@ public class CommandHandler { addCommand(new CreditsCommand()); addCommand(new DanceCommand()); addCommand(new DiagonalCommand()); + addCommand(new DisableMassMentionsCommand()); + addCommand(new DisableMentionsCommand()); addCommand(new DisconnectCommand()); addCommand(new EjectAllCommand()); addCommand(new EmptyInventoryCommand()); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/DisableMassMentionsCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/DisableMassMentionsCommand.java new file mode 100644 index 00000000..00ca230d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/DisableMassMentionsCommand.java @@ -0,0 +1,25 @@ +package com.eu.habbo.habbohotel.commands; + +import com.eu.habbo.habbohotel.gameclients.GameClient; +import com.eu.habbo.habbohotel.users.Habbo; + +public class DisableMassMentionsCommand extends Command { + public DisableMassMentionsCommand() { + super("cmd_disablemassmentions", new String[]{"disablemassmentions", "togglemassmentions"}); + } + + @Override + public boolean handle(GameClient gameClient, String[] params) throws Exception { + if (gameClient == null) return true; + Habbo habbo = gameClient.getHabbo(); + if (habbo == null || habbo.getHabboStats() == null) return true; + + boolean newState = !habbo.getHabboStats().massMentionsEnabled(); + habbo.getHabboStats().setMassMentionsEnabled(newState); + + habbo.whisper(newState + ? "Broadcast mentions (@all / @friends / @room) are now ENABLED for you." + : "Broadcast mentions (@all / @friends / @room) are now DISABLED for you. Direct @nick mentions still work."); + return true; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/DisableMentionsCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/DisableMentionsCommand.java new file mode 100644 index 00000000..b1405019 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/DisableMentionsCommand.java @@ -0,0 +1,25 @@ +package com.eu.habbo.habbohotel.commands; + +import com.eu.habbo.habbohotel.gameclients.GameClient; +import com.eu.habbo.habbohotel.users.Habbo; + +public class DisableMentionsCommand extends Command { + public DisableMentionsCommand() { + super("cmd_disablementions", new String[]{"disablementions", "togglementions"}); + } + + @Override + public boolean handle(GameClient gameClient, String[] params) throws Exception { + if (gameClient == null) return true; + Habbo habbo = gameClient.getHabbo(); + if (habbo == null || habbo.getHabboStats() == null) return true; + + boolean newState = !habbo.getHabboStats().mentionsEnabled(); + habbo.getHabboStats().setMentionsEnabled(newState); + + habbo.whisper(newState + ? "@mention notifications are now ENABLED for you." + : "@mention notifications are now DISABLED for you. You will not receive direct or broadcast mentions."); + return true; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java index 8d9ca85f..0326da3e 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java @@ -9,8 +9,18 @@ import com.eu.habbo.habbohotel.users.HabboManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.sql.*; -import java.util.*; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class MentionManager { @@ -19,11 +29,8 @@ public class MentionManager { private static final int ROOM_NAME_MAX_LENGTH = 64; private static final int MESSAGE_MAX_LENGTH = 255; - private final ConcurrentHashMap cooldowns = new ConcurrentHashMap<>(); private final ConcurrentHashMap roomBroadcastCooldowns = new ConcurrentHashMap<>(); - - // Per-user request rate limits for the incoming packets that hit the DB. private final ConcurrentHashMap requestListCooldowns = new ConcurrentHashMap<>(); private final ConcurrentHashMap markReadCooldowns = new ConcurrentHashMap<>(); private final ConcurrentHashMap markAllCooldowns = new ConcurrentHashMap<>(); @@ -39,17 +46,12 @@ public class MentionManager { /** Broadcast category resolved from a mention alias. */ public enum BroadcastScope { NONE, - // @room / @stanza - reaches the people currently in the room. ROOM, - // @friends / @amici - reaches the sender's online friends, requires acc_mention_friends. FRIENDS, - // @all / @everyone / @tutti - reaches every online user, requires acc_mention_everyone. EVERYONE } - /** Permission key (DB column) required to send an "everyone" broadcast. */ public static final String PERMISSION_EVERYONE = "acc_mention_everyone"; - /** Permission key (DB column) required to send a "friends" broadcast. */ public static final String PERMISSION_FRIENDS = "acc_mention_friends"; private Set parseAliases(String configKey, String defaultValue) { @@ -76,14 +78,6 @@ public class MentionManager { return parseAliases("mentions.everyone.aliases", "all,everyone,tutti"); } - /** - * Classify an alias candidate (lowercased, punctuation-trimmed) into a - * broadcast scope. {@link BroadcastScope#EVERYONE} wins over - * {@link BroadcastScope#FRIENDS} which wins over {@link BroadcastScope#ROOM} - * so an admin who's also configured the same word into two lists gets the - * most permissive scope (which is also the one requiring the strongest - * permission, so it can't be misused). - */ private BroadcastScope classifyAlias(String alias, Set everyone, Set friends, @@ -135,8 +129,6 @@ public class MentionManager { BroadcastScope scope = this.classifyAlias(aliasCandidate, everyoneAliases, friendsAliases, roomAliases); if (scope != BroadcastScope.NONE) { - // Promote to the strongest detected scope so a message with - // both @room and @all routes through the @all permission. if (scope.ordinal() > broadcastScope.ordinal()) { broadcastScope = scope; } @@ -145,9 +137,6 @@ public class MentionManager { } } - // Gate the broadcast on the matching permission. If the sender does - // not have the right to use it, drop the broadcast entirely but - // keep processing any direct @nick tokens in the same message. if (broadcastScope == BroadcastScope.EVERYONE && !sender.hasPermission(PERMISSION_EVERYONE)) { broadcastScope = BroadcastScope.NONE; } else if (broadcastScope == BroadcastScope.FRIENDS && !sender.hasPermission(PERMISSION_FRIENDS)) { @@ -158,9 +147,6 @@ public class MentionManager { return; } - // Stricter cooldown for broadcasts: one @all/@friends/@room expands to - // up to mentions.max.targets DB writes and packet sends, so rate-limit it - // separately from direct mentions. if (broadcastScope != BroadcastScope.NONE) { long roomCooldownMs = Emulator.getConfig().getInt("mentions.room.cooldown.ms", 15000); Long lastRoom = this.roomBroadcastCooldowns.get(senderId); @@ -171,9 +157,6 @@ public class MentionManager { int maxTargets = Emulator.getConfig().getInt("mentions.max.targets", 50); if (maxTargets <= 0) maxTargets = 1; - - // Bound the number of direct tokens we even attempt to resolve so a - // crafted message can't push us through the room iteration N times. int maxDirectTokens = Math.min(directTokens.size(), maxTargets); List targets = new ArrayList<>(); @@ -187,7 +170,7 @@ public class MentionManager { this.collectFriendsTargets(sender, senderId, targets, seen, maxTargets); break; case ROOM: - this.collectRoomTargets(room, senderId, targets, seen, maxTargets); + this.collectRoomTargets(room, senderId, targets, seen, maxTargets, true); break; case NONE: default: @@ -198,6 +181,9 @@ public class MentionManager { if (habbo == null || habbo.getHabboInfo().getId() == senderId) { continue; } + if (!acceptsMention(habbo, false)) { + continue; + } if (seen.add(habbo.getHabboInfo().getId())) { targets.add(habbo); } @@ -229,9 +215,10 @@ public class MentionManager { } } - private void collectRoomTargets(Room room, int senderId, List targets, Set seen, int maxTargets) { + private void collectRoomTargets(Room room, int senderId, List targets, Set seen, int maxTargets, boolean isBroadcast) { for (Habbo habbo : room.getHabbos()) { if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue; + if (!acceptsMention(habbo, isBroadcast)) continue; if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo); if (targets.size() >= maxTargets) break; } @@ -246,6 +233,7 @@ public class MentionManager { if (buddyId == senderId) continue; Habbo online = habboManager.getHabbo(buddyId); if (online == null) continue; + if (!acceptsMention(online, true)) continue; if (seen.add(buddyId)) targets.add(online); if (targets.size() >= maxTargets) break; } @@ -254,11 +242,19 @@ public class MentionManager { private void collectEveryoneTargets(int senderId, List targets, Set seen, int maxTargets) { for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) { if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue; + if (!acceptsMention(habbo, true)) continue; if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo); if (targets.size() >= maxTargets) break; } } + private boolean acceptsMention(Habbo recipient, boolean isBroadcast) { + if (recipient == null || recipient.getHabboStats() == null) return true; + if (!recipient.getHabboStats().mentionsEnabled()) return false; + if (isBroadcast && !recipient.getHabboStats().massMentionsEnabled()) return false; + return true; + } + private void store(Habbo target, Habbo sender, Room room, String message, int mentionType, int timestamp, String roomName) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( @@ -281,9 +277,6 @@ public class MentionManager { } } - // Don't push a notification to the client when the INSERT did not - // return an id - the client dedup keys on id and a 0 would skip - // dedup entirely, opening a flood path on the next packet. if (generatedId <= 0) { return; } @@ -319,7 +312,6 @@ public class MentionManager { } public void markRead(int userId, int mode, int mentionId) { - // Caller has already validated mode and mentionId; this method is defensive only. if (mode != 0 && mode != 1) return; if (mode == 1 && mentionId <= 0) return; @@ -351,20 +343,11 @@ public class MentionManager { } } - /** - * Per-user rate limit for {@code RequestMentionsEvent}. Returns true when - * the caller should be served, false when it must be silently dropped. - */ public boolean tryAcquireRequestList(int userId) { long cooldownMs = Emulator.getConfig().getInt("mentions.request.cooldown.ms", 2000); return tryAcquire(this.requestListCooldowns, userId, cooldownMs); } - /** - * Per-user rate limit for {@code MarkMentionsReadEvent}. The mark-single - * path (mode == 1) is cheap and gets a short window; the mark-all path - * (mode != 1) is a bulk UPDATE and gets a longer one. - */ public boolean tryAcquireMarkRead(int userId, int mode) { long cooldownMs; ConcurrentHashMap bucket; @@ -378,9 +361,6 @@ public class MentionManager { return tryAcquire(bucket, userId, cooldownMs); } - /** - * Per-user rate limit for {@code DeleteMentionEvent}. - */ public boolean tryAcquireDelete(int userId) { long cooldownMs = Emulator.getConfig().getInt("mentions.delete.cooldown.ms", 500); return tryAcquire(this.deleteCooldowns, userId, cooldownMs); @@ -397,11 +377,6 @@ public class MentionManager { return true; } - /** - * Periodically drop cooldown entries older than the largest window so the - * maps don't accumulate one entry per user-that-ever-played for the entire - * server lifetime. - */ private void pruneCooldownsIfDue(long now) { if (now - this.lastPrune < PRUNE_INTERVAL_MS) return; this.lastPrune = now; @@ -448,12 +423,6 @@ public class MentionManager { return value.substring(0, max); } - /** - * Resolve a present room occupant from a raw mention token. Tries the token - * verbatim first (so usernames containing allowed punctuation such as '-', - * '.', '!' still match), then falls back to a trailing-punctuation-trimmed - * form so a mention written as "@Bob!" still resolves the user "Bob". - */ private Habbo resolveHabbo(Room room, String rawToken) { Habbo habbo = room.getHabbo(rawToken); if (habbo != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java index dc29be02..3c7e820d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java @@ -94,6 +94,8 @@ public class HabboStats implements Runnable { public boolean hasGottenDefaultSavedSearches; private HabboInfo habboInfo; private boolean allowTrade; + private boolean mentionsEnabled; + private boolean massMentionsEnabled; private int clubExpireTimestamp; private int muteEndTime; public int maxFriends; @@ -131,6 +133,8 @@ public class HabboStats implements Runnable { this.guilds = new ArrayList<>(); this.tags = set.getString("tags").split(";"); this.allowTrade = set.getString("can_trade").equals("1"); + this.mentionsEnabled = "1".equals(safeColumnString(set, "mentions_enabled", "1")); + this.massMentionsEnabled = "1".equals(safeColumnString(set, "mass_mentions_enabled", "1")); this.votedRooms = new TIntArrayStack(); this.clubExpireTimestamp = set.getInt("club_expire_timestamp"); this.loginStreak = set.getInt("login_streak"); @@ -749,13 +753,6 @@ public class HabboStats implements Runnable { return 0; } - /** - * Ignore an user. - * - * @param gameClient The client to which this HabboStats instance belongs. - * @param userId The user to ignore. - * @return true if successfully ignored, false otherwise. - */ public boolean ignoreUser(GameClient gameClient, int userId) { final Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); @@ -805,6 +802,44 @@ public class HabboStats implements Runnable { else return this.allowTrade; } + public boolean mentionsEnabled() { + return this.mentionsEnabled; + } + + public boolean massMentionsEnabled() { + return this.massMentionsEnabled; + } + + public void setMentionsEnabled(boolean enabled) { + this.mentionsEnabled = enabled; + persistFlag("mentions_enabled", enabled); + } + + public void setMassMentionsEnabled(boolean enabled) { + this.massMentionsEnabled = enabled; + persistFlag("mass_mentions_enabled", enabled); + } + + private void persistFlag(String column, boolean enabled) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET `" + column + "` = ? WHERE user_id = ? LIMIT 1")) { + statement.setString(1, enabled ? "1" : "0"); + statement.setInt(2, this.habboInfo.getId()); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Failed to persist users_settings.{} for user {}", column, this.habboInfo.getId(), e); + } + } + + private static String safeColumnString(ResultSet set, String column, String defaultValue) { + try { + String value = set.getString(column); + return value == null ? defaultValue : value; + } catch (SQLException e) { + return defaultValue; + } + } + public void setAllowTrade(boolean allowTrade) { this.allowTrade = allowTrade; }