From 238c85d64b68b5589cb0634e4b0513130e57a935 Mon Sep 17 00:00:00 2001
From: Andrew <109876401+andrewdotdev@users.noreply.github.com>
Date: Mon, 6 Apr 2026 20:33:48 +0200
Subject: [PATCH 1/2] Revamp dashboard UI & refactor bot modules
Overhaul the web dashboard UX and structure bot logic. The public/index.html was redesigned with a modern, responsive layout (new typography, color system, cards, mobile behavior), added search/filtering for bots, presence ranking, improved SSE handling, live uptime/session calculations, mobile detail panel, and various UI polish. Backend code was refactored to extract bot concerns into src/bot/* (constants, types, chat, lifecycle, party, social handlers, patching) and update imports in src/bot.ts; supporting changes applied to src/cli.ts, src/manager.ts, src/server.ts and tsconfig.json. Adds new bot event handlers and data fields (e.g. lastInvite, stats defaults) and improves incremental DOM updates for the bot list.
---
public/index.html | 1123 ++++++++++++++---------------------
src/bot.ts | 621 ++++++-------------
src/bot/chat.ts | 534 +++++++++++++++++
src/bot/constants.ts | 30 +
src/bot/events/lifecycle.ts | 100 ++++
src/bot/events/party.ts | 251 ++++++++
src/bot/events/social.ts | 40 ++
src/bot/patch.ts | 98 +++
src/bot/types.ts | 45 ++
src/cli.ts | 844 ++++++++++++++++++++++----
src/manager.ts | 2 +-
src/server.ts | 6 +-
tsconfig.json | 18 +-
13 files changed, 2447 insertions(+), 1265 deletions(-)
create mode 100644 src/bot/chat.ts
create mode 100644 src/bot/constants.ts
create mode 100644 src/bot/events/lifecycle.ts
create mode 100644 src/bot/events/party.ts
create mode 100644 src/bot/events/social.ts
create mode 100644 src/bot/patch.ts
create mode 100644 src/bot/types.ts
diff --git a/public/index.html b/public/index.html
index 01e5382..0866188 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2,614 +2,341 @@
-
+
BluGlo โ Dashboard
-
- BLUGLO
-
diff --git a/src/bot.ts b/src/bot.ts
index dc32271..9f3f74b 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -2,70 +2,25 @@ import { Client } from "fnbr";
import { BOT, RECONNECT, TIMINGS } from "./config.js";
import { bus, log } from "./events.js";
+import { MatchmakingState, Presence, type PresenceValue } from "./bot/constants.js";
+import { registerLifecycleHandlers } from "./bot/events/lifecycle.js";
+import { registerPartyHandlers } from "./bot/events/party.js";
+import { registerSocialHandlers } from "./bot/events/social.js";
+import type { FnbrClient } from "./bot/types.js";
import type { BotManager } from "./manager.js";
-import type {
- AccountData,
- BotSnapshot,
- BotStats,
- MatchStateLike,
- PartyInvitationLike,
- PartyMemberLike,
-} from "./types.js";
-
-export const Presence = {
- ACTIVE: "active",
- BUSY: "busy",
- OFFLINE: "offline",
- LOADING: "loading",
-} as const;
-
-export const MatchmakingState = {
- NOT_MATCHMAKING: "NotMatchmaking",
- FINDING_EMPTY_SERVER: "FindingEmptyServer",
- JOINING_SESSION: "JoiningExistingSession",
- TESTING_SERVERS: "TestingEmptyServers",
-} as const;
-
-const FORT_HIGH = 92765;
-const FORT_LOW = 0;
-
-type PresenceValue = (typeof Presence)[keyof typeof Presence];
-type FnbrClient = InstanceType & {
- setTimeout: typeof setTimeout;
- clearTimeout: typeof clearTimeout;
- defaultStatus?: string;
- friend?: {
- pendingList?: Array<{
- direction?: string;
- decline?: () => Promise;
- }>;
- resolve?: (accountId: string) => {
- sendJoinRequest?: () => Promise;
- } | null;
- };
- user?: {
- self?: { displayName?: string };
- displayName?: string;
- };
- party?: {
- members?: { size: number };
- me?: {
- meta?: { schema?: Record };
- sendPatch?: (patch: Record) => Promise;
- };
- };
- xmpp?: {
- disconnect?: () => void;
- };
- leaveParty?: () => Promise;
- login: () => Promise;
- logout?: () => Promise;
- removeAllListeners: () => void;
- setStatus: (status: string, type: string) => void;
- on: (event: string, listener: (...args: any[]) => void) => void;
- once: (event: string, listener: (...args: any[]) => void) => void;
-};
-
+import type { AccountData, BotSnapshot, BotStats } from "./types.js";
+
+export { MatchmakingState, Presence };
+
+/**
+ * Main runtime wrapper around a single fnbr.js client.
+ *
+ * The class intentionally keeps connection helpers and shared state here,
+ * while the verbose event registration lives in src/bot/events/*.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ */
export class BluGlo {
public readonly accountId: string;
public readonly deviceId: string;
@@ -107,6 +62,37 @@ export class BluGlo {
return this.accountId.slice(0, 8);
}
+ public get timings() {
+ return TIMINGS;
+ }
+
+ /**
+ * Returns the uptime of the active session in seconds.
+ */
+ public getCurrentSessionUptimeSeconds(): number {
+ if (!this.stats.connectedAt) return 0;
+ return Math.max(0, Math.floor((Date.now() - this.stats.connectedAt) / 1000));
+ }
+
+ /**
+ * Returns the accumulated uptime plus the active session uptime.
+ */
+ public getLiveTotalUptimeSeconds(): number {
+ return this.stats.totalUptime + this.getCurrentSessionUptimeSeconds();
+ }
+
+ /**
+ * Moves the active session uptime into the accumulated total.
+ * This keeps totals correct across disconnects and reloads.
+ */
+ public accumulateUptime(): void {
+ const sessionUptime = this.getCurrentSessionUptimeSeconds();
+ if (sessionUptime > 0) {
+ this.stats.totalUptime += sessionUptime;
+ }
+ this.stats.connectedAt = null;
+ }
+
public get snapshot(): BotSnapshot {
return {
accountId: this.accountId,
@@ -115,13 +101,22 @@ export class BluGlo {
presence: this.presence,
status: this.status,
retryCount: this.retryCount,
- stats: this.stats,
+ stats: {
+ ...this.stats,
+ totalUptime: this.getLiveTotalUptimeSeconds(),
+ },
actions: this.actions,
};
}
+ /**
+ * Creates the fnbr.js client instance and wires all event handlers.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ */
public start(): void {
- this._setPresence(Presence.LOADING, "Connecting...");
+ this.setPresence(Presence.LOADING, "Connecting...");
const idleMsg = this.actions.idleStatus || BOT.idleStatus;
const busyMsg = this.actions.busyStatus || BOT.busyStatus;
@@ -159,443 +154,167 @@ export class BluGlo {
partyBuildId: "1:3:51618937",
}) as FnbrClient;
- const initTimer = setTimeout(() => {
- log(this.accountId, "error", "Startup timeout reached");
- this._setPresence(Presence.OFFLINE, "Startup timeout");
- this._scheduleReconnect();
- }, TIMINGS.initTimeoutMs);
-
- this.client.once("ready", () => {
- clearTimeout(initTimer);
- this.retryCount = 0;
- this.stats.connectedAt = Date.now();
- this._setPresence(Presence.ACTIVE, idleMsg);
- log(this.accountId, "ok", "Connected and ready");
-
- const resolvedDisplayName =
- this.client?.user?.self?.displayName ||
- this.client?.user?.displayName ||
- this.displayName ||
- null;
-
- if (resolvedDisplayName && resolvedDisplayName !== this.displayName) {
- this.displayName = resolvedDisplayName;
- this.manager?.updateDisplayName(this.accountId, resolvedDisplayName);
- }
-
- if (this.actions.denyFriendRequests) {
- const pendingList = this.client?.friend?.pendingList as any;
- const pending =
- pendingList?.filter?.((friend: any) => friend.direction === "INCOMING") ?? [];
- const pendingArray = Array.isArray(pending)
- ? pending
- : Array.from((pending?.values?.() ?? pending) as Iterable);
- for (const friend of pendingArray) {
- void friend.decline?.().catch(() => undefined);
- }
- if (pendingArray.length > 0) {
- log(this.accountId, "info", `Declined ${pendingArray.length} pending friend request(s)`);
- }
- }
-
- if (this.reJoinTo) {
- const friend = this.client?.friend?.resolve?.(this.reJoinTo);
- void friend?.sendJoinRequest?.().catch(() => undefined);
- log(this.accountId, "info", `Retrying join to ${this.reJoinTo.slice(0, 8)}`);
- this.reJoinTo = null;
- }
-
- this.keepaliveInterval = setInterval(
- () => {
- try {
- this.client?.setStatus(
- this.presence === Presence.BUSY
- ? this.actions.busyStatus || BOT.busyStatus
- : this.actions.idleStatus || BOT.idleStatus,
- "online",
- );
- } catch {
- log(this.accountId, "warn", "Keepalive failed, trying to reconnect...");
- if (this.keepaliveInterval) {
- clearInterval(this.keepaliveInterval);
- }
- this._onDisconnect();
- }
- },
- 1000 * 60 * 4,
- );
- });
-
- this.client.on("disconnected", () => this._onDisconnect());
- this.client.on("xmpp:message:error", (error: unknown) => this._onXmppError(error));
-
- this.client.on("party:member:disconnected", (member: PartyMemberLike) => {
- if (member.id === this.accountId) this._onDisconnect();
- });
- this.client.on("party:member:expired", (member: PartyMemberLike) => {
- if (member.id === this.accountId) this._onDisconnect();
- });
- this.client.on("party:member:kicked", (member: PartyMemberLike) => {
- if (member.id === this.accountId) this._returnToIdle(idleMsg);
- });
-
- this.client.on("party:member:left", (member: PartyMemberLike) => {
- const alone =
- member.party.members.size === 1 && member.party.members.first?.()?.id === this.accountId;
- if (member.id === this.accountId || alone) {
- this._returnToIdle(idleMsg);
- }
- });
-
- this.client.on(
- "friend:request",
- (
- incoming: PartyInvitationLike["sender"] & {
- accept?: () => Promise;
- },
- ) => {
- if (!incoming) return;
- if (this.actions.denyFriendRequests) {
- void incoming.decline?.().catch(() => undefined);
- log(this.accountId, "info", `Declined friend request from ${incoming.displayName}`);
- } else {
- void incoming.accept?.().catch(() => undefined);
- log(this.accountId, "info", `Accepted friend request from ${incoming.displayName}`);
- }
- },
- );
+ registerLifecycleHandlers(this, idleMsg);
+ registerSocialHandlers(this);
+ registerPartyHandlers(this, idleMsg, busyMsg);
+ }
- this.client.on("friend:added", (friend: { id: string; displayName?: string }) => {
- log(this.accountId, "info", `New friend: ${friend.displayName}`);
- bus.emit("friend", {
- accountId: this.accountId,
- friendId: friend.id,
- displayName: friend.displayName,
- });
- });
+ /**
+ * Stops the current bot instance and closes listeners/connections.
+ *
+ * @see https://nodejs.org/api/events.html
+ */
+ public stop(): void {
+ this.clearPartyTimeout();
+ this.accumulateUptime();
- this.client.on("party:member:joined", async (member: PartyMemberLike) => {
- try {
- const schema = member.party.meta?.schema ?? {};
- const campaignInfo = JSON.parse(schema["Default:CampaignInfo_j"] ?? "{}") as {
- CampaignInfo?: { matchmakingState?: string };
- };
- const state = campaignInfo.CampaignInfo?.matchmakingState;
-
- if (state && state !== MatchmakingState.NOT_MATCHMAKING && member.id === this.accountId) {
- log(
- this.accountId,
- "warn",
- "Party already in matchmaking when joined โ leaving immediately",
- );
- await this.client?.leaveParty?.().catch(() => undefined);
- this._returnToIdle(idleMsg);
- return;
- }
- } catch {
- // ignore invalid campaign info payloads
- }
+ if (this.keepaliveInterval) {
+ clearInterval(this.keepaliveInterval);
+ this.keepaliveInterval = null;
+ }
- const collision = await this.manager?.handleCollision(member.party, this);
- if (collision) return;
-
- const members = member.party.members
- .map((partyMember) => ({
- accountId: partyMember.id,
- displayName: partyMember.displayName,
- isLeader: partyMember.isLeader,
- }))
- .filter((partyMember) => partyMember.accountId !== this.accountId);
-
- bus.emit("joined", { accountId: this.accountId, members });
- log(
- this.accountId,
- "info",
- `Party members: ${members.map((partyMember) => partyMember.displayName || partyMember.accountId.slice(0, 8)).join(", ")}`,
- );
- });
-
- this.client.on("party:invite", async (invitation: PartyInvitationLike) => {
- const senderName = invitation.sender?.displayName ?? invitation.sender?.id?.slice(0, 8);
- log(this.accountId, "info", `Party invite from ${senderName}`);
- bus.emit("invite", {
- accountId: this.accountId,
- from: senderName,
- fromId: invitation.sender?.id,
- });
-
- if (this.presence === Presence.BUSY) {
- log(this.accountId, "info", "Declining (busy)");
- this.stats.invitesDeclined++;
- void invitation.decline?.().catch(() => undefined);
- return;
- }
+ this.client?.removeAllListeners();
+ this.client?.xmpp?.disconnect?.();
+ void this.client?.logout?.().catch(() => undefined);
+ this.setPresence(Presence.OFFLINE, "Stopped");
+ log(this.accountId, "info", "Bot stopped");
+ }
- if ((invitation.party?.members?.size ?? 0) >= BOT.partyMaxSize) {
- log(this.accountId, "info", "Declining (party full)");
- this.stats.invitesDeclined++;
- void invitation.decline?.().catch(() => undefined);
- return;
- }
+ /**
+ * Returns the bot to idle after leaving a party or finishing a taxi.
+ */
+ public returnToIdle(idleMsg?: string): void {
+ this.clearPartyTimeout();
+ this.setPresence(Presence.ACTIVE, idleMsg || BOT.idleStatus);
+ this.client?.setStatus?.(idleMsg || BOT.idleStatus, "online");
+ }
- if ((this.client?.party?.members?.size ?? 1) > 1) {
- log(this.accountId, "info", "Declining (already in party)");
- this.stats.invitesDeclined++;
- void invitation.decline?.().catch(() => undefined);
- return;
- }
+ /**
+ * Updates the in-memory presence and broadcasts it to the dashboard.
+ */
+ public setPresence(presence: PresenceValue, status: string): void {
+ this.presence = presence;
+ this.status = status;
+ bus.emit("status", { accountId: this.accountId, presence, status });
+ }
- if (this.manager?.hasOtherTaxiIn(invitation.party, this.accountId)) {
- log(this.accountId, "info", "Declining (another taxi already in that party)");
- this.stats.invitesDeclined++;
- void invitation.decline?.().catch(() => undefined);
- return;
- }
+ /**
+ * Clears the current auto-leave timer for a joined party.
+ */
+ public clearPartyTimeout(): void {
+ if (this.currentTimeout != null) {
+ this.client?.clearTimeout?.(this.currentTimeout);
+ this.currentTimeout = null;
+ }
+ }
- try {
- const { isPlaying, sessionId } = invitation.sender?.presence ?? {};
- if (isPlaying || sessionId) {
- log(this.accountId, "info", "Declining (sender already in match)");
- this.stats.invitesDeclined++;
- void invitation.decline?.().catch(() => undefined);
- return;
- }
- } catch {
- // ignore presence parsing issues
- }
+ /**
+ * Starts a lightweight keepalive so the status stays fresh.
+ */
+ public startKeepalive(): void {
+ if (this.keepaliveInterval) {
+ clearInterval(this.keepaliveInterval);
+ this.keepaliveInterval = null;
+ }
+ const tick = () => {
try {
- this._setPresence(Presence.BUSY, busyMsg);
- await invitation.accept();
- this.client?.setStatus(busyMsg, "online");
+ if (!this.client) return;
- if (TIMINGS.postAcceptDelayMs > 0) {
- await new Promise((resolve) => setTimeout(resolve, TIMINGS.postAcceptDelayMs));
- }
+ const status =
+ this.presence === Presence.BUSY
+ ? this.actions.busyStatus || BOT.busyStatus
+ : this.actions.idleStatus || BOT.idleStatus;
- await this._applyPatch();
- log(this.accountId, "ok", `In party with ${senderName} โ patch applied`);
-
- this._clearTimeout();
- this.currentTimeout =
- this.client?.setTimeout(() => {
- log(this.accountId, "warn", "Party timeout โ leaving");
- void this.client?.leaveParty?.().catch(() => undefined);
- this.currentTimeout = null;
- this._returnToIdle(idleMsg);
- }, TIMINGS.partyAutoLeaveMs) ?? null;
- } catch (error: any) {
+ this.client.setStatus?.(status, "online");
+ } catch (error) {
log(
this.accountId,
- "error",
- `Error while accepting invite: ${error?.code ?? error?.message ?? String(error)}`,
+ "warn",
+ `Keepalive failed: ${error instanceof Error ? error.message : String(error)}`,
);
- this._setPresence(Presence.ACTIVE, idleMsg);
- this.reJoinTo = invitation.sender?.id ?? null;
- this._onXmppError(error);
- }
- });
-
- this.client.on(
- "party:member:matchstate:updated",
- (member: PartyMemberLike, value: MatchStateLike, prev: MatchStateLike) => {
- void member;
- const from = `${prev?.location}`;
- const to = `${value?.location}`;
-
- if (from === "PreLobby" && to === "ConnectingToLobby") {
- log(
- this.accountId,
- "ok",
- `Matchmaking detected โ leaving in ${TIMINGS.matchstateLeaveDelayMs}ms`,
- );
-
- this.client?.setTimeout(async () => {
- await this.client?.leaveParty?.().catch(() => undefined);
- this._clearTimeout();
- this.stats.taxisCompleted++;
- this._returnToIdle(idleMsg);
- log(this.accountId, "ok", `Taxi completed #${this.stats.taxisCompleted}`);
- }, TIMINGS.matchstateLeaveDelayMs);
+
+ if (this.keepaliveInterval) {
+ clearInterval(this.keepaliveInterval);
+ this.keepaliveInterval = null;
}
- },
- );
- void this.client.login().catch((error: Error) => {
- log(this.accountId, "error", `Login error: ${error?.message}`);
- clearTimeout(initTimer);
- this._setPresence(Presence.OFFLINE, "Login error");
- this._scheduleReconnect();
- });
+ this.handleDisconnect();
+ }
+ };
+
+ tick();
+
+ this.keepaliveInterval = setInterval(tick, 1000 * 60 * 4);
}
- public stop(): void {
- this._clearTimeout();
+ /**
+ * Called when the client is disconnected unexpectedly.
+ */
+ public handleDisconnect(): void {
if (this.keepaliveInterval) {
clearInterval(this.keepaliveInterval);
this.keepaliveInterval = null;
}
- this.client?.removeAllListeners();
- this.client?.xmpp?.disconnect?.();
- void this.client?.logout?.().catch(() => undefined);
- this._setPresence(Presence.OFFLINE, "Stopped");
- log(this.accountId, "info", "Bot stopped");
- }
- public _returnToIdle(idleMsg?: string): void {
- this._clearTimeout();
- this._setPresence(Presence.ACTIVE, idleMsg || BOT.idleStatus);
- this.client?.setStatus?.(idleMsg || BOT.idleStatus, "online");
- }
-
- private _onDisconnect(): void {
- this._setPresence(Presence.OFFLINE, "Disconnected");
+ this.accumulateUptime();
+ this.setPresence(Presence.OFFLINE, "Disconnected");
this.retryCount++;
if (this.retryCount <= RECONNECT.maxRetries) {
- log(
- this.accountId,
- "warn",
- `Disconnected โ retry ${this.retryCount}/${RECONNECT.maxRetries}`,
- );
- this._scheduleReconnect();
- } else {
- log(this.accountId, "error", `Maximum retries reached (${RECONNECT.maxRetries})`);
- this._setPresence(Presence.OFFLINE, "Permanent error โ use /reload ");
+ log(this.accountId, "warn", `Disconnected โ retry ${this.retryCount}/${RECONNECT.maxRetries}`);
+ this.scheduleReconnect();
+ return;
}
+
+ log(this.accountId, "error", `Maximum retries reached (${RECONNECT.maxRetries})`);
+ this.setPresence(Presence.OFFLINE, "Permanent error โ use /reload ");
}
- private _onXmppError(error: unknown): void {
+ /**
+ * Handles fnbr/XMPP errors that should trigger a reconnect.
+ */
+ public handleXmppError(error: unknown): void {
const code =
typeof (error as { code?: string })?.code === "string"
? (error as { code: string }).code.toLowerCase()
: "";
- const shouldReconnect = ["disconnect", "invalid_refresh_token", "party_not_found"].some(
- (value) => code.includes(value),
+
+ const shouldReconnect = ["disconnect", "invalid_refresh_token", "party_not_found"].some((value) =>
+ code.includes(value),
);
if (shouldReconnect) {
- this._onDisconnect();
+ this.handleDisconnect();
}
}
- private _scheduleReconnect(): void {
+ /**
+ * Schedules a clean reconnect by rebuilding the client instance.
+ */
+ public scheduleReconnect(): void {
setTimeout(() => {
log(this.accountId, "info", "Reconnecting...");
- this._cleanup();
+ this.cleanup();
this.start();
}, TIMINGS.reconnectDelayMs);
}
- private _cleanup(): void {
- this._clearTimeout();
+ /**
+ * Releases listeners and network resources before reconnecting.
+ */
+ public cleanup(): void {
+ this.clearPartyTimeout();
+ this.accumulateUptime();
+
if (this.keepaliveInterval) {
clearInterval(this.keepaliveInterval);
this.keepaliveInterval = null;
}
- this.client?.removeAllListeners?.();
+
+ this.client?.removeAllListeners();
this.client?.xmpp?.disconnect?.();
void this.client?.logout?.().catch(() => undefined);
this.client = null;
}
-
- private _setPresence(presence: PresenceValue, status: string): void {
- this.presence = presence;
- this.status = status;
- bus.emit("status", { accountId: this.accountId, presence, status });
- }
-
- private _clearTimeout(): void {
- if (this.currentTimeout != null) {
- this.client?.clearTimeout?.(this.currentTimeout);
- this.currentTimeout = null;
- }
- }
-
- private async _applyPatch(): Promise {
- const stat = this.actions.high ? FORT_HIGH : FORT_LOW;
-
- const schema = this.client?.party?.me?.meta?.schema ?? {};
- const _mpLoadout1 = (() => {
- const value = schema["Default:MpLoadout1_j"];
- if (!value) return null;
-
- if (typeof value === "string") {
- try {
- return JSON.parse(value) as unknown;
- } catch {
- return null;
- }
- }
-
- if (typeof value === "object") {
- return value;
- }
-
- return null;
- })();
- void _mpLoadout1;
-
- const cosmetics: Record = {
- "Default:MpLoadout1_j": JSON.stringify({
- MpLoadout1: {
- s: {
- ac: { i: "CID_039_Athena_Commando_F_Disco", v: ["0"] },
- li: { i: "StandardBanner20", v: [] },
- lc: { i: "DefaultColor2", v: [] },
- },
- },
- }),
- };
-
- const patch: Record = {
- "Default:FORTStats_j": JSON.stringify({
- FORTStats: {
- fortitude: stat,
- offense: stat,
- resistance: stat,
- tech: stat,
- teamFortitude: 0,
- teamOffense: 0,
- teamResistance: 0,
- teamTech: 0,
- fortitude_Phoenix: stat,
- offense_Phoenix: stat,
- resistance_Phoenix: stat,
- tech_Phoenix: stat,
- teamFortitude_Phoenix: 0,
- teamOffense_Phoenix: 0,
- teamResistance_Phoenix: 0,
- teamTech_Phoenix: 0,
- },
- }),
- "Default:PackedState_j": JSON.stringify({
- PackedState: {
- subGame: "Campaign",
- location: "PreLobby",
- gameMode: "None",
- voiceChatStatus: "PartyVoice",
- hasCompletedSTWTutorial: true,
- hasPurchasedSTW: true,
- platformSupportsSTW: true,
- bReturnToLobbyAndReadyUp: false,
- bHideReadyUp: false,
- bDownloadOnDemandActive: false,
- bIsPartyLFG: false,
- bShouldRecordPartyChannel: false,
- },
- }),
- ...cosmetics,
- };
-
- if (this.actions.high) {
- patch["Default:CampaignCommanderLoadoutRating_d"] = "999.00";
- patch["Default:CampaignBackpackRating_d"] = "999.000000";
- }
-
- await this.client?.party?.me?.sendPatch?.(patch);
- setTimeout(() => {
- void this.client?.party?.me?.setEmote("EID_Hype").catch(() => undefined);
- }, 1000);
- }
}
diff --git a/src/bot/chat.ts b/src/bot/chat.ts
new file mode 100644
index 0000000..0bac9ed
--- /dev/null
+++ b/src/bot/chat.ts
@@ -0,0 +1,534 @@
+import { generateKeyPairSync, randomUUID, sign } from "node:crypto";
+import type { BluGlo } from "../bot.js";
+
+type RawChatKind = "dm" | "party";
+type SignedMessageType = "Persistent" | "Party";
+
+interface FnbrSessionLike {
+ accessToken?: string;
+ token?: string;
+}
+
+interface RegisteredKeyData {
+ jwt: string;
+ [key: string]: unknown;
+}
+
+interface DMConversationResponse {
+ conversationId: string;
+ isReportable?: boolean;
+}
+
+interface RawChatCache {
+ privateKey?: CryptoKeyLike;
+ publicKeyBase64?: string;
+ registeredKey?: RegisteredKeyData;
+ dmConversationIds: Map;
+ dmReportable: Map;
+}
+
+interface CryptoKeyLike {
+ // Minimal shape for node:crypto sign()
+}
+
+const EOS_CHAT_BASE = "https://api.epicgames.dev";
+const PUBLIC_KEY_BASE = "https://publickey-service-live.ecosec.on.epicgames.com";
+
+const RAW_CHAT_CACHE_SYMBOL = Symbol.for("bluglo.rawChatCache");
+
+/**
+ * Returns or creates the per-client raw chat cache.
+ */
+function getRawChatCache(bot: BluGlo): RawChatCache {
+ if (!bot.client) {
+ throw new Error("Client is not initialized");
+ }
+
+ const target = bot.client as unknown as {
+ [RAW_CHAT_CACHE_SYMBOL]?: RawChatCache;
+ };
+
+ const existing = target[RAW_CHAT_CACHE_SYMBOL];
+ if (existing) return existing;
+
+ const created: RawChatCache = {
+ dmConversationIds: new Map(),
+ dmReportable: new Map(),
+ };
+
+ target[RAW_CHAT_CACHE_SYMBOL] = created;
+ return created;
+}
+
+/**
+ * Small sleep helper for retries / pacing.
+ */
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Splits long messages into smaller chunks.
+ */
+function chunkMessage(text: string, maxLength = 240): string[] {
+ const clean = text.replace(/\r/g, "").trim();
+ if (!clean) return [];
+
+ const lines = clean.split("\n");
+ const chunks: string[] = [];
+ let current = "";
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ if (!trimmed) {
+ if (current && !current.endsWith("\n")) current += "\n";
+ continue;
+ }
+
+ if (trimmed.length > maxLength) {
+ if (current) {
+ chunks.push(current.trim());
+ current = "";
+ }
+
+ const words = trimmed.split(" ");
+ let partial = "";
+
+ for (const word of words) {
+ const test = partial ? `${partial} ${word}` : word;
+
+ if (test.length <= maxLength) {
+ partial = test;
+ } else {
+ if (partial) chunks.push(partial.trim());
+
+ if (word.length > maxLength) {
+ for (let i = 0; i < word.length; i += maxLength) {
+ chunks.push(word.slice(i, i + maxLength));
+ }
+ partial = "";
+ } else {
+ partial = word;
+ }
+ }
+ }
+
+ if (partial) chunks.push(partial.trim());
+ continue;
+ }
+
+ const test = current ? `${current}\n${trimmed}` : trimmed;
+
+ if (test.length <= maxLength) {
+ current = test;
+ } else {
+ if (current) chunks.push(current.trim());
+ current = trimmed;
+ }
+ }
+
+ if (current) chunks.push(current.trim());
+
+ return chunks.filter(Boolean);
+}
+
+/**
+ * Gets the logged-in account id from fnbr's client.
+ */
+function getSelfAccountId(bot: BluGlo): string {
+ const selfId =
+ (bot.client as any)?.user?.self?.id ??
+ (bot.client as any)?.user?.id ??
+ bot.accountId;
+
+ if (!selfId) {
+ throw new Error("Missing self account id");
+ }
+
+ return selfId;
+}
+
+/**
+ * Gets the EOS deployment id from the logged-in fnbr client.
+ */
+function getDeploymentId(bot: BluGlo): string {
+ const deploymentId = (bot.client as any)?.config?.eosDeploymentId;
+ if (!deploymentId) {
+ throw new Error("Missing EOS deployment id");
+ }
+ return deploymentId;
+}
+
+/**
+ * Extracts a token from fnbr auth sessions.
+ *
+ * fnbr internally stores sessions by keys like "fortnite" and "fortniteEOS".
+ * We reuse those sessions instead of logging in again.
+ */
+function getSessionAccessToken(bot: BluGlo, key: "fortnite" | "fortniteEOS"): string {
+ const sessions = (bot.client as any)?.auth?.sessions as Map | undefined;
+ const session = sessions?.get(key);
+
+ const token = session?.accessToken ?? session?.token;
+ if (!token) {
+ throw new Error(`Missing access token for session "${key}"`);
+ }
+
+ return token;
+}
+
+/**
+ * Generates and stores an ed25519 keypair in memory.
+ */
+function ensureKeypair(bot: BluGlo): void {
+ const cache = getRawChatCache(bot);
+ if (cache.privateKey && cache.publicKeyBase64) return;
+
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
+ const spkiDer = publicKey.export({ type: "spki", format: "der" }) as Buffer;
+ const rawPublicKey = spkiDer.subarray(spkiDer.length - 32);
+
+ cache.privateKey = privateKey as unknown as CryptoKeyLike;
+ cache.publicKeyBase64 = rawPublicKey.toString("base64");
+}
+
+/**
+ * Registers the public key on Epic's public key service if needed.
+ */
+async function ensureRegisteredPublicKey(bot: BluGlo): Promise {
+ const cache = getRawChatCache(bot);
+ if (cache.registeredKey?.jwt) {
+ return cache.registeredKey;
+ }
+
+ ensureKeypair(bot);
+
+ const fortniteToken = getSessionAccessToken(bot, "fortnite");
+
+ const response = await fetch(`${PUBLIC_KEY_BASE}/publickey/v2/publickey/`, {
+ method: "POST",
+ headers: {
+ Authorization: `bearer ${fortniteToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ key: cache.publicKeyBase64,
+ algorithm: "ed25519",
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Public key registration failed: ${response.status} ${await response.text()}`);
+ }
+
+ const json = (await response.json()) as RegisteredKeyData;
+ if (!json?.jwt) {
+ throw new Error("Public key registration succeeded but jwt is missing");
+ }
+
+ cache.registeredKey = json;
+ return json;
+}
+
+/**
+ * Creates a signed EOS chat body exactly in the style used by rebootpy/fnbr:
+ * body = base64(JSON(messageInfo))
+ * signature = ed25519-sign(body + \\0)
+ */
+async function createSignedMessage(
+ bot: BluGlo,
+ conversationId: string,
+ content: string,
+ kind: RawChatKind,
+): Promise<{ body: string; signature: string }> {
+ ensureKeypair(bot);
+
+ const selfId = getSelfAccountId(bot);
+ const cache = getRawChatCache(bot);
+
+ const messageInfo = {
+ mid: randomUUID(),
+ sid: selfId,
+ rid: conversationId,
+ msg: content,
+ tst: Date.now(),
+ seq: 1,
+ rec: false,
+ mts: [],
+ cty: (kind === "party" ? "Party" : "Persistent") as SignedMessageType,
+ };
+
+ const body = Buffer.from(JSON.stringify(messageInfo), "utf8").toString("base64");
+ const messageToSign = Buffer.concat([Buffer.from(body, "utf8"), Buffer.from([0])]);
+
+ const signature = sign(null, messageToSign, cache.privateKey as any).toString("base64");
+
+ return { body, signature };
+}
+
+/**
+ * Creates or reuses a DM conversation id for a target user.
+ */
+async function getOrCreateDMConversation(
+ bot: BluGlo,
+ targetAccountId: string,
+): Promise<{ conversationId: string; isReportable: boolean }> {
+ const cache = getRawChatCache(bot);
+
+ const cachedConversationId = cache.dmConversationIds.get(targetAccountId);
+ const cachedReportable = cache.dmReportable.get(targetAccountId);
+
+ if (cachedConversationId && typeof cachedReportable === "boolean") {
+ return {
+ conversationId: cachedConversationId,
+ isReportable: cachedReportable,
+ };
+ }
+
+ const selfId = getSelfAccountId(bot);
+ const eosToken = getSessionAccessToken(bot, "fortniteEOS");
+
+ const response = await fetch(
+ `${EOS_CHAT_BASE}/epic/chat/v1/public/_/conversations?createIfExists=false`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `bearer ${eosToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ title: "",
+ type: "dm",
+ members: [selfId, targetAccountId],
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`DM conversation lookup failed: ${response.status} ${await response.text()}`);
+ }
+
+ const json = (await response.json()) as DMConversationResponse;
+
+ if (!json?.conversationId) {
+ throw new Error("DM conversation response missing conversationId");
+ }
+
+ const isReportable = Boolean(json.isReportable);
+
+ cache.dmConversationIds.set(targetAccountId, json.conversationId);
+ cache.dmReportable.set(targetAccountId, isReportable);
+
+ return {
+ conversationId: json.conversationId,
+ isReportable,
+ };
+}
+
+/**
+ * Low-level EOS chat sender.
+ */
+async function postChatMessage(
+ bot: BluGlo,
+ args: {
+ conversationId: string;
+ kind: RawChatKind;
+ text: string;
+ allowedRecipients: string[];
+ isReportable: boolean;
+ },
+): Promise {
+ const eosToken = getSessionAccessToken(bot, "fortniteEOS");
+ const selfId = getSelfAccountId(bot);
+ const deploymentId = getDeploymentId(bot);
+ const keyData = await ensureRegisteredPublicKey(bot);
+ const { body, signature } = await createSignedMessage(
+ bot,
+ args.conversationId,
+ args.text,
+ args.kind,
+ );
+
+ const namespace = args.kind === "dm" ? "_" : deploymentId;
+ const url = `${EOS_CHAT_BASE}/epic/chat/v1/public/${namespace}/conversations/${args.conversationId}/messages?fromAccountId=${selfId}`;
+
+ const platform = (bot.client as any)?.config?.platform ?? "WIN";
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `bearer ${eosToken}`,
+ "Content-Type": "application/json",
+ "X-Epic-Correlation-ID": `EOS-${Date.now()}-${randomUUID()}`,
+ },
+ body: JSON.stringify({
+ allowedRecipients: args.allowedRecipients,
+ message: { body },
+ isReportable: args.isReportable,
+ metadata: {
+ TmV: "2",
+ Pub: keyData.jwt,
+ Sig: signature,
+ NPM: args.kind === "party" ? "1" : undefined,
+ PlfNm: platform,
+ PlfId: selfId,
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Chat send failed: ${response.status} ${await response.text()}`);
+ }
+}
+
+/**
+ * Sends a whisper/DM using pure HTTP requests, but reusing fnbr's live auth sessions.
+ */
+export async function sendWhisperRaw(
+ bot: BluGlo,
+ targetAccountId: string,
+ text: string,
+ options?: {
+ delayMs?: number;
+ chunkLength?: number;
+ betweenChunksMs?: number;
+ },
+): Promise {
+ try {
+ if (!targetAccountId) {
+ return false;
+ }
+
+ if (targetAccountId === getSelfAccountId(bot)) {
+ return false;
+ }
+
+ if (options?.delayMs) {
+ await sleep(options.delayMs);
+ }
+
+ const chunks = chunkMessage(text, options?.chunkLength ?? 240);
+ if (chunks.length === 0) {
+ return false;
+ }
+
+ const selfId = getSelfAccountId(bot);
+ const dm = await getOrCreateDMConversation(bot, targetAccountId);
+
+ for (let i = 0; i < chunks.length; i++) {
+ await postChatMessage(bot, {
+ conversationId: dm.conversationId,
+ kind: "dm",
+ text: chunks[i]!,
+ allowedRecipients: [targetAccountId, selfId],
+ isReportable: dm.isReportable,
+ });
+
+ if (i < chunks.length - 1 && (options?.betweenChunksMs ?? 500) > 0) {
+ await sleep(options?.betweenChunksMs ?? 500);
+ }
+ }
+
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
+/**
+ * Sends a party chat message using pure HTTP requests, but reusing fnbr's live auth sessions.
+ */
+export async function sendPartyMessageRaw(
+ bot: BluGlo,
+ text: string,
+ options?: {
+ delayMs?: number;
+ chunkLength?: number;
+ betweenChunksMs?: number;
+ },
+): Promise {
+ try {
+ if (options?.delayMs) {
+ await sleep(options.delayMs);
+ }
+
+ const party = bot.client?.party;
+ if (!party?.id) {
+ return false;
+ }
+
+ const members = [...(party.members ?? [])];
+ const selfId = getSelfAccountId(bot);
+
+ const recipients = members
+ .map((member: any) => member.id)
+ .filter((id: string) => id && id !== selfId);
+
+ if (recipients.length === 0) {
+ return false;
+ }
+
+ const chunks = chunkMessage(text, options?.chunkLength ?? 240);
+ if (chunks.length === 0) {
+ return false;
+ }
+
+ const conversationId = `p-${party.id}`;
+
+ for (let i = 0; i < chunks.length; i++) {
+ await postChatMessage(bot, {
+ conversationId,
+ kind: "party",
+ text: chunks[i]!,
+ allowedRecipients: recipients,
+ isReportable: false,
+ });
+
+ if (i < chunks.length - 1 && (options?.betweenChunksMs ?? 450) > 0) {
+ await sleep(options?.betweenChunksMs ?? 450);
+ }
+ }
+
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
+/**
+ * Convenience helper.
+ */
+export async function sendRawChat(
+ bot: BluGlo,
+ payload:
+ | {
+ type: "party";
+ text: string;
+ delayMs?: number;
+ chunkLength?: number;
+ betweenChunksMs?: number;
+ }
+ | {
+ type: "whisper";
+ userId: string;
+ text: string;
+ delayMs?: number;
+ chunkLength?: number;
+ betweenChunksMs?: number;
+ },
+): Promise {
+ if (payload.type === "party") {
+ return sendPartyMessageRaw(bot, payload.text, {
+ delayMs: payload.delayMs,
+ chunkLength: payload.chunkLength,
+ betweenChunksMs: payload.betweenChunksMs,
+ });
+ }
+
+ return sendWhisperRaw(bot, payload.userId, payload.text, {
+ delayMs: payload.delayMs,
+ chunkLength: payload.chunkLength,
+ betweenChunksMs: payload.betweenChunksMs,
+ });
+}
\ No newline at end of file
diff --git a/src/bot/constants.ts b/src/bot/constants.ts
new file mode 100644
index 0000000..0759281
--- /dev/null
+++ b/src/bot/constants.ts
@@ -0,0 +1,30 @@
+/**
+ * High-level runtime presence shown in the dashboard and CLI.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ */
+export const Presence = {
+ ACTIVE: "active",
+ BUSY: "busy",
+ OFFLINE: "offline",
+ LOADING: "loading",
+} as const;
+
+/**
+ * Save the World matchmaking states used by Fortnite party metadata.
+ *
+ * @see https://github.com/MixV2/EpicResearch
+ */
+export const MatchmakingState = {
+ NOT_MATCHMAKING: "NotMatchmaking",
+ FINDING_EMPTY_SERVER: "FindingEmptyServer",
+ JOINING_SESSION: "JoiningExistingSession",
+ TESTING_SERVERS: "TestingEmptyServers",
+} as const;
+
+export const FORT_HIGH = 92765;
+export const FORT_LOW = 0;
+export const PARTY_PREFIX = "?";
+
+export type PresenceValue = (typeof Presence)[keyof typeof Presence];
diff --git a/src/bot/events/lifecycle.ts b/src/bot/events/lifecycle.ts
new file mode 100644
index 0000000..e389391
--- /dev/null
+++ b/src/bot/events/lifecycle.ts
@@ -0,0 +1,100 @@
+import { BOT } from "../../config.js";
+import { log } from "../../events.js";
+import { Presence } from "../constants.js";
+import type { BluGlo } from "../../bot.js";
+import type { PartyMemberLike } from "../../types.js";
+
+/**
+ * Registers lifecycle and connection handlers.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ */
+export function registerLifecycleHandlers(bot: BluGlo, idleMsg: string): void {
+ const initTimer = setTimeout(() => {
+ log(bot.accountId, "error", "Startup timeout reached");
+ bot.setPresence(Presence.OFFLINE, "Startup timeout");
+ bot.scheduleReconnect();
+ }, bot.timings.initTimeoutMs);
+
+ bot.client?.once("ready", () => {
+ clearTimeout(initTimer);
+ bot.retryCount = 0;
+ bot.stats.connectedAt = Date.now();
+ bot.setPresence(Presence.ACTIVE, idleMsg);
+ bot.startKeepalive();
+ log(bot.accountId, "ok", "Connected and ready");
+
+ const resolvedDisplayName =
+ bot.client?.user?.self?.displayName ||
+ bot.client?.user?.displayName ||
+ bot.displayName ||
+ null;
+
+ if (resolvedDisplayName && resolvedDisplayName !== bot.displayName) {
+ bot.displayName = resolvedDisplayName;
+ bot.manager?.updateDisplayName(bot.accountId, resolvedDisplayName);
+ }
+
+ if (bot.actions.denyFriendRequests) {
+ const pendingList = bot.client?.friend?.pendingList as any;
+ const pending = pendingList?.filter?.((friend: any) => friend.direction === "INCOMING") ?? [];
+ const pendingArray = Array.isArray(pending)
+ ? pending
+ : Array.from((pending?.values?.() ?? pending) as Iterable);
+
+ for (const friend of pendingArray) {
+ void friend.decline?.().catch(() => undefined);
+ }
+
+ if (pendingArray.length > 0) {
+ log(bot.accountId, "info", `Declined ${pendingArray.length} pending friend request(s)`);
+ }
+ }
+
+ if (bot.reJoinTo) {
+ const friend = bot.client?.friend?.resolve?.(bot.reJoinTo);
+ void friend?.sendJoinRequest?.().catch(() => undefined);
+ log(bot.accountId, "info", `Retrying join to ${bot.reJoinTo.slice(0, 8)}`);
+ bot.reJoinTo = null;
+ }
+ });
+
+ bot.client?.on("disconnected", () => bot.handleDisconnect());
+ bot.client?.on("xmpp:message:error", (error: unknown) => bot.handleXmppError(error));
+
+ bot.client?.on("party:member:disconnected", (member: PartyMemberLike) => {
+ if (member.id === bot.accountId) bot.handleDisconnect();
+ });
+
+ bot.client?.on("party:member:expired", (member: PartyMemberLike) => {
+ if (member.id === bot.accountId) bot.handleDisconnect();
+ });
+
+ bot.client?.on("party:member:kicked", (member: PartyMemberLike) => {
+ if (member.id === bot.accountId) bot.returnToIdle(idleMsg);
+ });
+
+ bot.client?.on("party:member:left", (member: PartyMemberLike) => {
+ const alone = member.party.members.size === 1 && member.party.members.first?.()?.id === bot.accountId;
+
+ if (member.id === bot.accountId || alone) {
+ bot.returnToIdle(idleMsg || BOT.idleStatus);
+ }
+ });
+
+ bot.client?.on("party:member:disconnected", (member: PartyMemberLike) => {
+ const alone = member.party.members.size === 1 && member.party.members.first?.()?.id === bot.accountId;
+
+ if (member.id === bot.accountId || alone) {
+ bot.returnToIdle(idleMsg || BOT.idleStatus);
+ }
+ });
+
+ bot.client?.login().catch((error: Error) => {
+ log(bot.accountId, "error", `Login error: ${error?.message}`);
+ clearTimeout(initTimer);
+ bot.setPresence(Presence.OFFLINE, "Login error");
+ bot.scheduleReconnect();
+ });
+}
diff --git a/src/bot/events/party.ts b/src/bot/events/party.ts
new file mode 100644
index 0000000..6335491
--- /dev/null
+++ b/src/bot/events/party.ts
@@ -0,0 +1,251 @@
+import { BOT } from "../../config.js";
+import { bus, log } from "../../events.js";
+import { MatchmakingState, Presence } from "../constants.js";
+import { applyPartyPatch } from "../patch.js";
+import type { BluGlo } from "../../bot.js";
+import { PARTY_PREFIX } from "../constants.js";
+import type { MatchStateLike, PartyInvitationLike, PartyMemberLike } from "../../types.js";
+import type { PartyMember, PartyMessage } from "fnbr";
+import { sendPartyMessageRaw, sendWhisperRaw } from "../chat.js";
+
+/**
+ * Registers party and matchmaking handlers.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ * @see https://github.com/MixV2/EpicResearch
+ */
+export function registerPartyHandlers(bot: BluGlo, idleMsg: string, busyMsg: string): void {
+ bot.client?.on("party:member:joined", async (member: PartyMemberLike) => {
+ try {
+ const schema = member.party.meta?.schema ?? {};
+ const campaignInfo = JSON.parse(schema["Default:CampaignInfo_j"] ?? "{}") as {
+ CampaignInfo?: { matchmakingState?: string };
+ };
+ const state = campaignInfo.CampaignInfo?.matchmakingState;
+
+ if (state && state !== MatchmakingState.NOT_MATCHMAKING && member.id === bot.accountId) {
+ log(bot.accountId, "warn", "Party already in matchmaking when joined โ leaving immediately");
+ await bot.client?.leaveParty?.().catch(() => undefined);
+ bot.returnToIdle(idleMsg);
+ return;
+ }
+ } catch {
+ // Ignore invalid campaign info payloads.
+ }
+
+ const collision = await bot.manager?.handleCollision(member.party, bot);
+ if (collision) return;
+
+ const members = bot.client?.party?.members
+ .map((partyMember) => ({
+ accountId: partyMember.id,
+ displayName: partyMember.displayName,
+ isLeader: partyMember.isLeader,
+ }))
+ .filter((partyMember) => partyMember.accountId !== bot.accountId) || member.party.members
+ .map((partyMember) => ({
+ accountId: partyMember.id,
+ displayName: partyMember.displayName,
+ isLeader: partyMember.isLeader,
+ }))
+ .filter((partyMember) => partyMember.accountId !== bot.accountId);
+
+
+
+ bus.emit("joined", { accountId: bot.accountId, members });
+ // log(
+ // bot.accountId,
+ // "info",
+ // `Party members: ${members.map((partyMember) => partyMember.displayName || partyMember.accountId.slice(0, 8)).join(", ")}`,
+ // );
+
+ const welcomeMessage = `Welcome! BluGlo is an open-source TaxiBot manager for Fortnite STW. You can check it out at https://github.com/andrewdotdev/BluGlo
+
+Commands in party chat:
+?pl min -> set power level to 0
+?pl max -> set power level to maximum`;
+ setTimeout(async () => {
+ const partyMembers = [...(bot.client?.party?.members?.values() ?? [])];
+
+ for (const partyMember of partyMembers) {
+ if (partyMember.id === bot.accountId) continue;
+
+ try {
+ await sendWhisperRaw(bot, partyMember.id, welcomeMessage, {
+ chunkLength: 256,
+ betweenChunksMs: 600,
+ });
+ } catch (err) {
+ log(bot.accountId, "error", `Whisper failed: ${err} `);
+ }
+ }
+ }, 1000);
+
+ });
+
+ bot.client?.on("party:invite", async (invitation: PartyInvitationLike) => {
+ const senderName = invitation.sender?.displayName ?? invitation.sender?.id?.slice(0, 8);
+ log(bot.accountId, "info", `Party invite from ${senderName}`);
+ bus.emit("invite", {
+ accountId: bot.accountId,
+ from: senderName,
+ fromId: invitation.sender?.id,
+ });
+
+ if (bot.presence === Presence.BUSY) {
+ log(bot.accountId, "info", "Declining (busy)");
+ bot.stats.invitesDeclined++;
+ void invitation.decline?.().catch(() => undefined);
+ return;
+ }
+
+ if ((invitation.party?.members?.size ?? 0) >= BOT.partyMaxSize) {
+ log(bot.accountId, "info", "Declining (party full)");
+ bot.stats.invitesDeclined++;
+ void invitation.decline?.().catch(() => undefined);
+ return;
+ }
+
+ if ((bot.client?.party?.members?.size ?? 1) > 1) {
+ log(bot.accountId, "info", "Declining (already in party)");
+ bot.stats.invitesDeclined++;
+ void invitation.decline?.().catch(() => undefined);
+ return;
+ }
+
+ if (bot.manager?.hasOtherTaxiIn(invitation.party, bot.accountId)) {
+ log(bot.accountId, "info", "Declining (another taxi already in that party)");
+ bot.stats.invitesDeclined++;
+ void invitation.decline?.().catch(() => undefined);
+ return;
+ }
+
+ try {
+ const { isPlaying, sessionId } = invitation.sender?.presence ?? {};
+ if (isPlaying || sessionId) {
+ log(bot.accountId, "info", "Declining (sender already in match)");
+ bot.stats.invitesDeclined++;
+ void invitation.decline?.().catch(() => undefined);
+ return;
+ }
+ } catch {
+ // Ignore presence parsing issues.
+ }
+
+ try {
+ bot.setPresence(Presence.BUSY, busyMsg);
+ await invitation.accept();
+ bot.client?.setStatus(busyMsg, "online");
+
+ if (bot.timings.postAcceptDelayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, bot.timings.postAcceptDelayMs));
+ }
+
+ // hardcoded restart before applying new patch
+ bot.actions.high = true;
+ await applyPartyPatch(bot);
+ log(bot.accountId, "ok", `In party with ${senderName} โ patch applied`);
+
+ bot.clearPartyTimeout();
+ bot.currentTimeout =
+ bot.client?.setTimeout(() => {
+ log(bot.accountId, "warn", "Party timeout โ leaving");
+ void bot.client?.leaveParty?.().catch(() => undefined);
+ bot.currentTimeout = null;
+ bot.returnToIdle(idleMsg);
+ }, bot.timings.partyAutoLeaveMs) ?? null;
+ } catch (error: any) {
+ log(
+ bot.accountId,
+ "error",
+ `Error while accepting invite: ${error?.code ?? error?.message ?? String(error)}`,
+ );
+ bot.setPresence(Presence.ACTIVE, idleMsg);
+ bot.reJoinTo = invitation.sender?.id ?? null;
+ bot.handleXmppError(error);
+ }
+ });
+
+ bot.client?.on(
+ "party:member:matchstate:updated",
+ (member: PartyMemberLike, value: MatchStateLike, prev: MatchStateLike) => {
+ void member;
+ const from = `${prev?.location}`;
+ const to = `${value?.location}`;
+
+ if (from === "PreLobby" && to === "ConnectingToLobby") {
+ log(bot.accountId, "ok", `Matchmaking detected โ leaving in ${bot.timings.matchstateLeaveDelayMs}ms`);
+
+ bot.client?.setTimeout(async () => {
+ await bot.client?.leaveParty?.().catch(() => undefined);
+ bot.clearPartyTimeout();
+ bot.stats.taxisCompleted++;
+ bot.returnToIdle(idleMsg);
+ log(bot.accountId, "ok", `Taxi completed #${bot.stats.taxisCompleted}`);
+ }, bot.timings.matchstateLeaveDelayMs);
+ }
+ },
+ );
+
+ bot.client?.on("party:member:message", async (message: PartyMessage) => {
+ let messageContent = extractPartyMessageText(message.content);
+ if (!messageContent.startsWith(PARTY_PREFIX)) return;
+ messageContent = messageContent.slice(PARTY_PREFIX.length).trim();
+ if (!messageContent) return;
+
+ const [command, subcommand] = messageContent.toLowerCase().split(/\s+/);
+
+ switch (command) {
+ case "pl": {
+ switch (subcommand) {
+ case "min": {
+ if (!bot.actions.high) return;
+ bot.actions.high = false;
+ await applyPartyPatch(bot);
+ await bot.client?.party?.chat.send?.("Switched to low stats");
+ log(bot.accountId, "info", "Session stats changed to LOW");
+ break;
+ }
+
+ case "max": {
+ if (bot.actions.high) return;
+
+ bot.actions.high = true;
+ await applyPartyPatch(bot);
+ await bot.client?.party?.chat.send?.("Switched to high stats");
+ log(bot.accountId, "info", "Session stats changed to HIGH");
+ break;
+ }
+
+ default: {
+ await bot.client?.party?.chat.send?.(
+ `Usage: ${PARTY_PREFIX}pl min | ${PARTY_PREFIX}pl max`,
+ );
+ break;
+ }
+ }
+
+ break;
+ }
+ }
+ });
+}
+
+function extractPartyMessageText(content: string): string {
+ try {
+ // Decode base64 -> utf8
+ const decoded = Buffer.from(content.trim(), "base64").toString("utf8");
+
+ const cleanedDecoded = decoded.replace(/\0+$/, "").trim();
+
+ const parsed = JSON.parse(cleanedDecoded) as {
+ msg?: string;
+ };
+
+ return parsed.msg?.trim() ?? content.trim();
+ } catch (error) {
+ console.log("extractPartyMessageText failed:", error);
+ return content.trim();
+ }
+}
\ No newline at end of file
diff --git a/src/bot/events/social.ts b/src/bot/events/social.ts
new file mode 100644
index 0000000..e4fe07d
--- /dev/null
+++ b/src/bot/events/social.ts
@@ -0,0 +1,40 @@
+import { bus, log } from "../../events.js";
+import type { BluGlo } from "../../bot.js";
+import type { PartyInvitationLike } from "../../types.js";
+
+/**
+ * Registers friend-related handlers.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ */
+export function registerSocialHandlers(bot: BluGlo): void {
+ bot.client?.on(
+ "friend:request",
+ (
+ incoming: PartyInvitationLike["sender"] & {
+ accept?: () => Promise;
+ },
+ ) => {
+ if (!incoming) return;
+
+ if (bot.actions.denyFriendRequests) {
+ void incoming.decline?.().catch(() => undefined);
+ log(bot.accountId, "info", `Declined friend request from ${incoming.displayName}`);
+ return;
+ }
+
+ void incoming.accept?.().catch(() => undefined);
+ log(bot.accountId, "info", `Accepted friend request from ${incoming.displayName}`);
+ },
+ );
+
+ bot.client?.on("friend:added", (friend: { id: string; displayName?: string }) => {
+ log(bot.accountId, "info", `New friend: ${friend.displayName}`);
+ bus.emit("friend", {
+ accountId: bot.accountId,
+ friendId: friend.id,
+ displayName: friend.displayName,
+ });
+ });
+}
diff --git a/src/bot/patch.ts b/src/bot/patch.ts
new file mode 100644
index 0000000..c24eff7
--- /dev/null
+++ b/src/bot/patch.ts
@@ -0,0 +1,98 @@
+import { FORT_HIGH, FORT_LOW } from "./constants.js";
+import type { BluGlo } from "../bot.js";
+
+/**
+ * Applies the STW stat and cosmetic patch after the bot joins a party.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ */
+export async function applyPartyPatch(bot: BluGlo): Promise {
+ const stat = bot.actions.high ? FORT_HIGH : FORT_LOW;
+
+ const schema = bot.client?.party?.me?.meta?.schema ?? {};
+ const mpLoadout1 = (() => {
+ const value = schema["Default:MpLoadout1_j"];
+ if (!value) return null;
+
+ if (typeof value === "string") {
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ return null;
+ }
+ }
+
+ if (typeof value === "object") {
+ return value;
+ }
+
+ return null;
+ })();
+
+ // Keep the parsed value available for future cosmetics logic without removing the current behavior.
+ void mpLoadout1;
+
+ const cosmetics: Record = {
+ "Default:MpLoadout1_j": JSON.stringify({
+ MpLoadout1: {
+ s: {
+ ac: { i: "CID_039_Athena_Commando_F_Disco", v: ["0"] },
+ li: { i: "StandardBanner20", v: [] },
+ lc: { i: "DefaultColor2", v: [] },
+ },
+ },
+ }),
+ };
+
+ const patch: Record = {
+ "Default:FORTStats_j": JSON.stringify({
+ FORTStats: {
+ fortitude: stat,
+ offense: stat,
+ resistance: stat,
+ tech: stat,
+ teamFortitude: 0,
+ teamOffense: 0,
+ teamResistance: 0,
+ teamTech: 0,
+ fortitude_Phoenix: stat,
+ offense_Phoenix: stat,
+ resistance_Phoenix: stat,
+ tech_Phoenix: stat,
+ teamFortitude_Phoenix: 0,
+ teamOffense_Phoenix: 0,
+ teamResistance_Phoenix: 0,
+ teamTech_Phoenix: 0,
+ },
+ }),
+ "Default:PackedState_j": JSON.stringify({
+ PackedState: {
+ subGame: "Campaign",
+ location: "PreLobby",
+ gameMode: "None",
+ voiceChatStatus: "PartyVoice",
+ hasCompletedSTWTutorial: true,
+ hasPurchasedSTW: true,
+ platformSupportsSTW: true,
+ bReturnToLobbyAndReadyUp: false,
+ bHideReadyUp: false,
+ bDownloadOnDemandActive: false,
+ bIsPartyLFG: false,
+ bShouldRecordPartyChannel: false,
+ },
+ }),
+ ...cosmetics,
+ };
+
+ if (bot.actions.high) {
+ patch["Default:CampaignCommanderLoadoutRating_d"] = "999.00";
+ patch["Default:CampaignBackpackRating_d"] = "999.000000";
+ }
+
+ await bot.client?.party?.me?.sendPatch?.(patch);
+
+ setTimeout(() => {
+ void bot.client?.party?.me?.setEmote?.("EID_Hype").catch(() => undefined);
+ }, 1000);
+}
diff --git a/src/bot/types.ts b/src/bot/types.ts
new file mode 100644
index 0000000..a1457fc
--- /dev/null
+++ b/src/bot/types.ts
@@ -0,0 +1,45 @@
+import { Client } from "fnbr";
+
+/**
+ * Narrow local client shape used by this project.
+ * It keeps the codebase typed without needing to model the full fnbr.js surface.
+ *
+ * @see https://fnbr.js.org
+ * @see https://github.com/fnbrjs/fnbr.js
+ */
+export type FnbrClient = InstanceType & {
+ setTimeout: typeof setTimeout;
+ clearTimeout: typeof clearTimeout;
+ defaultStatus?: string;
+ friend?: {
+ pendingList?: Array<{
+ direction?: string;
+ decline?: () => Promise;
+ }>;
+ resolve?: (accountId: string) => {
+ sendJoinRequest?: () => Promise;
+ } | null;
+ };
+ user?: {
+ self?: { displayName?: string };
+ displayName?: string;
+ };
+ party?: {
+ members?: { size: number };
+ me?: {
+ meta?: { schema?: Record };
+ sendPatch?: (patch: Record) => Promise;
+ setEmote?: (emoteId: string) => Promise;
+ };
+ };
+ xmpp?: {
+ disconnect?: () => void;
+ };
+ leaveParty?: () => Promise;
+ login: () => Promise;
+ logout?: () => Promise;
+ removeAllListeners: () => void;
+ setStatus: (status: string, type: string) => void;
+ on: (event: string, listener: (...args: any[]) => void) => void;
+ once: (event: string, listener: (...args: any[]) => void) => void;
+};
diff --git a/src/cli.ts b/src/cli.ts
index 766f2e8..484aaa6 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,43 +2,139 @@ import * as readline from "node:readline";
import { Presence } from "./bot.js";
import { BOT } from "./config.js";
-import { log } from "./events.js";
+import { bus } from "./events.js";
import type { BotManager } from "./manager.js";
-const HELP = `
-Commands:
- /add [authorizationCode] โ Add and start a new bot
- /add:device_auth โ Add and start a bot manually
- /remove โ Stop and remove a bot
- /reload โ Reconnect one bot
- /reload all โ Reconnect all bots
- /list โ List bots and states
- /stats โ Show bot statistics
- /help โ Show this help
- /exit โ Stop everything and exit
-
-Docs:
- fnbr https://fnbr.js.org
- fnbr repo https://github.com/fnbrjs/fnbr.js
- EpicResearch https://github.com/MixV2/EpicResearch
-`;
+type NoticeLevel = "info" | "warn" | "error" | "ok";
+type BottomMode = "result" | "help";
+interface Notice {
+ ts: number;
+ level: NoticeLevel;
+ text: string;
+}
+
+const REFRESH_MS = 5000;
+
+/**
+ * Minimal command palette shown only when the user explicitly asks for help.
+ *
+ * @see https://nodejs.org/api/readline.html
+ */
+const HELP_LINES = [
+ "/add [code]",
+ "/add:device_auth ",
+ "/remove ",
+ "/reload ",
+ "/list",
+ "/stats",
+ "/help",
+ "/exit",
+];
+
+interface CLIState {
+ dirty: boolean;
+ disposed: boolean;
+ notices: Notice[];
+ bottomMode: BottomMode;
+ banner: string | null;
+ extraLines: string[];
+}
+
+/**
+ * Starts a compact TUI for bot monitoring and commands.
+ *
+ * Design goals:
+ * - Minimal by default
+ * - Stable screen with throttled redraws
+ * - Single command input
+ * - Only last command result shown at the bottom
+ * - Help hidden unless requested
+ *
+ * @see https://nodejs.org/api/tty.html
+ * @see https://nodejs.org/api/readline.html
+ */
export function startCLI(manager: BotManager): void {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
- prompt: "taxi> ",
+ prompt: "โบ ",
terminal: true,
});
- console.log("\nโ CLI ready. Type /help to see commands.\n");
- rl.prompt();
+ const state: CLIState = {
+ dirty: true,
+ disposed: false,
+ notices: [
+ {
+ ts: Date.now(),
+ level: "ok",
+ text: "Ready",
+ },
+ ],
+ bottomMode: "result",
+ banner: "BluGlo",
+ extraLines: []
+ };
+
+ const originalLog = console.log.bind(console);
+
+ /**
+ * Redirects noisy console output into the bottom status area instead of
+ * breaking the TUI layout.
+ */
+ console.log = (...args: unknown[]) => {
+ const text = args
+ .map((value) => {
+ if (typeof value === "string") return value;
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ })
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ if (!text) return;
+
+ setNotice(state, inferLevel(text), compactLog(text));
+ };
+
+ const dirtyEvents = [
+ "status",
+ "profile",
+ "invite",
+ "joined",
+ "left",
+ "friend",
+ "removed",
+ "account:created",
+ "log",
+ ];
+
+ for (const eventName of dirtyEvents) {
+ bus.on(eventName, () => {
+ state.dirty = true;
+ });
+ }
+
+ const timer = setInterval(() => {
+ if (!state.dirty || state.disposed) return;
+ render(manager, rl, state);
+ }, REFRESH_MS);
+
+ render(manager, rl, state);
rl.on("line", (line) => {
void (async () => {
const trimmed = line.trim();
+
if (!trimmed) {
- rl.prompt();
+ state.bottomMode = "result";
+ state.dirty = true;
+ render(manager, rl, state);
return;
}
@@ -46,159 +142,490 @@ export function startCLI(manager: BotManager): void {
const cmd = rawCmd?.toLowerCase();
if (!cmd) {
- rl.prompt();
+ state.bottomMode = "result";
+ state.dirty = true;
+ render(manager, rl, state);
return;
}
- switch (cmd) {
- case "/add": {
- let authInput = args.join(" ").trim();
-
- if (!authInput) {
- const authUrl = _getAuthorizationCodeUrl();
+ /**
+ * Every new command clears the previous visual response.
+ */
+ state.notices = [];
+ state.extraLines = [];
+ state.bottomMode = "result";
+ state.dirty = true;
+ render(manager, rl, state);
- console.log("\nTo add a new account:");
- console.log(
- "1) Open this link in your browser and sign in with the Epic/Fortnite account:",
- );
- console.log(`\n${authUrl}\n`);
- console.log("2) Copy only the code value from the final URL, or paste the full URL.");
- console.log("3) Paste it below.\n");
- console.log("Docs:");
- console.log(" fnbr: https://fnbr.js.org");
- console.log(
- " EpicResearch auth code: https://github.com/MixV2/EpicResearch/blob/master/docs/auth/grant_types/authorization_code.md",
- );
- console.log(
- " EpicResearch exchange code: https://github.com/MixV2/EpicResearch/blob/master/docs/auth/grant_types/exchange_code.md\n",
- );
+ try {
+ switch (cmd) {
+ case "/add": {
+ let authInput = args.join(" ").trim();
- authInput = (await _question(rl, "authorizationCode> ")).trim();
+ if (!authInput) {
+ const authUrl = getAuthorizationCodeUrl();
+
+ setNotice(state, "info", "Open Epic login URL");
+ state.extraLines = [
+ "Copy the code from the Epic redirect page.",
+ authUrl,
+ ];
+ state.dirty = true;
+ render(manager, rl, state);
+
+ authInput = (await question(rl, "code โบ ")).trim();
+ state.extraLines = [];
+ }
if (!authInput) {
- console.log("Operation cancelled: no authorization code was provided.");
+ setNotice(state, "warn", "Cancelled");
break;
}
- }
- try {
- const authorizationCode = _extractAuthorizationCode(authInput);
+ const authorizationCode = extractAuthorizationCode(authInput);
if (!authorizationCode) {
- console.log("Could not extract a valid authorization code.");
- console.log("Paste the raw code or a full URL containing ?code=...");
+ setNotice(state, "error", "Invalid auth code");
break;
}
+ setNotice(state, "info", "Adding bot...");
+ render(manager, rl, state);
+
await manager.add_authcode(authorizationCode);
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- log(null, "error", `Error in /add: ${message}`);
+ setNotice(state, "ok", "Bot added");
+ break;
}
- break;
- }
+ case "/add:device_auth": {
+ const [accountId, deviceId, secret] = args;
- case "/add:device_auth": {
- const [accountId, deviceId, secret] = args;
+ if (!accountId || !deviceId || !secret) {
+ setNotice(state, "warn", "Usage: /add:device_auth ");
+ break;
+ }
- if (!accountId || !deviceId || !secret) {
- console.log("Usage: /add:device_auth ");
+ manager.add(accountId, deviceId, secret);
+ setNotice(state, "ok", `Added ${shortId(accountId)}`);
break;
}
- manager.add(accountId, deviceId, secret);
- break;
- }
+ case "/remove": {
+ const target = args[0];
+
+ if (!target) {
+ setNotice(state, "warn", "Usage: /remove ");
+ break;
+ }
- case "/remove": {
- const target = args[0];
- if (!target) {
- console.log("Usage: /remove ");
+ const resolved = resolveBotId(manager, target);
+ const ok = manager.remove(resolved);
+ setNotice(state, ok ? "ok" : "warn", ok ? `Removed ${shortId(resolved)}` : "Bot not found");
break;
}
- manager.remove(_resolve(manager, target));
- break;
- }
- case "/reload": {
- const target = args[0];
- if (!target || target === "all") {
- manager.reloadAll();
- } else {
- manager.reload(_resolve(manager, target));
- }
- break;
- }
+ case "/reload": {
+ const target = args[0];
+
+ if (!target || target === "all") {
+ manager.reloadAll();
+ setNotice(state, "ok", "Reloading all");
+ break;
+ }
- case "/list": {
- const bots = [...manager.bots.values()];
- if (bots.length === 0) {
- console.log(" No active bots.");
+ const resolved = resolveBotId(manager, target);
+ const ok = manager.reload(resolved);
+ setNotice(state, ok ? "ok" : "warn", ok ? `Reloading ${shortId(resolved)}` : "Bot not found");
break;
}
- const icon: Record = {
- [Presence.ACTIVE]: "๐ข",
- [Presence.BUSY]: "๐ก",
- [Presence.OFFLINE]: "๐ด",
- [Presence.LOADING]: "โช",
- };
-
- console.log("");
- for (const bot of bots) {
- const currentIcon = icon[bot.presence] ?? "โ";
- console.log(` ${currentIcon} ${bot.accountId.slice(0, 8)}... ${bot.status}`);
+ case "/list": {
+ setNotice(state, "info", `${manager.bots.size} bot(s) loaded`);
+ break;
}
- console.log("");
- break;
- }
- case "/stats": {
- const bots = [...manager.bots.values()];
- if (bots.length === 0) {
- console.log(" No bots.");
+ case "/stats": {
+ const totals = [...manager.bots.values()].reduce(
+ (acc, bot) => {
+ acc.taxis += bot.stats.taxisCompleted;
+ acc.declined += bot.stats.invitesDeclined;
+ acc.retries += bot.retryCount;
+ return acc;
+ },
+ { taxis: 0, declined: 0, retries: 0 },
+ );
+
+ setNotice(
+ state,
+ "info",
+ `taxis:${totals.taxis} declined:${totals.declined} retries:${totals.retries}`,
+ );
break;
}
- console.log("");
- for (const bot of bots) {
- const uptime = bot.stats.connectedAt
- ? Math.floor((Date.now() - bot.stats.connectedAt) / 1000)
- : 0;
- console.log(
- ` ${bot.accountId.slice(0, 8)}... | taxis: ${bot.stats.taxisCompleted} | declined: ${bot.stats.invitesDeclined} | uptime: ${uptime}s | retries: ${bot.retryCount}`,
- );
+
+ case "/help": {
+ state.bottomMode = "help";
+ state.dirty = true;
+ break;
}
- console.log("");
- break;
- }
- case "/help":
- console.log(HELP);
- break;
+ case "/exit": {
+ setNotice(state, "warn", "Stopping...");
+ render(manager, rl, state);
- case "/exit":
- console.log("\nStopping bots...");
- for (const bot of manager.bots.values()) {
- bot.stop();
+ clearInterval(timer);
+ state.disposed = true;
+
+ for (const bot of manager.bots.values()) {
+ bot.stop();
+ }
+
+ setTimeout(() => process.exit(0), 400);
+ return;
}
- setTimeout(() => process.exit(0), 500);
- break;
- default:
- console.log(`Unknown command: ${cmd}. Type /help`);
+ default:
+ setNotice(state, "warn", `Unknown command: ${cmd}`);
+ break;
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ setNotice(state, "error", compactLog(message));
}
- rl.prompt();
+ render(manager, rl, state);
})();
});
+ rl.on("SIGINT", () => {
+ rl.close();
+ });
+
rl.on("close", () => {
- console.log("\nCLI closed. The server is still running.");
+ clearInterval(timer);
+ state.disposed = true;
+ console.log = originalLog;
+ process.stdout.write("\x1b[2J\x1b[H");
+ originalLog("CLI closed.");
+ });
+
+ process.stdout.on("resize", () => {
+ state.dirty = true;
+ render(manager, rl, state);
+ });
+}
+
+function setNotice(state: CLIState, level: NoticeLevel, text: string): void {
+ const notice: Notice = {
+ ts: Date.now(),
+ level,
+ text,
+ };
+
+ state.notices.push(notice);
+
+ if (state.notices.length > 5) {
+ state.notices.shift();
+ }
+
+ state.bottomMode = "result";
+ state.dirty = true;
+}
+
+/**
+ * Full TUI render.
+ *
+ * Layout:
+ * - title bar
+ * - bots table
+ * - bottom panel (last result or help)
+ * - input prompt
+ */
+function render(manager: BotManager, rl: readline.Interface, state: CLIState): void {
+ state.dirty = false;
+
+ const width = Math.max(process.stdout.columns || 100, 96);
+ const now = new Date();
+
+ const bots = [...manager.bots.values()];
+ const rows = bots.map((bot) => {
+ const name = bot.displayName || shortId(bot.accountId);
+ const uptime = getLiveUptime(bot.stats.totalUptime, bot.stats.connectedAt);
+
+ return {
+ id: shortId(bot.accountId),
+ name,
+ state: simplifyPresence(bot.presence),
+ status: compactStatus(bot.status),
+ high: bot.actions.high ? "yes" : "no",
+ taxis: String(bot.stats.taxisCompleted),
+ declined: String(bot.stats.invitesDeclined),
+ retries: String(bot.retryCount),
+ uptime: formatDuration(uptime),
+ };
});
+
+ const out: string[] = [];
+
+ out.push(clearScreen());
+ out.push(drawTopBar(width, now, bots.length));
+ out.push("");
+ out.push(drawTableBox(width, rows));
+ out.push("");
+ out.push(drawBottomBox(width, state));
+ out.push("");
+
+ process.stdout.write(out.join("\n"));
+
+ rl.setPrompt(styledPrompt());
+ rl.prompt(true);
+}
+
+/**
+ * Pretty top bar.
+ */
+function drawTopBar(width: number, now: Date, botCount: number): string {
+ const left = bold("BluGlo");
+ const right = dim(`${now.toLocaleTimeString()} ยท bots ${botCount} ยท ${REFRESH_MS / 1000}s`);
+ const spacing = Math.max(1, width - visibleLength(stripAnsi(left)) - visibleLength(stripAnsi(right)));
+ return `${left}${" ".repeat(spacing)}${right}`;
+}
+
+/**
+ * Main data table box.
+ */
+function drawTableBox(
+ width: number,
+ rows: Array<{
+ id: string;
+ name: string;
+ state: string;
+ status: string;
+ high: string;
+ taxis: string;
+ declined: string;
+ retries: string;
+ uptime: string;
+ }>,
+): string {
+ if (width < 96) {
+ const compactLines = [
+ boxTop(" Bots ", width),
+ ];
+
+ if (rows.length === 0) {
+ compactLines.push(boxLine(dim("No bots loaded"), width));
+ compactLines.push(boxBottom(width));
+ return compactLines.join("\n");
+ }
+
+ rows.forEach((row, index) => {
+ if (index > 0) compactLines.push(boxSep(width));
+ compactLines.push(
+ boxLine(
+ `${pad(row.id, 8)} ${pad(row.name, 18)} ${colorState(row.state)}`,
+ width,
+ ),
+ );
+ compactLines.push(
+ boxLine(
+ dim(`status: ${row.status} | high: ${row.high} | taxis: ${row.taxis} | retry: ${row.retries} | up: ${row.uptime}`),
+ width,
+ ),
+ );
+ });
+
+ compactLines.push(boxBottom(width));
+ return compactLines.join("\n");
+ }
+
+ const innerWidth = width - 4;
+
+ const idW = 10;
+ const stateW = 9;
+ const highW = 6;
+ const taxisW = 7;
+ const decW = 10;
+ const retriesW = 8;
+ const uptimeW = 9;
+
+ const fixed = idW + stateW + highW + taxisW + decW + retriesW + uptimeW + 7 * 3;
+ const remaining = Math.max(innerWidth - fixed, 26);
+ const nameW = Math.min(Math.max(Math.floor(remaining * 0.34), 14), 22);
+ const statusW = Math.max(remaining - nameW, 16);
+
+ const header = [
+ pad("ID", idW),
+ pad("Name", nameW),
+ pad("State", stateW),
+ pad("Status", statusW),
+ pad("High", highW),
+ pad("Taxis", taxisW),
+ pad("Declined", decW),
+ pad("Retry", retriesW),
+ pad("Uptime", uptimeW),
+ ].join(" โ ");
+
+ const body =
+ rows.length === 0
+ ? [dim("No bots loaded")]
+ : rows.map((row) =>
+ [
+ pad(row.id, idW),
+ pad(row.name, nameW),
+ pad(colorState(row.state), stateW),
+ pad(row.status, statusW),
+ pad(row.high, highW),
+ pad(row.taxis, taxisW),
+ pad(row.declined, decW),
+ pad(row.retries, retriesW),
+ pad(row.uptime, uptimeW),
+ ].join(" โ "),
+ );
+
+ const lines = [
+ boxTop(" Bots ", width),
+ boxLine(bold(header), width),
+ boxSep(width),
+ ...body.map((line) => boxLine(line, width)),
+ boxBottom(width),
+ ];
+
+ return lines.join("\n");
}
-function _resolve(manager: BotManager, input: string): string {
+/**
+ * Bottom panel shows either:
+ * - the latest command result
+ * - the help palette
+ */
+function drawBottomBox(width: number, state: CLIState): string {
+ if (state.bottomMode === "help") {
+ const lines = [
+ boxTop(" Help ", width),
+ ...HELP_LINES.map((line) => boxLine(dim(line), width)),
+ boxBottom(width),
+ ];
+ return lines.join("\n");
+ }
+
+ const notices = state.notices ?? [];
+
+ if (notices.length === 0) {
+ const lines = [
+ boxTop(" Logs (0/5) ", width),
+ boxLine(dim("No output"), width),
+ boxBottom(width),
+ ];
+ return lines.join("\n");
+ }
+
+ const lines = [
+ boxTop(` Logs (${notices.length}/5) `, width),
+ ];
+
+ notices.forEach((notice, index) => {
+ const label = formatNoticeLabel(notice.level);
+ const timestamp = dim(new Date(notice.ts).toLocaleTimeString());
+
+ if (index > 0) {
+ lines.push(boxSep(width));
+ }
+
+ lines.push(boxLine(`${label} ${timestamp}`, width));
+
+ for (const line of wrapText(notice.text, width - 6)) {
+ lines.push(boxLine(line, width));
+ }
+
+ if (index === notices.length - 1 && state.extraLines.length > 0) {
+ lines.push(boxSep(width));
+ for (const extra of state.extraLines) {
+ for (const line of wrapText(extra, width - 6)) {
+ lines.push(boxLine(dim(line), width));
+ }
+ }
+ }
+ });
+
+ lines.push(boxBottom(width));
+ return lines.join("\n");
+}
+
+function formatNoticeLabel(level: NoticeLevel): string {
+ switch (level) {
+ case "ok":
+ return green("OK");
+ case "warn":
+ return yellow("WARN");
+ case "error":
+ return red("ERROR");
+ default:
+ return cyan("INFO");
+ }
+}
+
+function simplifyPresence(presence: string): string {
+ switch (presence) {
+ case Presence.ACTIVE:
+ return "active";
+ case Presence.BUSY:
+ return "busy";
+ case Presence.LOADING:
+ return "loading";
+ case Presence.OFFLINE:
+ return "offline";
+ default:
+ return presence;
+ }
+}
+
+function colorState(value: string): string {
+ switch (value) {
+ case "active":
+ return green(value);
+ case "busy":
+ return yellow(value);
+ case "loading":
+ return cyan(value);
+ case "offline":
+ return dim(value);
+ default:
+ return value;
+ }
+}
+
+function compactStatus(status: string): string {
+ return status
+ .replace(/\s+/g, " ")
+ .replace(/waiting for invite/i, "waiting")
+ .replace(/in party/i, "party")
+ .replace(/connecting/i, "connecting")
+ .replace(/disconnected/i, "offline")
+ .trim();
+}
+
+function getLiveUptime(totalUptime: number, connectedAt: number | null): number {
+ if (!connectedAt) return totalUptime;
+ return totalUptime + Math.max(0, Date.now() - connectedAt);
+}
+
+function formatDuration(ms: number): string {
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
+ const h = Math.floor(totalSeconds / 3600);
+ const m = Math.floor((totalSeconds % 3600) / 60);
+ const s = totalSeconds % 60;
+
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+function shortId(accountId: string): string {
+ return accountId.slice(0, 8);
+}
+
+function resolveBotId(manager: BotManager, input: string): string {
if (input.length === 36) return input;
for (const id of manager.bots.keys()) {
if (id.startsWith(input)) return id;
@@ -206,12 +633,13 @@ function _resolve(manager: BotManager, input: string): string {
return input;
}
-function _getAuthorizationCodeUrl(): string {
- const clientId = BOT.auth.authorizationCodeClient.clientId || "ec684b8c687f479fadea3cb2ad83f5c6";
+function getAuthorizationCodeUrl(): string {
+ const clientId =
+ BOT.auth.authorizationCodeClient.clientId || "ec684b8c687f479fadea3cb2ad83f5c6";
return `https://www.epicgames.com/id/api/redirect?clientId=${clientId}&responseType=code`;
}
-function _extractAuthorizationCode(input: string): string | null {
+function extractAuthorizationCode(input: string): string | null {
const value = input.trim();
if (!value) return null;
if (/^[a-f0-9]{32}$/i.test(value)) return value;
@@ -242,8 +670,164 @@ function _extractAuthorizationCode(input: string): string | null {
return null;
}
-function _question(rl: readline.Interface, text: string): Promise {
+function question(rl: readline.Interface, text: string): Promise {
return new Promise((resolve) => {
rl.question(text, resolve);
});
}
+
+/**
+ * Compresses verbose runtime logs into short readable one-liners.
+ */
+function compactLog(text: string): string {
+ let value = text
+ .replace(/\[system\]\s*/gi, "")
+ .replace(/^\s*[โโ โ]\s*/u, "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ value = value
+ .replace(/Dashboard at .*/i, "Dashboard ready")
+ .replace(/Loading (\d+) account\(s\)\.\.\./i, "Loading $1 account(s)")
+ .replace(/No saved accounts found\. Use \/add to add one\./i, "No saved accounts")
+ .replace(/Exchanging authorization code with .*?/i, "Exchanging auth code")
+ .replace(/Requesting exchange code\.\.\./i, "Requesting exchange code")
+ .replace(/Exchanging exchange code with .*?/i, "Creating device auth")
+ .replace(/Creating device auth for (.+?)\.\.\./i, "Creating device auth: $1")
+ .replace(/Device auth created for (.+)/i, "Device auth ready: $1")
+ .replace(/Account ([a-f0-9]{8}).* added/i, "Added $1")
+ .replace(/Reloading ([a-f0-9]{8}).*/i, "Reloading $1")
+ .replace(/Disconnected โ retry (\d+)\/(\d+)/i, "Retry $1/$2")
+ .replace(/Maximum retries reached \((\d+)\)/i, "Max retries ($1)")
+ .replace(/Bot stopped/i, "Stopped")
+ .replace(/Connecting\.\.\./i, "Connecting")
+ .replace(/Permanent error โ use \/reload /i, "Permanent error")
+ .replace(/Account not found: ([a-f0-9]{8}).*/i, "Bot not found: $1");
+
+ return value;
+}
+
+function inferLevel(text: string): NoticeLevel {
+ if (/^\s*โ|error|failed|exception|rejection/i.test(text)) return "error";
+ if (/^\s*โ |warn|invalid|cancel/i.test(text)) return "warn";
+ if (/^\s*โ|ready|added|loaded|connected|joined|created/i.test(text)) return "ok";
+ return "info";
+}
+
+function styledPrompt(): string {
+ return `${bold(cyan("โบ"))} `;
+}
+
+function clearScreen(): string {
+ return "\x1b[2J\x1b[H";
+}
+
+function boxTop(title: string, width: number): string {
+ const inner = width - 2;
+ const text = `โ${title}`;
+ const fill = Math.max(0, inner - visibleLength(stripAnsi(title)) - 1);
+ return `${text}${"โ".repeat(fill)}โ`;
+}
+
+function boxSep(width: number): string {
+ return `โ${"โ".repeat(width - 2)}โค`;
+}
+
+function boxBottom(width: number): string {
+ return `โ${"โ".repeat(width - 2)}โ`;
+}
+
+function boxLine(content: string, width: number): string {
+ const inner = width - 4;
+ const fitted = fitAnsi(content, inner);
+ const cleanLen = visibleLength(stripAnsi(fitted));
+ const padding = Math.max(0, inner - cleanLen);
+ return `โ ${fitted}${" ".repeat(padding)} โ`;
+}
+
+function pad(value: string, width: number): string {
+ const fitted = fitAnsi(value, width);
+ const padding = Math.max(0, width - visibleLength(stripAnsi(fitted)));
+ return `${fitted}${" ".repeat(padding)}`;
+}
+
+function wrapText(value: string, width: number): string[] {
+ const clean = stripAnsi(value);
+ if (!clean) return [""];
+
+ const result: string[] = [];
+
+ for (const rawLine of clean.split("\n")) {
+ if (rawLine.length <= width) {
+ result.push(rawLine);
+ continue;
+ }
+
+ const words = rawLine.split(" ");
+ let current = "";
+
+ for (const word of words) {
+ if (word.length > width) {
+ if (current) {
+ result.push(current);
+ current = "";
+ }
+
+ for (let i = 0; i < word.length; i += width) {
+ result.push(word.slice(i, i + width));
+ }
+ continue;
+ }
+
+ const test = current ? `${current} ${word}` : word;
+ if (test.length <= width) {
+ current = test;
+ } else {
+ if (current) result.push(current);
+ current = word;
+ }
+ }
+
+ if (current) result.push(current);
+ }
+
+ return result.length ? result : [clean];
+}
+
+function fitAnsi(value: string, width: number): string {
+ const clean = stripAnsi(value);
+ if (clean.length <= width) return value;
+ return clean.slice(0, Math.max(0, width - 1)) + "โฆ";
+}
+
+function visibleLength(value: string): number {
+ return stripAnsi(value).length;
+}
+
+function stripAnsi(value: string): string {
+ return value.replace(/\x1B\[[0-9;]*m/g, "");
+}
+
+function bold(value: string): string {
+ return `\x1b[1m${value}\x1b[0m`;
+}
+
+function dim(value: string): string {
+ return `\x1b[90m${value}\x1b[0m`;
+}
+
+function cyan(value: string): string {
+ return `\x1b[36m${value}\x1b[0m`;
+}
+
+function green(value: string): string {
+ return `\x1b[32m${value}\x1b[0m`;
+}
+
+function yellow(value: string): string {
+ return `\x1b[33m${value}\x1b[0m`;
+}
+
+function red(value: string): string {
+ return `\x1b[31m${value}\x1b[0m`;
+}
\ No newline at end of file
diff --git a/src/manager.ts b/src/manager.ts
index 7641dcc..54f5b74 100644
--- a/src/manager.ts
+++ b/src/manager.ts
@@ -250,7 +250,7 @@ export class BotManager {
log(currentBot.accountId, "warn", "Collision: another taxi is already in the party โ leaving");
await currentBot.client?.leaveParty?.().catch(() => undefined);
- currentBot._returnToIdle(currentBot.actions.idleStatus || currentBot.client?.defaultStatus);
+ currentBot.returnToIdle(currentBot.actions.idleStatus || currentBot.client?.defaultStatus);
return true;
}
diff --git a/src/server.ts b/src/server.ts
index f213fd7..2c163dc 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -20,6 +20,10 @@ const MIME: Record = {
const sseClients = new Set();
+/**
+ * Broadcasts dashboard-safe runtime updates to every connected SSE client.
+ * Logs are intentionally excluded so the browser only receives state changes.
+ */
export function broadcast(event: string, data: unknown): void {
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const response of sseClients) {
@@ -32,7 +36,7 @@ export function broadcast(event: string, data: unknown): void {
}
export function startServer(manager: BotManager) {
- const events = ["status", "profile", "log", "invite", "joined", "left", "friend", "removed"];
+ const events = ["status", "profile", "invite", "joined", "left", "friend", "removed"];
for (const eventName of events) {
bus.on(eventName, (data) => broadcast(eventName, data));
}
diff --git a/tsconfig.json b/tsconfig.json
index 95a15ac..1f9ca4c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,24 +1,28 @@
{
"compilerOptions": {
- "lib": ["ESNext"],
+ "lib": [
+ "ESNext"
+ ],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": false,
-
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
-
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
-
- "types": ["bun"]
+ "types": [
+ "bun"
+ ]
},
- "include": ["src/**/*.ts", "src/**/*.tsx"]
-}
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx"
+ ]
+}
\ No newline at end of file
From 3231e17cc0508f4026fa6d2e64e70b6272cc8295 Mon Sep 17 00:00:00 2001
From: Andrew <109876401+andrewdotdev@users.noreply.github.com>
Date: Mon, 6 Apr 2026 20:38:11 +0200
Subject: [PATCH 2/2] Delete .github/workflows/pr-autofix.yml
---
.github/workflows/pr-autofix.yml | 75 --------------------------------
1 file changed, 75 deletions(-)
delete mode 100644 .github/workflows/pr-autofix.yml
diff --git a/.github/workflows/pr-autofix.yml b/.github/workflows/pr-autofix.yml
deleted file mode 100644
index 56d11ec..0000000
--- a/.github/workflows/pr-autofix.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: PR Autofix
-
-on:
- issue_comment:
- types: [created]
-
-permissions:
- contents: write
- pull-requests: write
- issues: read
-
-jobs:
- autofix:
- if: >
- github.event.issue.pull_request &&
- (
- contains(github.event.comment.body, '/prettier') ||
- contains(github.event.comment.body, '/autofix') ||
- contains(github.event.comment.body, '@github-actions prettier')
- )
- runs-on: ubuntu-latest
-
- steps:
- - name: Get PR branch info
- id: pr
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh pr view ${{ github.event.issue.number }} --json headRefName,headRepositoryOwner,headRepository,isCrossRepository \
- --jq '{headRefName, headRepositoryOwner: .headRepositoryOwner.login, headRepository: .headRepository.name, isCrossRepository}' > pr.json
- echo "ref=$(jq -r '.headRefName' pr.json)" >> $GITHUB_OUTPUT
- echo "owner=$(jq -r '.headRepositoryOwner' pr.json)" >> $GITHUB_OUTPUT
- echo "repo=$(jq -r '.headRepository' pr.json)" >> $GITHUB_OUTPUT
- echo "cross=$(jq -r '.isCrossRepository' pr.json)" >> $GITHUB_OUTPUT
-
- - name: Stop if PR comes from fork
- if: steps.pr.outputs.cross == 'true'
- run: |
- echo "This autofix workflow is disabled for PRs from forks."
- exit 1
-
- - name: Checkout PR branch
- uses: actions/checkout@v5
- with:
- repository: ${{ steps.pr.outputs.owner }}/${{ steps.pr.outputs.repo }}
- ref: ${{ steps.pr.outputs.ref }}
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Setup Bun
- uses: oven-sh/setup-bun@v2
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Run Prettier
- run: bun run format
-
- - name: Run ESLint autofix
- run: bun run lint:fix
-
- - name: Commit and push changes
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add .
- git diff --cached --quiet && exit 0
- git commit -m "chore: apply prettier and eslint fixes"
- git push
-
- - name: Comment back on PR
- if: success()
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh issue comment ${{ github.event.issue.number }} --body "โ
Applied Prettier/ESLint fixes to the PR branch."
\ No newline at end of file