feat(housekeeping): hotel alert + dashboard + audit log

Closes out the HK panel server-side surface.

* Incoming 9127 HousekeepingSendHotelAlertEvent — broadcast a
  StaffAlertWithLinkComposer to every online user that hasn't
  set blockStaffAlerts. Composed once, fanned out by reference;
  empty-message guard returns `housekeeping.error.alert_empty`.

* Outgoing 9206 HousekeepingDashboardComposer + Incoming 9128
  HousekeepingGetDashboardEvent — single round trip with the
  aggregated counters: online / total users + active / total
  rooms + pending support tickets + sanctions in the last 24h +
  approximate emulator uptime + a version string. Active-rooms
  is derived from RoomManager.getActiveRooms().getUserCount()>0
  to avoid counting idle preloaded rooms. Peak online today /
  all-time aren't tracked yet, so they currently echo the live
  online count as a best-effort placeholder.

* Outgoing 9207 HousekeepingActionLogComposer + Incoming 9129
  HousekeepingListActionLogEvent — read the optional
  housekeeping_log table. If the table isn't there the SQL
  exception is swallowed and an empty list goes back, so the
  panel renders a no-entries view rather than crashing. Schema
  is documented in the handler's javadoc; operators who want
  audit run a single CREATE TABLE then the HK panel populates
  from new writes (writes are a follow-up — every HK handler
  will eventually append a row).

`mvn package` clean — the final fat jar lands in
Latest_Compiled_Version/ after the build finishes.
This commit is contained in:
simoleo89
2026-05-24 11:52:36 +02:00
committed by simoleo89
parent 6126c35779
commit fbf979419e
8 changed files with 357 additions and 0 deletions
@@ -739,5 +739,8 @@ public class PacketManager {
this.registerHandler(Incoming.HousekeepingGiveCurrencyEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingGiveCurrencyEvent.class);
this.registerHandler(Incoming.HousekeepingGrantItemEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingGrantItemEvent.class);
this.registerHandler(Incoming.HousekeepingSetHcSubscriptionEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingSetHcSubscriptionEvent.class);
this.registerHandler(Incoming.HousekeepingSendHotelAlertEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingSendHotelAlertEvent.class);
this.registerHandler(Incoming.HousekeepingGetDashboardEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingGetDashboardEvent.class);
this.registerHandler(Incoming.HousekeepingListActionLogEvent, com.eu.habbo.messages.incoming.housekeeping.HousekeepingListActionLogEvent.class);
}
}
@@ -483,4 +483,7 @@ public class Incoming {
public static final int HousekeepingGiveCurrencyEvent = 9118;
public static final int HousekeepingGrantItemEvent = 9119;
public static final int HousekeepingSetHcSubscriptionEvent = 9120;
public static final int HousekeepingSendHotelAlertEvent = 9121;
public static final int HousekeepingGetDashboardEvent = 9122;
public static final int HousekeepingListActionLogEvent = 9123;
}
@@ -0,0 +1,91 @@
package com.eu.habbo.messages.incoming.housekeeping;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.permissions.Permission;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.housekeeping.HousekeepingDashboardComposer;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class HousekeepingGetDashboardEvent extends MessageHandler {
@Override
public int getRatelimit() {
return 2000;
}
@Override
public void handle() throws Exception {
if (!this.client.getHabbo().hasPermission(Permission.ACC_HOUSEKEEPING)) {
return;
}
int onlineUsers = Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().size();
int activeRooms = 0;
int totalUsers = 0;
int totalRooms = 0;
int pendingTickets = 0;
int sanctionsLast24h = 0;
int now = Emulator.getIntUnixTimestamp();
// activeRooms = loaded rooms with at least one user
try {
for (var room : Emulator.getGameEnvironment().getRoomManager().getActiveRooms()) {
if (room != null && room.getUserCount() > 0) activeRooms++;
}
} catch (Exception ignored) {
// fall through with 0
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) {
try (PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM users");
ResultSet rs = statement.executeQuery()) {
if (rs.next()) totalUsers = rs.getInt(1);
}
try (PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM rooms");
ResultSet rs = statement.executeQuery()) {
if (rs.next()) totalRooms = rs.getInt(1);
}
try (PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM support_tickets WHERE state = 0");
ResultSet rs = statement.executeQuery()) {
if (rs.next()) pendingTickets = rs.getInt(1);
}
try (PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM bans WHERE timestamp > ?")) {
statement.setInt(1, now - (24 * 3600));
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) sanctionsLast24h = rs.getInt(1);
}
}
} catch (SQLException ignored) {
// Surface 0s rather than failing the whole dashboard on a missing
// optional table — the HK panel can render partial data.
}
int uptime = (int) ((System.currentTimeMillis() - HOUSEKEEPING_BOOT_MILLIS) / 1000);
String version = "Arcturus-Morningstar-Extended";
this.client.sendResponse(new HousekeepingDashboardComposer(
onlineUsers,
totalUsers,
activeRooms,
totalRooms,
onlineUsers, // peakOnlineToday — not tracked, use current as best-effort
onlineUsers, // peakOnlineAllTime — same
pendingTickets,
sanctionsLast24h,
Math.max(uptime, 0),
version
));
}
// Approximate uptime — captured at class-load time rather than emu startup
// (Emulator.java doesn't expose a public startup timestamp). For HK panel
// headline metrics this is close enough; if tighter accuracy is needed
// later, plumb Emulator.startup through and read it here.
private static final long HOUSEKEEPING_BOOT_MILLIS = System.currentTimeMillis();
}
@@ -0,0 +1,85 @@
package com.eu.habbo.messages.incoming.housekeeping;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.permissions.Permission;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.housekeeping.HousekeepingActionLogComposer;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* Read the housekeeping_log audit table. The table isn't part of the
* base FullDatabase.sql yet — operators who want audit have to create
* it once:
*
* CREATE TABLE IF NOT EXISTS `housekeeping_log` (
* `id` INT NOT NULL AUTO_INCREMENT,
* `timestamp` INT NOT NULL,
* `actor_id` INT NOT NULL,
* `actor_name` VARCHAR(64) NOT NULL DEFAULT '',
* `target_type` VARCHAR(16) NOT NULL DEFAULT 'user',
* `target_id` INT NOT NULL DEFAULT 0,
* `target_label` VARCHAR(128) NOT NULL DEFAULT '',
* `action` VARCHAR(64) NOT NULL DEFAULT '',
* `detail` VARCHAR(500) NOT NULL DEFAULT '',
* `success` TINYINT NOT NULL DEFAULT 1,
* PRIMARY KEY (`id`), KEY `timestamp` (`timestamp`)
* ) ENGINE=InnoDB;
*
* If the table is missing we swallow the SQL error and return an empty
* list — the panel just shows "no audit entries" instead of breaking.
* Writing into the table is a follow-up: each HK handler will append
* a row once the table exists; for now the listing is read-only.
*/
public class HousekeepingListActionLogEvent extends MessageHandler {
private static final int HARD_LIMIT = 500;
@Override
public int getRatelimit() {
return 1000;
}
@Override
public void handle() throws Exception {
if (!this.client.getHabbo().hasPermission(Permission.ACC_HOUSEKEEPING)) {
return;
}
int limit = Math.min(Math.max(this.packet.readInt(), 1), HARD_LIMIT);
List<HousekeepingActionLogComposer.Row> rows = new ArrayList<>();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"SELECT id, timestamp, actor_id, actor_name, target_type, target_id, target_label, action, detail, success " +
"FROM housekeeping_log ORDER BY id DESC LIMIT ?")) {
statement.setInt(1, limit);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
rows.add(new HousekeepingActionLogComposer.Row(
rs.getInt("id"),
rs.getInt("timestamp"),
rs.getInt("actor_id"),
rs.getString("actor_name"),
rs.getString("target_type"),
rs.getInt("target_id"),
rs.getString("target_label"),
rs.getString("action"),
rs.getString("detail"),
rs.getInt("success") == 1
));
}
}
} catch (SQLException ignored) {
// table absent — return empty list, log via emu logger left to the panel UI
}
this.client.sendResponse(new HousekeepingActionLogComposer(rows));
}
}
@@ -0,0 +1,58 @@
package com.eu.habbo.messages.incoming.housekeeping;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.permissions.Permission;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.generic.alerts.StaffAlertWithLinkComposer;
import com.eu.habbo.messages.outgoing.housekeeping.HousekeepingActionResultComposer;
import java.util.Map;
/**
* Mirrors :ha — staff alert with sender attribution, broadcast to
* every online user whose `blockStaffAlerts` flag isn't set. Composed
* once and forwarded by reference (sendResponse compiles to the same
* underlying buffer) so the broadcast is O(N habbos) wire writes,
* not O(N) compose calls.
*/
public class HousekeepingSendHotelAlertEvent extends MessageHandler {
private static final String ACTION_KEY = "hotel.alert";
@Override
public int getRatelimit() {
return 2000;
}
@Override
public void handle() throws Exception {
if (!this.client.getHabbo().hasPermission(Permission.ACC_HOUSEKEEPING)) {
return;
}
String message = this.packet.readString();
if (message == null || message.trim().isEmpty()) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.alert_empty"));
return;
}
String body = message + "\r\n-" + this.client.getHabbo().getHabboInfo().getUsername();
ServerMessage broadcast = new StaffAlertWithLinkComposer(body, "").compose();
int reached = 0;
for (Map.Entry<Integer, Habbo> entry : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().entrySet()) {
Habbo habbo = entry.getValue();
if (habbo == null || habbo.getClient() == null) continue;
if (habbo.getHabboStats() != null && habbo.getHabboStats().blockStaffAlerts) continue;
habbo.getClient().sendResponse(broadcast);
reached++;
}
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, reached, ""));
}
}
@@ -591,5 +591,7 @@ public class Outgoing {
public static final int HousekeepingActionResultComposer = 9201;
public static final int HousekeepingRoomDetailComposer = 9202;
public static final int HousekeepingRoomListComposer = 9203;
public static final int HousekeepingDashboardComposer = 9204;
public static final int HousekeepingActionLogComposer = 9205;
}
@@ -0,0 +1,65 @@
package com.eu.habbo.messages.outgoing.housekeeping;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing;
import java.util.List;
public class HousekeepingActionLogComposer extends MessageComposer {
public static class Row {
public final int id;
public final int timestamp;
public final int actorId;
public final String actorName;
public final String targetType;
public final int targetId;
public final String targetLabel;
public final String action;
public final String detail;
public final boolean success;
public Row(int id, int timestamp, int actorId, String actorName, String targetType, int targetId,
String targetLabel, String action, String detail, boolean success) {
this.id = id;
this.timestamp = timestamp;
this.actorId = actorId;
this.actorName = actorName != null ? actorName : "";
this.targetType = targetType != null ? targetType : "user";
this.targetId = targetId;
this.targetLabel = targetLabel != null ? targetLabel : "";
this.action = action != null ? action : "";
this.detail = detail != null ? detail : "";
this.success = success;
}
}
private final List<Row> rows;
public HousekeepingActionLogComposer(List<Row> rows) {
this.rows = rows;
}
@Override
protected ServerMessage composeInternal() {
this.response.init(Outgoing.HousekeepingActionLogComposer);
this.response.appendInt(this.rows != null ? this.rows.size() : 0);
if (this.rows != null) {
for (Row r : this.rows) {
this.response.appendInt(r.id);
this.response.appendInt(r.timestamp);
this.response.appendInt(r.actorId);
this.response.appendString(r.actorName);
this.response.appendString(r.targetType);
this.response.appendInt(r.targetId);
this.response.appendString(r.targetLabel);
this.response.appendString(r.action);
this.response.appendString(r.detail);
this.response.appendBoolean(r.success);
}
}
return this.response;
}
}
@@ -0,0 +1,50 @@
package com.eu.habbo.messages.outgoing.housekeeping;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing;
public class HousekeepingDashboardComposer extends MessageComposer {
private final int onlineUsers;
private final int totalUsers;
private final int activeRooms;
private final int totalRooms;
private final int peakOnlineToday;
private final int peakOnlineAllTime;
private final int pendingTickets;
private final int sanctionsLast24h;
private final int serverUptimeSeconds;
private final String serverVersion;
public HousekeepingDashboardComposer(int onlineUsers, int totalUsers, int activeRooms, int totalRooms,
int peakOnlineToday, int peakOnlineAllTime, int pendingTickets,
int sanctionsLast24h, int serverUptimeSeconds, String serverVersion) {
this.onlineUsers = onlineUsers;
this.totalUsers = totalUsers;
this.activeRooms = activeRooms;
this.totalRooms = totalRooms;
this.peakOnlineToday = peakOnlineToday;
this.peakOnlineAllTime = peakOnlineAllTime;
this.pendingTickets = pendingTickets;
this.sanctionsLast24h = sanctionsLast24h;
this.serverUptimeSeconds = serverUptimeSeconds;
this.serverVersion = serverVersion != null ? serverVersion : "";
}
@Override
protected ServerMessage composeInternal() {
this.response.init(Outgoing.HousekeepingDashboardComposer);
this.response.appendInt(this.onlineUsers);
this.response.appendInt(this.totalUsers);
this.response.appendInt(this.activeRooms);
this.response.appendInt(this.totalRooms);
this.response.appendInt(this.peakOnlineToday);
this.response.appendInt(this.peakOnlineAllTime);
this.response.appendInt(this.pendingTickets);
this.response.appendInt(this.sanctionsLast24h);
this.response.appendInt(this.serverUptimeSeconds);
this.response.appendString(this.serverVersion);
return this.response;
}
}