mirror of
https://github.com/VOREStation/VOREStation.git
synced 2026-08-23 12:08:28 +01:00
port of chatbox websocket client [No GBP] (#19496)
* port of chatbox websocket client * .
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user