port of chatbox websocket client [No GBP] (#19496)

* port of chatbox websocket client

* .
This commit is contained in:
Kashargul
2026-07-24 11:06:19 +02:00
committed by GitHub
parent 933c54648d
commit 60c07f65dd
9 changed files with 348 additions and 1 deletions
+33
View File
@@ -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)
+5 -1
View File
@@ -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(<App />);
// 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', {
@@ -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 (
<Section fill>
<Stack fill vertical>
<Stack.Item>
<LabeledList>
<LabeledList.Item label="Websocket Client">
<Button.Checkbox
checked={settings.websocketEnabled}
color="transparent"
onClick={() => {
const websocketEnabled = !settings.websocketEnabled;
updateSettings({ websocketEnabled });
wsUpdate(websocketEnabled);
}}
>
Enabled
</Button.Checkbox>
<Button
icon={'question'}
onClick={() => {
chatRenderer.processBatch([
{
type: '',
createdAt: Date.now(),
roundId: game.roundId,
html:
'<div class="boxed_message"><b>Websocket Information</b><br><span class="notice">' +
'Quick rundown. This connects to the specified websocket server, and ' +
'forwards all data/payloads from the server, to the websocket. Allowing ' +
'you to have in-game actions reflect in other services, or the real ' +
'world, (ex. Reactive RGB, haptics, play effects/animations in vtubing ' +
'software, etc). You can find more information ' +
'<a href="https://github.com/tgstation/tgstation/pull/96241">here in the pull request.</a></span></div>',
},
]);
}}
/>
</LabeledList.Item>
<LabeledList.Item label="Websocket Server">
<Stack.Item>
<Stack>
<Input
width={'100%'}
value={settings.websocketServer}
placeholder="localhost:4242"
onChange={(value) =>
updateSettings({
websocketServer: value,
})
}
/>
</Stack>
</Stack.Item>
</LabeledList.Item>
<LabeledList.Item label="Websocket Controls">
<Button
ml={0.5}
icon={'globe'}
color={'good'}
onClick={wsReconnect}
>
Force Reconnect
</Button>
<Button
ml={0.5}
icon={'globe'}
color={'bad'}
onClick={wsDisconnect}
>
Force Disconnect
</Button>
</LabeledList.Item>
</LabeledList>
</Stack.Item>
</Stack>
</Section>
);
}
@@ -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' && <ChatPageSettings />}
{activeTab === 'statPanel' && <SettingsStatPanel />}
{activeTab === 'ttsSettings' && <TTSSettings />}
{activeTab === 'websocket' && <SettingsWebsocket />}
</Stack.Item>
</Stack>
);
@@ -33,6 +33,8 @@ export const defaultSettings: SettingsState = {
activeTab: SETTINGS_TABS[0].id,
visible: false,
},
websocketEnabled: false,
websocketServer: '',
visibleMessageLimit: 2500,
visibleMessages: 0,
};
@@ -39,6 +39,10 @@ export const SETTINGS_TABS = [
id: 'ttsSettings',
name: 'TTS/Accessibility',
},
{
id: 'websocket',
name: 'Websocket',
},
] as const;
export const FONTS_DISABLED = 'Default';
@@ -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 });
@@ -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(),
});
@@ -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
? `<span class='adminsay'>${message}</span>`
: `<div class="boxed_message"><center><span class='alertwarning'>${message}</span></center></div>`,
},
]);
}
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: <code>localhost:1234</code>`,
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<string, any>): void {
if (websocket?.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify(msg));
}
}