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 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 -
- -
-
- Connecting... +
+
๐Ÿš•
+
+
BLUGLO
+
Bot dashboard
+
+
+ +
+
+
+ Connecting... +
+
0 bots
+
-
0 bots
-
-
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