From 60c07f65dd5d0d0e323e80acb2db252e0bfc7ddc Mon Sep 17 00:00:00 2001 From: Kashargul <144968721+Kashargul@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:06:19 +0200 Subject: [PATCH] port of chatbox websocket client [No GBP] (#19496) * port of chatbox websocket client * . --- code/modules/tgui_panel/tgui_panel.dm | 33 +++ tgui/packages/tgui-panel/index.tsx | 6 +- .../SettingTabs/SettingsWebsocket.tsx | 99 +++++++++ .../tgui-panel/settings/SettingsPanel.tsx | 2 + tgui/packages/tgui-panel/settings/atoms.ts | 2 + .../packages/tgui-panel/settings/constants.ts | 4 + .../packages/tgui-panel/settings/migration.ts | 6 + tgui/packages/tgui-panel/settings/types.ts | 2 + tgui/packages/tgui-panel/websocket/helpers.ts | 195 ++++++++++++++++++ 9 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx create mode 100644 tgui/packages/tgui-panel/websocket/helpers.ts diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm index 119e335f744..aafabecfae7 100644 --- a/code/modules/tgui_panel/tgui_panel.dm +++ b/code/modules/tgui_panel/tgui_panel.dm @@ -115,6 +115,10 @@ analyze_telemetry(payload) return TRUE + if(type == "requestMetadata") + send_metadata() + return TRUE + /** * public * @@ -122,3 +126,32 @@ */ /datum/tgui_panel/proc/send_roundrestart() window.send_message("roundrestart") + +/** + * private + * + * Sent when a client requests metadata - used for websocket stuff. + */ +/datum/tgui_panel/proc/send_metadata() + var/static/list/webroot_asset_urls + + var/list/metadata = list( + "game_version" = GLOB.game_version, + "server_name" = CONFIG_GET(string/servername), + "round_id" = GLOB.round_id, + "map_name" = SSmapping.current_map?.name, + "round_duration" = round(ROUND_TIME() / 10, 1), + "gamestate" = SSticker.current_state, + ) + // if we're using webroot - also pass along the webroot url and such, so we can embed chat logs with the proper styles/images if desired + if(istype(SSassets.transport, /datum/asset_transport/webroot)) + if(isnull(webroot_asset_urls)) + webroot_asset_urls = list() + for(var/asset_type in list(/datum/asset/simple/tgui_panel, /datum/asset/simple/namespaced/fontawesome, /datum/asset/simple/namespaced/tgfont, /datum/asset/spritesheet_batched/chat)) + var/datum/asset/asset = get_asset_datum(asset_type) + webroot_asset_urls += asset.get_url_mappings() + metadata["webroot"] = list( + "base_url" = CONFIG_GET(string/asset_cdn_url), + "assets" = webroot_asset_urls, + ) + window.send_message("metadata", metadata) diff --git a/tgui/packages/tgui-panel/index.tsx b/tgui/packages/tgui-panel/index.tsx index 0041681e341..d0ba8e5fba2 100644 --- a/tgui/packages/tgui-panel/index.tsx +++ b/tgui/packages/tgui-panel/index.tsx @@ -16,6 +16,7 @@ import { setupHotReloading } from 'tgui-dev-server/link/client'; import { App } from './app'; import { bus } from './events/listeners'; import { setupPanelFocusHacks } from './panelFocus'; +import { wsSend } from './websocket/helpers'; const root = createRoot(document.getElementById('react-root')!); @@ -43,7 +44,10 @@ function setupApp() { render(); // Dispatch incoming messages as store actions - Byond.subscribe((type, payload) => bus.dispatch({ type, payload })); + Byond.subscribe((type, payload) => { + bus.dispatch({ type, payload }); + wsSend({ type, payload }); + }); // Unhide the panel Byond.winset('outputwindow.legacy_output_selector', { diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx new file mode 100644 index 00000000000..e3db5258093 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx @@ -0,0 +1,99 @@ +import { useAtomValue } from 'jotai'; +import { + Button, + Input, + LabeledList, + Section, + Stack, +} from 'tgui-core/components'; +import { chatRenderer } from 'tgui-panel/chat/renderer'; +import { gameAtom } from 'tgui-panel/game/atoms'; +import { + wsDisconnect, + wsReconnect, + wsUpdate, +} from 'tgui-panel/websocket/helpers'; +import { useSettings } from '../use-settings'; + +export function SettingsWebsocket(props) { + const game = useAtomValue(gameAtom); + const { settings, updateSettings } = useSettings(); + + return ( +
+ + + + + { + const websocketEnabled = !settings.websocketEnabled; + updateSettings({ websocketEnabled }); + wsUpdate(websocketEnabled); + }} + > + Enabled + + + + + + + +
+ ); +} diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx index 83995b0071f..25937895d73 100644 --- a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx @@ -12,6 +12,7 @@ import { ExportTab } from './SettingTabs/ExportTab'; import { MessageLimits } from './SettingTabs/MessageLimits'; import { SettingsGeneral } from './SettingTabs/SettingsGeneral'; import { SettingsStatPanel } from './SettingTabs/SettingsStatPanel'; +import { SettingsWebsocket } from './SettingTabs/SettingsWebsocket'; import { TextHighlightSettings } from './SettingTabs/TextHighlightSettings'; import { TTSSettings } from './SettingTabs/TTSSettings'; import { useSettings } from './use-settings'; @@ -56,6 +57,7 @@ export const SettingsPanel = (props) => { {activeTab === 'chatPage' && } {activeTab === 'statPanel' && } {activeTab === 'ttsSettings' && } + {activeTab === 'websocket' && } ); diff --git a/tgui/packages/tgui-panel/settings/atoms.ts b/tgui/packages/tgui-panel/settings/atoms.ts index 9e0f3e86b4e..022e199c5e5 100644 --- a/tgui/packages/tgui-panel/settings/atoms.ts +++ b/tgui/packages/tgui-panel/settings/atoms.ts @@ -33,6 +33,8 @@ export const defaultSettings: SettingsState = { activeTab: SETTINGS_TABS[0].id, visible: false, }, + websocketEnabled: false, + websocketServer: '', visibleMessageLimit: 2500, visibleMessages: 0, }; diff --git a/tgui/packages/tgui-panel/settings/constants.ts b/tgui/packages/tgui-panel/settings/constants.ts index b25be7f871b..a69c67da09f 100644 --- a/tgui/packages/tgui-panel/settings/constants.ts +++ b/tgui/packages/tgui-panel/settings/constants.ts @@ -39,6 +39,10 @@ export const SETTINGS_TABS = [ id: 'ttsSettings', name: 'TTS/Accessibility', }, + { + id: 'websocket', + name: 'Websocket', + }, ] as const; export const FONTS_DISABLED = 'Default'; diff --git a/tgui/packages/tgui-panel/settings/migration.ts b/tgui/packages/tgui-panel/settings/migration.ts index abc18c54772..dc685e1e944 100644 --- a/tgui/packages/tgui-panel/settings/migration.ts +++ b/tgui/packages/tgui-panel/settings/migration.ts @@ -1,6 +1,7 @@ import { storage } from 'common/storage'; import { smoothMerge } from 'common/type-safety'; import { omit, pick } from 'es-toolkit'; +import { wsUpdate } from 'tgui-panel/websocket/helpers'; import { setMusicVolume } from '../audio/handlers'; import { MESSAGE_TYPES } from '../chat/constants'; import { chatRenderer } from '../chat/renderer'; @@ -127,6 +128,11 @@ export function startSettingsMigration(next: MergedSettings): void { store.set(settingsAtom, draftSettings); console.log('Migrated panel settings:', draftSettings); + if (draftSettings.websocketEnabled !== defaultSettings.websocketEnabled) { + // Ensure websocket state is correct after migration + wsUpdate(draftSettings.websocketEnabled); + } + const migratedHighlights = migrateHighlights(highlightPart); storage.set('panel-settings', { ...draftSettings, ...migratedHighlights }); diff --git a/tgui/packages/tgui-panel/settings/types.ts b/tgui/packages/tgui-panel/settings/types.ts index da1dbf2b3f9..ad85f0c4cab 100644 --- a/tgui/packages/tgui-panel/settings/types.ts +++ b/tgui/packages/tgui-panel/settings/types.ts @@ -34,6 +34,8 @@ export const settingsSchema = z.object({ ttsVoice: z.string(), version: z.number(), view: viewSchema, + websocketEnabled: z.boolean(), + websocketServer: z.string(), visibleMessages: z.number(), visibleMessageLimit: z.number(), }); diff --git a/tgui/packages/tgui-panel/websocket/helpers.ts b/tgui/packages/tgui-panel/websocket/helpers.ts new file mode 100644 index 00000000000..fa02fada690 --- /dev/null +++ b/tgui/packages/tgui-panel/websocket/helpers.ts @@ -0,0 +1,195 @@ +import { createLogger } from 'tgui/logging'; +import { chatRenderer } from 'tgui-panel/chat/renderer'; +import { store } from 'tgui-panel/events/store'; +import { gameAtom } from 'tgui-panel/game/atoms'; +import { settingsAtom } from 'tgui-panel/settings/atoms'; + +const MAX_RETRIES = 10; +const RETRY_INTERVAL = 500; // ms + +// Websocket close codes +const WEBSOCKET_DISABLED = 4555; +const WEBSOCKET_REATTEMPT = 4556; +const SAFE_CLOSE_CODE = 1000; + +const logger = createLogger('websocket'); + +let websocket: WebSocket | null = null; +let reconnectTimer: number | null = null; +let retryCount = 0; +let manuallyClosed = false; + +function sendWSNotice(message, small = false) { + const { roundId } = store.get(gameAtom); + chatRenderer.processBatch([ + { + type: '', + createdAt: Date.now(), + roundId: roundId, + html: small + ? `${message}` + : `
${message}
`, + }, + ]); +} + +function clearReconnectTimer() { + if (reconnectTimer !== null) { + clearInterval(reconnectTimer); + reconnectTimer = null; + } +} + +function safeClose(code = SAFE_CLOSE_CODE, reason?: string) { + if (!websocket) return; + if ( + websocket.readyState === WebSocket.CLOSED || + websocket.readyState === WebSocket.CLOSING + ) { + return; + } + + websocket.close(code, reason); +} + +function startReconnectLoop() { + if (reconnectTimer !== null) return; + + reconnectTimer = window.setInterval(() => { + const { websocketEnabled } = store.get(settingsAtom); + + if (!websocketEnabled) { + clearReconnectTimer(); + return; + } + + if (retryCount >= MAX_RETRIES) { + clearReconnectTimer(); + sendWSNotice( + `Websocket failed to reconnect after ${MAX_RETRIES} attempts.`, + true, + ); + return; + } + + if ( + !websocket || + websocket.readyState === WebSocket.CLOSED || + websocket.readyState === WebSocket.CLOSING + ) { + retryCount++; + setupWebsocket(); + } + }, RETRY_INTERVAL); +} + +function setupWebsocket(force = false) { + const { websocketEnabled, websocketServer } = store.get(settingsAtom); + + if (!websocketEnabled) { + clearReconnectTimer(); + safeClose(WEBSOCKET_DISABLED); + websocket = null; + return; + } + + if (!force && websocket && websocket.readyState === WebSocket.OPEN) { + return; + } + + if (force) { + clearReconnectTimer(); + + if (websocket) { + manuallyClosed = true; + try { + websocket.close(WEBSOCKET_REATTEMPT, 'forced reconnect'); + } catch { + /* ignore */ + } + websocket = null; + } + } + + try { + manuallyClosed = false; + websocket = new WebSocket(`ws://${websocketServer}`); + } catch (e: any) { + if (e.name === 'SyntaxError') { + sendWSNotice( + `Error creating websocket: Invalid address! Make sure you're following the placeholder. Example: localhost:1234`, + true, + ); + return; + } + sendWSNotice(`Error creating websocket: ${e.name} - ${e.message}`); + startReconnectLoop(); + return; + } + + websocket.addEventListener('open', () => { + clearReconnectTimer(); + sendWSNotice('Websocket connected!', true); + Byond.sendMessage('requestMetadata'); // let's be nice and request metadata + retryCount = 0; + }); + + websocket.addEventListener('message', (event) => { + if (event.data === 'requestMetadata') { + Byond.sendMessage('requestMetadata'); + } + }); + + websocket.addEventListener('close', (ev) => { + websocket = null; + + if (manuallyClosed || ev.code === WEBSOCKET_DISABLED) return; + + sendWSNotice( + `Websocket disconnected! Code: ${ev.code} Reason: ${ev.reason || 'None provided'}`, + true, + ); + + startReconnectLoop(); + }); + + websocket.addEventListener('error', (ev) => { + logger.error('got websocket error', ev); + safeClose(WEBSOCKET_REATTEMPT, 'got error from server'); + }); +} + +// Initial connect +setupWebsocket(); + +export function wsUpdate(enabled: boolean): void { + if (enabled) { + setupWebsocket(); + return; + } + + manuallyClosed = true; + clearReconnectTimer(); + safeClose(WEBSOCKET_DISABLED); + websocket = null; +} + +export function wsReconnect(): void { + setupWebsocket(true); + sendWSNotice('Attempting to connect to websocket...', true); +} + +export function wsDisconnect(): void { + manuallyClosed = true; + clearReconnectTimer(); + safeClose(WEBSOCKET_DISABLED); + websocket = null; + retryCount = 0; + sendWSNotice('Websocket forcefully disconnected. (Retry count reset)', true); +} + +export function wsSend(msg: Record): void { + if (websocket?.readyState === WebSocket.OPEN) { + websocket.send(JSON.stringify(msg)); + } +}