Skip to content

Commit ff414f3

Browse files
flleeppyyAbsolucy
andauthored
Chatbox Websocket client (#5744)
<!-- Write **BELOW** The Headers and **ABOVE** The comments else it may not be viewable. --> <!-- You can view Contributing.MD for a detailed description of the pull request process. --> ## About The Pull Request This PR adds a websocket client to the chatbox, that redirects all incoming payloads from the server, to the specified server, which allows for external integrations with the game. All of which is parsable JSON. Some ideas include RGB feedback, triggering an event in VTubing software, or automating a physical device based on what's sent to the chat. (For example, a haptic suit, and have a regex for "in the right leg" or any body part) Works on 515 and 516. (I didn't realize IE11 had websockets until I went to go and make sure my code to disable it worked, then had to double check myself because it was connecting to my websocket server, and working. so. LOL) Here's a barebones snippet, not feature complete but it has enough where you can start with phrase ```js const ws = require("ws"); const phrases = require("./phrases"); const LISTENPORT = 8094; const wss = new ws.Server({ port: LISTENPORT }); wss.on("listening", () => console.log(`Listening on ${LISTENPORT}`)); wss.on("connection", (socket) => { console.log("Client connected!"); socket.on("message", async (data) => { try { const messageData = JSON.parse(data.toString("utf-8")); if (messageData.type !== "chat/message") return; const parsedPayload = JSON.parse(messageData.payload); if (!parsedPayload?.content?.html) return; const message = parsedPayload.content.html.toLowerCase(); const matchedPhrase = phrases.phrases.find((phrase) => message.match(phrase.regex) ); if (matchedPhrase) { // Catched phrase could be something like "stabs you in the chest with the kitchen knife" // /(userdanger'>(?:.*you.*in the.*with the))/gi // example, setup a websocket client to connect to vNyan and send the command "modelBleed" if (matchedPhrase.type === "vnyanAction") vnyanWs.send(JSON.stringify(matchedPhrase.command)) } } catch (e) { console.error("Message Processing Error:", e); } }); socket.on("close", () => console.log("Client disconnected.")); }); ``` ```js modules.export = { phrases: [ { regex: /(userdanger'>(?:.*you.*in the.*with the))/gi, type: "vnyanAction", command: "modelBleed" }, ] } ``` ## Why It's Good For The Game QoL for people who want things outside of the game to respond to in-game actions. See above examples. ## Changelog <!-- If your PR modifies aspects of the game that can be concretely observed by players or admins you should add a changelog. If your change does NOT meet this description, remove this section. Be sure to properly mark your PRs to prevent unnecessary GBP loss. You can read up on GBP and it's effects on PRs in the tgstation guides for contributors. Please note that maintainers freely reserve the right to remove and add tags should they deem it appropriate. You can attempt to finagle the system all you want, but it's best to shoot for clear communication right off the bat. --> :cl: add: Chatbox websocket feature /:cl: <!-- Both :cl:'s are required for the changelog to work! You can put your name to the right of the first :cl: if you want to overwrite your GitHub username as author ingame. --> <!-- You can use multiple of the same prefix (they're only used for the icon ingame) and delete the unneeded ones. Despite some of the tags, changelogs should generally represent how a player might be affected by the changes rather than a summary of the PR's contents. --> --------- Co-authored-by: Lucy <[email protected]>
1 parent 06ca1c8 commit ff414f3

File tree

5 files changed

+234
-0
lines changed

5 files changed

+234
-0
lines changed

tgui/packages/tgui-panel/index.jsx

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { pingMiddleware, pingReducer } from './ping';
2323
import { settingsMiddleware, settingsReducer } from './settings';
2424
import { telemetryMiddleware } from './telemetry';
2525
import { setGlobalStore } from 'tgui/backend';
26+
import { websocketMiddleware } from './websocket';
2627

2728
perf.mark('inception', window.performance?.timing?.navigationStart);
2829
perf.mark('init');
@@ -37,6 +38,7 @@ const store = configureStore({
3738
}),
3839
middleware: {
3940
pre: [
41+
websocketMiddleware,
4042
chatMiddleware,
4143
pingMiddleware,
4244
telemetryMiddleware,

tgui/packages/tgui-panel/settings/SettingsPanel.jsx

+91
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import {
4040
selectHighlightSettings,
4141
selectHighlightSettingById,
4242
} from './selectors';
43+
import { reconnectWebsocket, disconnectWebsocket } from '../websocket';
44+
import { chatRenderer } from '../chat/renderer';
4345

4446
export const SettingsPanel = (props, context) => {
4547
const activeTab = useSelector(context, selectActiveTab);
@@ -71,6 +73,7 @@ export const SettingsPanel = (props, context) => {
7173
{activeTab === 'general' && <SettingsGeneral />}
7274
{activeTab === 'chatPage' && <ChatPageSettings />}
7375
{activeTab === 'textHighlight' && <TextHighlightSettings />}
76+
{activeTab === 'experimental' && <ExperimentalSettings />}
7477
</Stack.Item>
7578
</Stack>
7679
);
@@ -393,3 +396,91 @@ const TextHighlightSetting = (props, context) => {
393396
</Stack.Item>
394397
);
395398
};
399+
400+
const ExperimentalSettings = (props, context) => {
401+
const { websocketEnabled, websocketServer } = useSelector(
402+
context,
403+
selectSettings,
404+
);
405+
const dispatch = useDispatch(context);
406+
407+
return (
408+
<Section>
409+
<Stack vertical>
410+
<Stack.Item>
411+
<LabeledList>
412+
<LabeledList.Item label="Websocket Client">
413+
<Button.Checkbox
414+
content={'Enabled'}
415+
checked={websocketEnabled}
416+
color="transparent"
417+
onClick={() =>
418+
dispatch(
419+
updateSettings({
420+
websocketEnabled: !websocketEnabled,
421+
}),
422+
)
423+
}
424+
/>
425+
<Button
426+
icon={'question'}
427+
onClick={() => {
428+
chatRenderer.processBatch([
429+
{
430+
html:
431+
'<div class="boxed_message"><b>Websocket Information</b><br><span class="notice">' +
432+
'Quick rundown. This connects to the specified websocket server, and ' +
433+
'forwards all data/payloads from the server, to the websocket. Allowing ' +
434+
'you to have in-game actions reflect in other services, or the real ' +
435+
'world, (ex. Reactive RGB, haptics, play effects/animations in vtubing ' +
436+
'software, etc). You can find more information ' +
437+
'<a href="https://github.com/Monkestation/Monkestation2.0/pull/5744">here in the pull request.</a></span></div>',
438+
},
439+
]);
440+
}}
441+
/>
442+
</LabeledList.Item>
443+
<LabeledList.Item label="Websocket Server">
444+
<Stack.Item>
445+
<Stack>
446+
<Input
447+
width={'100%'}
448+
value={websocketServer}
449+
placeholder="localhost:1990"
450+
onChange={(e, value) =>
451+
dispatch(
452+
updateSettings({
453+
websocketServer: value,
454+
}),
455+
)
456+
}
457+
/>
458+
</Stack>
459+
</Stack.Item>
460+
</LabeledList.Item>
461+
<LabeledList.Item label="Websocket Controls">
462+
<Button
463+
ml={0.5}
464+
content="Force Reconnect"
465+
icon={'globe'}
466+
color={'good'}
467+
onClick={() => {
468+
dispatch(reconnectWebsocket());
469+
}}
470+
/>
471+
<Button
472+
ml={0.5}
473+
content="Force Disconnect"
474+
icon={'globe'}
475+
color={'bad'}
476+
onClick={() => {
477+
dispatch(disconnectWebsocket());
478+
}}
479+
/>
480+
</LabeledList.Item>
481+
</LabeledList>
482+
</Stack.Item>
483+
</Stack>
484+
</Section>
485+
);
486+
};

tgui/packages/tgui-panel/settings/constants.js

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export const SETTINGS_TABS = [
1818
id: 'chatPage',
1919
name: 'Chat Tabs',
2020
},
21+
{
22+
id: 'experimental',
23+
name: 'Experimental',
24+
},
2125
];
2226

2327
export const FONTS_DISABLED = 'Default';

tgui/packages/tgui-panel/settings/reducer.js

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const initialState = {
3838
visible: false,
3939
activeTab: SETTINGS_TABS[0].id,
4040
},
41+
websocketEnabled: false,
42+
websocketServer: '',
4143
};
4244

4345
export const settingsReducer = (state = initialState, action) => {

tgui/packages/tgui-panel/websocket.ts

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { createAction } from 'common/redux';
2+
import { chatRenderer } from './chat/renderer';
3+
import { loadSettings, updateSettings } from './settings/actions';
4+
import { selectSettings } from './settings/selectors';
5+
6+
const sendWSNotice = (message, small = false) => {
7+
chatRenderer.processBatch([
8+
{
9+
html: small
10+
? `<span class='adminsay'>${message}</span>`
11+
: `<div class="boxed_message"><center><span class='alertwarning'>${message}</span></center></div>`,
12+
},
13+
]);
14+
};
15+
16+
export const reconnectWebsocket = createAction('websocket/reconnect');
17+
export const disconnectWebsocket = createAction('websocket/disconnect');
18+
19+
// Websocket close codes
20+
const WEBSOCKET_DISABLED = 4555;
21+
const WEBSOCKET_REATTEMPT = 4556;
22+
23+
export const websocketMiddleware = (store) => {
24+
let websocket: WebSocket | null = null;
25+
26+
const setupWebsocket = (store) => {
27+
const { websocketEnabled, websocketServer } = selectSettings(
28+
store.getState(),
29+
);
30+
if (!websocketEnabled) {
31+
websocket?.close(WEBSOCKET_REATTEMPT);
32+
return;
33+
}
34+
35+
websocket?.close(WEBSOCKET_REATTEMPT);
36+
37+
try {
38+
websocket = new WebSocket(`ws://${websocketServer}`);
39+
} catch (e) {
40+
if (e.name === 'SyntaxError') {
41+
sendWSNotice(
42+
`Error creating websocket: Invalid address! Make sure you're following the placeholder. Example: <code>localhost:1234</code>`,
43+
);
44+
return;
45+
}
46+
sendWSNotice(`Error creating websocket: ${e.name} - ${e.message}`);
47+
return;
48+
}
49+
50+
websocket.addEventListener('open', () => {
51+
sendWSNotice('Websocket connected!', true);
52+
});
53+
54+
websocket.addEventListener('close', function closeEventThing(ev) {
55+
const { websocketEnabled } = selectSettings(store.getState());
56+
if (!websocketEnabled) {
57+
// Doing this because eitherwise it 'close' will get called
58+
// thousands of times per second if the connection wasn't closed properly.
59+
// I don't know WHY it does that but it just does.
60+
ev.target?.removeEventListener('close', closeEventThing);
61+
websocket?.removeEventListener('close', closeEventThing);
62+
return;
63+
}
64+
if (ev.code !== WEBSOCKET_DISABLED && ev.code !== WEBSOCKET_REATTEMPT) {
65+
sendWSNotice(
66+
`Websocket disconnected! Code: ${ev.code} Reason: ${ev.reason || 'None provided'}`,
67+
);
68+
}
69+
});
70+
71+
websocket.addEventListener('error', () => {
72+
// Really don't think we should do anything here.
73+
// setTimeout(() => setupWebsocket(store), 2000);
74+
});
75+
};
76+
77+
setTimeout(() => setupWebsocket(store));
78+
79+
return (next) => (action) => {
80+
const { type, payload } = action as {
81+
type: string;
82+
payload: {
83+
websocketEnabled: boolean;
84+
websocketServer: string;
85+
};
86+
};
87+
if (!payload) return next(action);
88+
if (type === updateSettings.type || type === loadSettings.type) {
89+
if (typeof payload?.websocketEnabled === 'undefined') {
90+
store.dispatch(
91+
updateSettings({
92+
websocketEnabled: false,
93+
}),
94+
);
95+
return next(action);
96+
}
97+
if (!payload.websocketEnabled) {
98+
websocket?.close(WEBSOCKET_DISABLED);
99+
websocket = null;
100+
} else if (
101+
!websocket ||
102+
websocket.url !== payload.websocketServer ||
103+
(payload.websocketEnabled &&
104+
(!websocket || websocket.readyState !== websocket.OPEN))
105+
) {
106+
websocket?.close(WEBSOCKET_REATTEMPT, 'Websocket settings changed');
107+
sendWSNotice('Websocket enabled.', true);
108+
setupWebsocket(store);
109+
}
110+
return next(action);
111+
}
112+
113+
if (type === reconnectWebsocket.type) {
114+
const settings = selectSettings(store.getState());
115+
if (settings.websocketEnabled) setupWebsocket(store);
116+
return next(action);
117+
}
118+
119+
if (type === disconnectWebsocket.type) {
120+
websocket?.close(WEBSOCKET_DISABLED);
121+
websocket = null;
122+
sendWSNotice('Websocket forcefully disconnected.', true);
123+
}
124+
125+
websocket &&
126+
websocket.readyState === websocket.OPEN &&
127+
websocket?.send(
128+
JSON.stringify({
129+
type,
130+
payload,
131+
}),
132+
);
133+
return next(action);
134+
};
135+
};

0 commit comments

Comments
 (0)