[MIRROR] Chat history prototype (#10278)

Co-authored-by: Selis <12716288+ItsSelis@users.noreply.github.com>
Co-authored-by: Kashargul <144968721+Kashargul@users.noreply.github.com>
This commit is contained in:
CHOMPStation2StaffMirrorBot
2025-03-02 09:22:34 -07:00
committed by GitHub
parent 36231619db
commit 09bb0e61b3
33 changed files with 746 additions and 235 deletions

View File

@@ -231,3 +231,22 @@ CREATE TABLE IF NOT EXISTS `round` (
`station_name` VARCHAR(80) NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `chatlogs_ckeys` (
`ckey` varchar(45) NOT NULL,
`token` varchar(255) DEFAULT NULL,
PRIMARY KEY (`ckey`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `chatlogs_logs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`round_id` int(11) NOT NULL DEFAULT -1,
`target` varchar(45) NOT NULL,
`text` mediumtext NOT NULL,
`text_raw` mediumtext NOT NULL,
`type` varchar(128) DEFAULT NULL,
`created_at` bigint(20) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `chatlogs_ckeys_FK` (`target`),
CONSTRAINT `chatlogs_ckeys_FK` FOREIGN KEY (`target`) REFERENCES `chatlogs_ckeys` (`ckey`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View File

@@ -52,6 +52,7 @@
#define TOTAL_HUDS 14 // Total number of HUDs. Like body layers, and other things, it comes up sometimes.
#define CLIENT_FROM_VAR(I) (ismob(I) ? I:client : (isclient(I) ? I : null))
#define CKEY_FROM_VAR(I) ((ismob(I) || isclient(I)) ? I:ckey : null)
// Shuttles.

View File

@@ -0,0 +1,39 @@
/* This comment bypasses grep checks */ /var/__vchatlog
/proc/__detect_vchatlog()
if (world.system_type == UNIX)
return __vchatlog = (fexists("./libvchatlog.so") ? "./libvchatlog.so" : "libvchatlog")
else
return __vchatlog = "vchatlog"
#define VCHATLOG (__vchatlog || __detect_vchatlog())
#define VCHATLOG_CALL(name, args...) call_ext(VCHATLOG, "byond:" + name)(args)
/**
* Generates and returns a random access token, for external API communication.
* The token is only valid for the current round.
*
* Arguments:
* * ckey - Ckey of the message receiver
* * token - Randomized token
*/
#define vchatlog_generate_token(ckey) VCHATLOG_CALL("generate_token", ckey)
/**
* Writes a new chatlog entry to the database. This function does not return anything.
*
* Arguments:
* * ckey - Ckey of the message receiver
* * html - HTML of the received message
* * round_id - Current ID of the round (library will resolve this to -1 if invalid or non-existant)
*/
#define vchatlog_write(ckey, html, round_id, type) VCHATLOG_CALL("write_chatlog", ckey, html, round_id, type)
/**
* This function returns a list of the 10 most recent roundids that are available to be exported.
* Note: -1 might appear. This id is used for internal library failures. Use with caution.
*
* Arguments:
* * ckey - Ckey of the message receiver
*/
#define vchatlog_get_recent_roundids(ckey) VCHATLOG_CALL("get_recent_roundids", ckey)

View File

@@ -712,3 +712,12 @@
/// Controls whether simple mobs may recolour themselves once/spawn by giving them the recolour verb
/// Admins may manually give them the verb even if disabled
/datum/config_entry/flag/allow_simple_mob_recolor
/// Chatlogs are now saved by calling the chatlogging library instead of letting the clients handle it
/// REQUIRES an database
/datum/config_entry/flag/chatlog_database_backend
default = FALSE
/// The endpoint for the chat to fetch the chatlogs from (for example, the last 2500 messages on init for the history)
/// REQUIRES chatlog_database_backend to be enabled
/datum/config_entry/string/chatlog_database_api_endpoint

View File

@@ -16,7 +16,7 @@ SUBSYSTEM_DEF(inactivity)
var/client/C = client_list[client_list.len]
client_list.len--
if(C.is_afk(CONFIG_GET(number/kick_inactive) MINUTES) && can_kick(C))
to_chat_immediate(C, world.time, span_warning("You have been inactive for more than [CONFIG_GET(number/kick_inactive)] minute\s and have been disconnected."))
to_chat_immediate(C, span_warning("You have been inactive for more than [CONFIG_GET(number/kick_inactive)] minute\s and have been disconnected."))
var/information
if(C.mob)

View File

@@ -179,3 +179,6 @@
/// If this client has been fully initialized or not
var/fully_created = FALSE
/// Token used for the external chatlog api. Only valid for the current round.
var/chatlog_token

View File

@@ -247,6 +247,9 @@
GLOB.clients += src
GLOB.directory[ckey] = src
if (CONFIG_GET(flag/chatlog_database_backend))
chatlog_token = vchatlog_generate_token(ckey)
// Instantiate stat panel
stat_panel = new(src, "statbrowser")
stat_panel.subscribe(src, .proc/on_stat_panel_message)

View File

@@ -29,6 +29,9 @@
if(target == world)
target = GLOB.clients
if(islist(target) && !LAZYLEN(target))
return
// Build a message
var/message = list()
if(type) message["type"] = type
@@ -39,6 +42,18 @@
// send it immediately
SSchat.send_immediate(target, message)
if (CONFIG_GET(flag/chatlog_database_backend))
if (islist(target))
for(var/tgt in target)
var/our_ckey = CKEY_FROM_VAR(tgt)
if(isnull(our_ckey))
continue
vchatlog_write(our_ckey, html, GLOB.round_id, type)
else
var/our_ckey = CKEY_FROM_VAR(target)
if(!isnull(our_ckey))
vchatlog_write(our_ckey, html, GLOB.round_id, type)
/**
* Sends the message to the recipient (target).
*
@@ -76,6 +91,9 @@
if(target == world)
target = GLOB.clients
if(islist(target) && !LAZYLEN(target))
return
// Build a message
var/message = list()
if(type) message["type"] = type
@@ -83,3 +101,15 @@
if(html) message["html"] = html
if(avoid_highlighting) message["avoidHighlighting"] = avoid_highlighting
SSchat.queue(target, message)
if (CONFIG_GET(flag/chatlog_database_backend))
if (islist(target))
for(var/tgt in target)
var/our_ckey = CKEY_FROM_VAR(tgt)
if(isnull(our_ckey))
continue
vchatlog_write(our_ckey, html, GLOB.round_id, type)
else
var/our_ckey = CKEY_FROM_VAR(target)
if(!isnull(our_ckey))
vchatlog_write(our_ckey, html, GLOB.round_id, type)

View File

@@ -73,11 +73,18 @@
/datum/tgui_panel/proc/on_message(type, payload)
if(type == "ready")
broken = FALSE
window.send_message("connected", list("round_id" = GLOB.round_id)) // Sends the round ID to the chat, requires round IDs
var/list/stored_rounds = CONFIG_GET(flag/chatlog_database_backend) ? vchatlog_get_recent_roundids(client.ckey) : null
window.send_message("connected", list(
"round_id" = GLOB.round_id, // Sends the round ID to the chat, requires round IDs
"chatlog_db_backend" = CONFIG_GET(flag/chatlog_database_backend),
"chatlog_api_endpoint" = CONFIG_GET(string/chatlog_database_api_endpoint),
"chatlog_stored_rounds" = islist(stored_rounds) ? list("0") + stored_rounds : list("0"),
))
window.send_message("update", list(
"config" = list(
"client" = list(
"ckey" = client.ckey,
"chatlog_token" = client.chatlog_token,
"address" = client.address,
"computer_id" = client.computer_id,
),

View File

@@ -587,3 +587,12 @@ JUKEBOX_TRACK_FILES config/jukebox.json
# Admins may manually give them the verb even if disabled
# Uncomment to enable
#ALLOW_SIMPLE_MOB_RECOLOR
# Chatlogs are now saved by calling the chatlogging library instead of letting the clients handle it
# REQUIRES an database
# Uncomment to enable
#CHATLOG_DATABASE_BACKEND
# The endpoint for the chat to fetch the chatlogs from (for example, the last 2500 messages on init for the history)
# REQUIRES chatlog_database_backend to be enabled
#CHATLOG_DATABASE_API_ENDPOINT https://example.com

BIN
libvchatlog.so Executable file

Binary file not shown.

View File

@@ -29,6 +29,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/webpack-env": "^1.18.5",
"@types/wicg-file-system-access": "^2023.10.5",
"@typescript-eslint/parser": "^8.13.0",
"@typescript-eslint/utils": "^8.13.0",
"css-loader": "^7.1.2",

View File

@@ -8,6 +8,7 @@ import { createAction } from 'common/redux';
import { createPage } from './model';
export const getChatData = createAction('chat/getChatData');
export const loadChat = createAction('chat/load');
export const rebuildChat = createAction('chat/rebuild');
export const updateMessageCount = createAction('chat/updateMessageCount');

View File

@@ -0,0 +1,162 @@
import { useGame } from '../game';
import type { gameState } from '../game/types';
import { useSettings } from '../settings';
import { MESSAGE_TYPE_UNKNOWN, MESSAGE_TYPES } from './constants';
import { canPageAcceptType } from './model';
import { createMessageNode } from './renderer';
import type { message, Page } from './types';
export function exportToDisk(
cssText: string,
startRound: number,
endRound: number,
hasTimestamps: boolean,
page: Page | null,
) {
const game: gameState = useGame();
// Fetch from server database
const opts: SaveFilePickerOptions = {
id: `ss13-chatlog-${game.roundId}`,
suggestedName: `ss13-chatlog-${game.roundId}.html`,
types: [
{
description: 'SS13 file',
accept: { 'text/plain': ['.html'] },
},
],
};
// We have to do it likes this, otherwise we get a security error
// only 516 can do this btw
if (startRound < endRound) {
getRound(cssText, game, page, opts, hasTimestamps, startRound, endRound);
} else if (startRound > 0) {
getRound(cssText, game, page, opts, hasTimestamps, startRound);
} else {
getRound(cssText, game, page, opts, hasTimestamps);
}
}
async function getRound(
cssText: string,
game: gameState,
page: Page | null,
opts: SaveFilePickerOptions,
hasTimestamps: boolean,
startRound?: number,
endRound?: number,
) {
const settings = useSettings();
const { ckey, token } = game.userData;
let messages: message[] = [];
const d = new Date();
const utcOffset = d.getTimezoneOffset() / -60;
let roundToExport = startRound;
if (!roundToExport) {
roundToExport = game.roundId ? game.roundId : 0;
}
let requestString = `${game.chatlogApiEndpoint}/api/export/${ckey}/${settings.logLineCount}?start_id=${roundToExport}`;
if (hasTimestamps) {
requestString += `&timezone_offset=${utcOffset}`;
}
if (endRound) {
requestString += `&end_id=${endRound}`;
}
window
.showSaveFilePicker(opts)
.then(async (fileHandle) => {
await new Promise<void>((resolve) => {
fetch(requestString, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
.then((response) => response.json())
.then((json) => {
json.forEach(
(obj: {
msg_type: string | null;
text_raw: string;
created_at: number;
round_id: number;
}) => {
const msg: message = {
type: obj.msg_type ? obj.msg_type : '',
html: obj.text_raw,
createdAt: obj.created_at,
roundId: obj.round_id,
};
const node = createMessageNode();
node.innerHTML = msg.html ? msg.html : '';
if (!msg.type) {
const typeDef = MESSAGE_TYPES.find(
(typeDef) =>
typeDef.selector && node.querySelector(typeDef.selector),
);
msg.type = typeDef?.type || MESSAGE_TYPE_UNKNOWN;
}
msg.html = node.outerHTML;
messages.push(msg);
},
);
let messagesHtml = '';
if (messages) {
for (let message of messages) {
// Filter messages according to active tab for export
if (page && canPageAcceptType(page, message.type)) {
messagesHtml += message.html + '\n';
}
}
const pageHtml =
'<!doctype html>\n' +
'<html>\n' +
'<head>\n' +
'<title>SS13 Chat Log - Round ' +
game.roundId +
'</title>\n' +
'<style>\n' +
cssText +
'</style>\n' +
'</head>\n' +
'<body>\n' +
'<div class="Chat">\n' +
messagesHtml +
'</div>\n' +
'</body>\n' +
'</html>\n';
try {
fileHandle.createWritable().then((writableHandle) => {
writableHandle.write(pageHtml);
writableHandle.close();
});
} catch (e) {
console.error(e);
}
resolve();
}
})
.catch((e) => {
console.error(e);
resolve();
});
});
})
.catch((e) => {
// Log the error if the error has nothing to do with the user aborting the download
if (e.name !== 'AbortError') {
console.error(e);
}
});
}

View File

@@ -16,11 +16,13 @@ import {
updateHighlightSetting,
updateSettings,
} from '../settings/actions';
import { blacklisted_tags } from '../settings/constants';
import { selectSettings } from '../settings/selectors';
import {
addChatPage,
changeChatPage,
changeScrollTracking,
getChatData,
loadChat,
moveChatPageLeft,
moveChatPageRight,
@@ -34,29 +36,31 @@ import {
import { createMessage, serializeMessage } from './model';
import { chatRenderer } from './renderer';
import { selectChat, selectCurrentChatPage } from './selectors';
import { message } from './types';
import type { message } from './types';
// List of blacklisted tags
const blacklisted_tags = ['a', 'iframe', 'link', 'video'];
let storedRounds: number[] = [];
let storedLines: number[] = [];
const saveChatToStorage = async (store: Store<number, Action<string>>) => {
const game = selectGame(store.getState());
const state = selectChat(store.getState());
const settings = selectSettings(store.getState());
const fromIndex = Math.max(
0,
chatRenderer.messages.length - settings.persistentMessageLimit,
);
const messages = chatRenderer.messages
.slice(fromIndex)
.map((message) => serializeMessage(message));
storage.set('chat-state', state);
storage.set('chat-messages', messages);
storage.set(
'chat-messages-archive',
chatRenderer.archivedMessages.map((message) => serializeMessage(message)),
); // FIXME: Better chat history
if (!game.databaseBackendEnabled) {
const fromIndex = Math.max(
0,
chatRenderer.messages.length - settings.persistentMessageLimit,
);
const messages = chatRenderer.messages
.slice(fromIndex)
.map((message) => serializeMessage(message));
storage.set('chat-messages', messages);
storage.set(
'chat-messages-archive',
chatRenderer.archivedMessages.map((message) => serializeMessage(message)),
);
} // FIXME: Better chat history
};
const loadChatFromStorage = async (store: Store<number, Action<string>>) => {
@@ -136,6 +140,83 @@ const loadChatFromStorage = async (store: Store<number, Action<string>>) => {
store.dispatch(loadChat(state));
};
const loadChatFromDBStorage = async (
store: Store<number, Action<string>>,
user_payload: { ckey: string; token: string },
) => {
const game = selectGame(store.getState());
const settings = selectSettings(store.getState());
const [state] = await Promise.all([storage.get('chat-state')]);
// Discard incompatible versions
if (state && state.version <= 4) {
store.dispatch(loadChat());
return;
}
const messages: message[] = []; // FIX ME, load from DB, first load has errors => check console
// Thanks for inventing async/await
await new Promise<void>((resolve) => {
fetch(
`${game.chatlogApiEndpoint}/api/logs/${user_payload.ckey}/${settings.persistentMessageLimit}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${user_payload.token}`,
'Content-Type': 'application/json',
},
},
)
.then((response) => response.json())
.then((json) => {
json.forEach(
(obj: {
msg_type: string | null;
text_raw: string;
created_at: number;
round_id: number;
}) => {
const msg: message = {
type: obj.msg_type ? obj.msg_type : '',
html: obj.text_raw,
createdAt: obj.created_at,
roundId: obj.round_id,
};
messages.push(msg);
},
);
if (messages) {
for (let message of messages) {
if (message.html) {
message.html = DOMPurify.sanitize(message.html, {
FORBID_TAGS: blacklisted_tags,
});
}
}
const batch = [
...messages,
createMessage({
type: 'internal/reconnected',
}),
];
chatRenderer.processBatch(batch, {
prepend: true,
});
}
store.dispatch(loadChat(state));
resolve();
})
.catch(() => {
store.dispatch(loadChat(state));
resolve();
});
});
};
export const chatMiddleware = (store) => {
let initialized = false;
let loaded = false;
@@ -170,6 +251,7 @@ export const chatMiddleware = (store) => {
settings.hideImportantInAdminTab,
settings.interleave,
settings.interleaveColor,
game.databaseBackendEnabled,
);
// Load the chat once settings are loaded
if (!initialized && (settings.initialized || settings.firstLoad)) {
@@ -177,7 +259,7 @@ export const chatMiddleware = (store) => {
setInterval(() => {
saveChatToStorage(store);
}, settings.saveInterval * 1000);
loadChatFromStorage(store);
// loadChatFromStorage(store);
}
if (type === 'chat/message') {
let payload_obj;
@@ -280,6 +362,8 @@ export const chatMiddleware = (store) => {
settings.logLineCount,
storedLines[storedLines.length - settings.exportEnd],
storedLines[storedLines.length - settings.exportStart],
settings.exportStart,
settings.exportEnd,
);
return;
}
@@ -293,6 +377,19 @@ export const chatMiddleware = (store) => {
settings.exportEnd = 0;
return;
}
if (type === 'exportDownloadReady') {
const event = new Event('chatexportplaced');
document.dispatchEvent(event);
}
if (type === getChatData.type) {
const user_payload = payload;
if (payload.token) {
loadChatFromDBStorage(store, user_payload);
} else {
loadChatFromStorage(store);
}
return;
}
return next(action);
};
};

View File

@@ -7,7 +7,7 @@
import { createUuid } from 'tgui-core/uuid';
import { MESSAGE_TYPE_INTERNAL, MESSAGE_TYPES } from './constants';
import { message, Page } from './types';
import type { message, Page } from './types';
export const canPageAcceptType = (page: Page, type: string): string | boolean =>
type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type];
@@ -41,8 +41,10 @@ export const adminPageOnly = (page: Page): boolean => {
return checked > 0 && adminTab;
};
export const canStoreType = (storedTypes: Object, type: string) =>
storedTypes[type];
export const canStoreType = (
storedTypes: Record<string, string>,
type: string,
) => storedTypes[type];
export const createPage = (obj?: Object): Page => {
let acceptedTypes = {};

View File

@@ -10,6 +10,7 @@ import { Tooltip } from 'tgui-core/components';
import { EventEmitter } from 'tgui-core/events';
import { classes } from 'tgui-core/react';
import { exportToDisk } from './chatExport';
import {
IMAGE_RETRY_DELAY,
IMAGE_RETRY_LIMIT,
@@ -29,7 +30,7 @@ import {
typeIsImportant,
} from './model';
import { highlightNode, linkifyNode } from './replaceInTextNode';
import type { message } from './types';
import type { message, Page } from './types';
const logger = createLogger('chatRenderer');
@@ -72,7 +73,7 @@ const createHighlightNode = (text: string, color: string): HTMLElement => {
return node;
};
const createMessageNode = (): HTMLElement => {
export const createMessageNode = (): HTMLElement => {
const node = document.createElement('div');
node.className = 'ChatMessage';
return node;
@@ -123,18 +124,19 @@ const handleImageError = (e: ErrorEvent) => {
setTimeout(() => {
/** @type {HTMLImageElement} */
const node = e.target as HTMLImageElement;
if (node) {
const attempts =
parseInt(node.getAttribute('data-reload-n') || '', 10) || 0;
if (attempts >= IMAGE_RETRY_LIMIT) {
logger.error(`failed to load an image after ${attempts} attempts`);
return;
}
const src = node.src;
node.src = '';
node.src = src + '#' + attempts;
node.setAttribute('data-reload-n', (attempts + 1).toString());
if (!node) {
return;
}
const attempts =
parseInt(node.getAttribute('data-reload-n') || '', 10) || 0;
if (attempts >= IMAGE_RETRY_LIMIT) {
// logger.error(`failed to load an image after ${attempts} attempts`);
return;
}
const src = node.src;
node.src = '';
node.src = src + '#' + attempts;
node.setAttribute('data-reload-n', (attempts + 1).toString());
}, IMAGE_RETRY_DELAY);
};
@@ -162,11 +164,11 @@ const updateMessageBadge = (message: message) => {
class ChatRenderer {
loaded: boolean;
rootNode: HTMLElement | null;
queue: string[];
queue: message[];
messages: message[];
archivedMessages: message[];
visibleMessages: message[];
page: null;
page: Page | null;
events: EventEmitter;
prependTimestamps: boolean;
visibleMessageLimit: number;
@@ -176,7 +178,7 @@ class ChatRenderer {
logLimit: number;
logEnable: boolean;
roundId: null | number;
storedTypes: {};
storedTypes: Record<string, string>;
interleave: boolean;
interleaveEnabled: boolean;
interleaveColor: string;
@@ -195,6 +197,7 @@ class ChatRenderer {
blacklistregex: RegExp;
}[]
| null;
databaseBackendEnabled: boolean;
constructor() {
/** @type {HTMLElement} */
this.loaded = false;
@@ -219,6 +222,7 @@ class ChatRenderer {
this.interleaveEnabled = false;
this.interleaveColor = '#909090';
this.hideImportantInAdminTab = false;
this.databaseBackendEnabled = false;
// Scroll handler
/** @type {HTMLElement} */
this.scrollNode = null;
@@ -448,12 +452,13 @@ class ChatRenderer {
combineIntervalLimit: number,
logEnable: boolean,
logLimit: number,
storedTypes: {},
storedTypes: Record<string, string>,
roundId: number | null,
prependTimestamps: boolean,
hideImportantInAdminTab: boolean,
interleaveEnabled: boolean,
interleaveColor: string,
databaseBackendEnabled: boolean,
) {
this.visibleMessageLimit = visibleMessageLimit;
this.combineMessageLimit = combineMessageLimit;
@@ -466,9 +471,10 @@ class ChatRenderer {
this.hideImportantInAdminTab = hideImportantInAdminTab;
this.interleaveEnabled = interleaveEnabled;
this.interleaveColor = interleaveColor;
this.databaseBackendEnabled = databaseBackendEnabled;
}
changePage(page) {
changePage(page: Page) {
if (!this.isReady()) {
this.page = page;
this.tryFlushQueue();
@@ -509,7 +515,7 @@ class ChatRenderer {
}
}
getCombinableMessage(predicate) {
getCombinableMessage(predicate: message) {
const now = Date.now();
const len = this.visibleMessages.length;
const from = len - 1;
@@ -532,7 +538,7 @@ class ChatRenderer {
}
processBatch(
batch,
batch: message[],
options: {
prepend?: boolean;
notifyListeners?: boolean;
@@ -692,6 +698,7 @@ class ChatRenderer {
if (
doArchive &&
this.logEnable &&
!this.databaseBackendEnabled &&
this.storedTypes &&
canStoreType(this.storedTypes, message.type)
) {
@@ -792,7 +799,7 @@ class ChatRenderer {
}
}
rebuildChat(rebuildLimit) {
rebuildChat(rebuildLimit: number) {
if (!this.isReady()) {
return;
}
@@ -815,7 +822,13 @@ class ChatRenderer {
});
}
saveToDisk(logLineCount, startLine = 0, endLine = 0) {
saveToDisk(
logLineCount: number = 0,
startLine: number = 0,
endLine: number = 0,
startRound: number = 0,
endRound: number = 0,
) {
// Compile currently loaded stylesheets as CSS text
let cssText = '';
const styleSheets = document.styleSheets;
@@ -830,59 +843,70 @@ class ChatRenderer {
}
cssText += 'body, html { background-color: #141414 }\n';
// Compile chat log as HTML text
let messagesHtml = '';
let tmpMsgArray: message[] = [];
if (startLine || endLine) {
if (!endLine) {
tmpMsgArray = this.archivedMessages.slice(startLine);
} else {
tmpMsgArray = this.archivedMessages.slice(startLine, endLine);
}
if (logLineCount > 0) {
tmpMsgArray = tmpMsgArray.slice(-logLineCount);
}
} else if (logLineCount > 0) {
tmpMsgArray = this.archivedMessages.slice(-logLineCount);
if (this.databaseBackendEnabled) {
exportToDisk(
cssText,
startRound,
endRound,
this.prependTimestamps,
this.page,
);
} else {
tmpMsgArray = this.archivedMessages;
}
// for (let message of this.visibleMessages) { // TODO: Actually having a better message archiving maybe for exports?
for (let message of tmpMsgArray) {
// Filter messages according to active tab for export
if (this.page && canPageAcceptType(this.page, message.type)) {
messagesHtml += message.html + '\n';
// Fetch from chat storage
let messagesHtml = '';
let tmpMsgArray: message[] = [];
if (startLine || endLine) {
if (!endLine) {
tmpMsgArray = this.archivedMessages.slice(startLine);
} else {
tmpMsgArray = this.archivedMessages.slice(startLine, endLine);
}
if (logLineCount > 0) {
tmpMsgArray = tmpMsgArray.slice(-logLineCount);
}
} else if (logLineCount > 0) {
tmpMsgArray = this.archivedMessages.slice(-logLineCount);
} else {
tmpMsgArray = this.archivedMessages;
}
// if (message.node) {
// messagesHtml += message.node.outerHTML + '\n';
// }
}
// Create a page
const pageHtml =
'<!doctype html>\n' +
'<html>\n' +
'<head>\n' +
'<title>SS13 Chat Log</title>\n' +
'<style>\n' +
cssText +
'</style>\n' +
'</head>\n' +
'<body>\n' +
'<div class="Chat">\n' +
messagesHtml +
'</div>\n' +
'</body>\n' +
'</html>\n';
// Create and send a nice blob
const blob = new Blob([pageHtml], { type: 'text/plain' });
const timestamp = new Date()
.toISOString()
.substring(0, 19)
.replace(/[-:]/g, '')
.replace('T', '-');
Byond.saveBlob(blob, `ss13-chatlog-${timestamp}.html`, '.html');
// for (let message of this.visibleMessages) { // TODO: Actually having a better message archiving maybe for exports?
for (let message of tmpMsgArray) {
// Filter messages according to active tab for export
if (this.page && canPageAcceptType(this.page, message.type)) {
messagesHtml += message.html + '\n';
}
// if (message.node) {
// messagesHtml += message.node.outerHTML + '\n';
// }
}
// Create a page
const pageHtml =
'<!doctype html>\n' +
'<html>\n' +
'<head>\n' +
'<title>SS13 Chat Log</title>\n' +
'<style>\n' +
cssText +
'</style>\n' +
'</head>\n' +
'<body>\n' +
'<div class="Chat">\n' +
messagesHtml +
'</div>\n' +
'</body>\n' +
'</html>\n';
// Create and send a nice blob
const blob = new Blob([pageHtml], { type: 'text/plain' });
const timestamp = new Date()
.toISOString()
.substring(0, 19)
.replace(/[-:]/g, '')
.replace('T', '-');
Byond.saveBlob(blob, `ss13-chatlog-${timestamp}.html`, '.html');
}
}
purgeMessageArchive() {

View File

@@ -10,3 +10,4 @@ export const roundRestarted = createAction('roundrestart');
export const connectionLost = createAction('game/connectionLost');
export const connectionRestored = createAction('game/connectionRestored');
export const dismissWarning = createAction('game/dismissWarning');
export const updateExportData = createAction('game/updateExportData');

View File

@@ -4,16 +4,21 @@
* @license MIT
*/
import { connectionLost } from './actions';
import { connectionLost, updateExportData } from './actions';
import { connectionRestored, dismissWarning } from './actions';
import type { gameState } from './types';
const initialState = {
const initialState: gameState = {
// TODO: This is where round info should be.
roundId: null,
roundTime: null,
roundRestartedAt: null,
connectionLostAt: null,
dismissedConnectionWarning: false,
databaseBackendEnabled: false,
chatlogApiEndpoint: '',
databaseStoredRounds: [],
userData: { ckey: '', token: '' },
};
export const gameReducer = (state = initialState, action) => {
@@ -29,6 +34,9 @@ export const gameReducer = (state = initialState, action) => {
return {
...state,
roundId: payload.round_id,
databaseBackendEnabled: payload.chatlog_db_backend,
chatlogApiEndpoint: payload.chatlog_api_endpoint,
databaseStoredRounds: payload.chatlog_stored_rounds,
};
}
}
@@ -51,5 +59,11 @@ export const gameReducer = (state = initialState, action) => {
dismissedConnectionWarning: true,
};
}
if (type === updateExportData.type) {
return {
...state,
userData: payload,
};
}
return state;
};

View File

@@ -0,0 +1,11 @@
export type gameState = {
roundId: number | null;
roundTime: null | number;
roundRestartedAt: null | number;
connectionLostAt: null | number;
dismissedConnectionWarning: boolean;
databaseBackendEnabled: boolean;
chatlogApiEndpoint: string;
databaseStoredRounds: string[];
userData: { ckey: string; token: string };
};

View File

@@ -5,6 +5,7 @@ import {
Button,
Collapsible,
Divider,
Dropdown,
LabeledList,
NumberInput,
Section,
@@ -32,63 +33,56 @@ export const ExportTab = (props) => {
totalStoredMessages,
storedTypes,
} = useSelector(selectSettings);
const [purgeConfirm, setPurgeConfirm] = useState(0);
const [logConfirm, setLogConfirm] = useState(false);
const [purgeButtonText, setPurgeButtonText] = useState(
'Purge message archive',
);
return (
<Section>
<Stack align="baseline">
{logEnable ? (
logConfirm ? (
<Button
{!game.databaseBackendEnabled &&
(logEnable ? (
<Button.Confirm
icon="ban"
color="red"
confirmIcon="ban"
confirmColor="red"
confirmContent="Disable?"
onClick={() => {
dispatch(
updateSettings({
logEnable: false,
}),
);
setLogConfirm(false);
}}
>
Disable?
</Button>
) : (
<Button
icon="ban"
color="red"
onClick={() => {
setLogConfirm(true);
setTimeout(() => {
setLogConfirm(false);
}, 5000);
}}
>
Disable logging
</Button.Confirm>
) : (
<Button
icon="download"
color="green"
onClick={() => {
dispatch(
updateSettings({
logEnable: true,
}),
);
}}
>
Enable logging
</Button>
)
) : (
<Button
icon="download"
color="green"
onClick={() => {
dispatch(
updateSettings({
logEnable: true,
}),
);
}}
>
Enable logging
</Button>
)}
))}
<Stack.Item grow />
<Stack.Item color="label">Round ID:&nbsp;</Stack.Item>
<Stack.Item color={game.roundId ? '' : 'red'}>
{game.roundId ? game.roundId : 'ERROR'}
</Stack.Item>
<Stack.Item color="label">DB Chatlogging:&nbsp;</Stack.Item>
<Stack.Item color={game.databaseBackendEnabled ? 'green' : 'red'}>
{game.databaseBackendEnabled ? 'Enabled' : 'Disabled'}
</Stack.Item>
</Stack>
{logEnable ? (
{logEnable && !game.databaseBackendEnabled && (
<>
<LabeledList>
<LabeledList.Item label="Amount of rounds to log (1 to 8)">
@@ -109,12 +103,10 @@ export const ExportTab = (props) => {
}
/>
&nbsp;
{logRetainRounds > 3 ? (
{logRetainRounds > 3 && (
<Box inline fontSize="0.9em" color="red">
Warning, might crash!
</Box>
) : (
''
)}
</LabeledList.Item>
<LabeledList.Item label="Hardlimit for the log archive (0 = inf. to 50000)">
@@ -135,7 +127,7 @@ export const ExportTab = (props) => {
}
/>
&nbsp;
{logLimit > 0 ? (
{logLimit > 0 && (
<Box
inline
fontSize="0.9em"
@@ -145,8 +137,6 @@ export const ExportTab = (props) => {
? 'Warning, might crash! Takes priority above round retention.'
: 'Takes priority above round retention.'}
</Box>
) : (
''
)}
</LabeledList.Item>
</LabeledList>
@@ -170,48 +160,92 @@ export const ExportTab = (props) => {
</Collapsible>
</Section>
</>
) : (
''
)}
<LabeledList>
<LabeledList.Item label="Export round start (0 = curr.) / end (0 = dis.)">
<NumberInput
width="5em"
step={1}
stepPixelSize={10}
minValue={0}
maxValue={exportEnd === 0 ? 0 : exportEnd - 1}
value={exportStart}
format={(value) => toFixed(value)}
onDrag={(value) =>
dispatch(
updateSettings({
exportStart: value,
}),
)
}
/>
<NumberInput
width="5em"
step={1}
stepPixelSize={10}
minValue={exportStart === 0 ? 0 : exportStart + 1}
maxValue={storedRounds}
value={exportEnd}
format={(value) => toFixed(value)}
onDrag={(value) =>
dispatch(
updateSettings({
exportEnd: value,
}),
)
}
/>
&nbsp;
<Box inline fontSize="0.9em" color="label">
Stored Rounds:&nbsp;
</Box>
<Box inline>{storedRounds}</Box>
<Stack align="center">
{game.databaseBackendEnabled ? (
<>
<Stack.Item>
<Dropdown
onSelected={(value) =>
dispatch(
updateSettings({
exportStart: value,
}),
)
}
options={game.databaseStoredRounds}
selected={exportStart}
/>
</Stack.Item>
<Stack.Item>
<Dropdown
onSelected={(value) =>
dispatch(
updateSettings({
exportEnd: value,
}),
)
}
options={game.databaseStoredRounds}
selected={exportEnd}
/>
</Stack.Item>
</>
) : (
<>
<Stack.Item>
<NumberInput
width="5em"
step={1}
stepPixelSize={10}
minValue={0}
maxValue={exportEnd === 0 ? 0 : exportEnd - 1}
value={exportStart}
format={(value) => toFixed(value)}
onDrag={(value) =>
dispatch(
updateSettings({
exportStart: value,
}),
)
}
/>
</Stack.Item>
<Stack.Item>
<NumberInput
width="5em"
step={1}
stepPixelSize={10}
minValue={exportStart === 0 ? 0 : exportStart + 1}
maxValue={storedRounds}
value={exportEnd}
format={(value) => toFixed(value)}
onDrag={(value) =>
dispatch(
updateSettings({
exportEnd: value,
}),
)
}
/>
</Stack.Item>
</>
)}
<Stack.Item>
<Box fontSize="0.9em" color="label">
&nbsp;Stored Rounds:&nbsp;
</Box>
</Stack.Item>
<Stack.Item>
<Box>
{game.databaseBackendEnabled
? game.databaseStoredRounds.length - 1
: storedRounds}
</Box>
</Stack.Item>
</Stack>
</LabeledList.Item>
<LabeledList.Item label="Amount of lines to export (0 = inf.)">
<NumberInput
@@ -231,38 +265,34 @@ export const ExportTab = (props) => {
}
/>
</LabeledList.Item>
<LabeledList.Item label="Totally stored messages">
<Box>{totalStoredMessages}</Box>
</LabeledList.Item>
{!game.databaseBackendEnabled && (
<LabeledList.Item label="Totally stored messages">
<Box>{totalStoredMessages}</Box>
</LabeledList.Item>
)}
</LabeledList>
<Divider />
<Button icon="save" onClick={() => dispatch(saveChatToDisk())}>
Save chat log
</Button>
{purgeConfirm > 0 ? (
<Button
{!game.databaseBackendEnabled && (
<Button.Confirm
disabled={purgeButtonText === 'Purged!'}
icon="trash"
color="red"
confirmIcon="trash"
confirmColor="red"
confirmContent="Are you sure?"
onClick={() => {
dispatch(purgeChatMessageArchive());
setPurgeConfirm(2);
}}
>
{purgeConfirm > 1 ? 'Purged!' : 'Are you sure?'}
</Button>
) : (
<Button
icon="trash"
color="red"
onClick={() => {
setPurgeConfirm(1);
setPurgeButtonText('Purged!');
setTimeout(() => {
setPurgeConfirm(0);
}, 5000);
setPurgeButtonText('Purge message archive');
}, 1000);
}}
>
Purge message archive
</Button>
{purgeButtonText}
</Button.Confirm>
)}
</Section>
);

View File

@@ -2,11 +2,13 @@ import { useDispatch, useSelector } from 'tgui/backend';
import { Box, LabeledList, NumberInput, Section } from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { useGame } from '../../game';
import { updateSettings } from '../actions';
import { selectSettings } from '../selectors';
export const MessageLimits = (props) => {
const dispatch = useDispatch();
const game = useGame();
const {
visibleMessageLimit,
persistentMessageLimit,
@@ -35,12 +37,10 @@ export const MessageLimits = (props) => {
}
/>
&nbsp;
{visibleMessageLimit >= 5000 ? (
{visibleMessageLimit >= 5000 && (
<Box inline fontSize="0.9em" color="red">
Impacts performance!
</Box>
) : (
''
)}
</LabeledList.Item>
<LabeledList.Item label="Amount of visually persistent lines 0-10000 (Default: 1000)">
@@ -61,12 +61,10 @@ export const MessageLimits = (props) => {
}
/>
&nbsp;
{persistentMessageLimit >= 2500 ? (
{persistentMessageLimit >= 2500 && (
<Box inline fontSize="0.9em" color="red">
Delays initialization!
</Box>
) : (
''
)}
</LabeledList.Item>
<LabeledList.Item label="Amount of different lines in-between to combine 0-10 (Default: 5)">
@@ -106,33 +104,33 @@ export const MessageLimits = (props) => {
}
/>
</LabeledList.Item>
<LabeledList.Item label="Message store interval 1-10 (Default: 10 Seconds) [Requires restart]">
<NumberInput
width="5em"
step={1}
stepPixelSize={5}
minValue={1}
maxValue={10}
value={saveInterval}
unit="s"
format={(value) => toFixed(value)}
onDrag={(value) =>
dispatch(
updateSettings({
saveInterval: value,
}),
)
}
/>
&nbsp;
{saveInterval <= 3 ? (
<Box inline fontSize="0.9em" color="red">
Warning, experimental! Might crash!
</Box>
) : (
''
)}
</LabeledList.Item>
{!game.databaseBackendEnabled && (
<LabeledList.Item label="Message store interval 1-10 (Default: 10 Seconds) [Requires restart]">
<NumberInput
width="5em"
step={1}
stepPixelSize={5}
minValue={1}
maxValue={10}
value={saveInterval}
unit="s"
format={(value) => toFixed(value)}
onDrag={(value) =>
dispatch(
updateSettings({
saveInterval: value,
}),
)
}
/>
&nbsp;
{saveInterval <= 3 && (
<Box inline fontSize="0.9em" color="red">
Warning, experimental! Might crash!
</Box>
)}
</LabeledList.Item>
)}
</LabeledList>
</Section>
);

View File

@@ -193,7 +193,7 @@ const TextHighlightSetting = (props) => {
)
}
/>
{highlightBlacklist ? (
{!!highlightBlacklist && (
<TextArea
height="3em"
value={blacklistText}
@@ -207,8 +207,6 @@ const TextHighlightSetting = (props) => {
)
}
/>
) : (
''
)}
</Stack.Item>
);

View File

@@ -52,3 +52,5 @@ export const FONTS = [
];
export const MAX_HIGHLIGHT_SETTINGS = 10;
export const blacklisted_tags = ['a', 'iframe', 'link', 'video'];

View File

@@ -7,21 +7,31 @@
import { storage } from 'common/storage';
import { createLogger } from 'tgui/logging';
import { getChatData } from './chat/actions';
import { updateExportData } from './game/actions';
const logger = createLogger('telemetry');
const MAX_CONNECTIONS_STORED = 10;
type Client = { ckey: string; address: string; computer_id: string };
type Client = {
ckey: string;
chatlog_token: string;
address: string;
computer_id: string;
};
type Telemetry = { limits: { connections: number }[]; connections: Client[] };
const connectionsMatch = (a: Client, b: Client) =>
a.ckey === b.ckey &&
a.chatlog_token === b.chatlog_token &&
a.address === b.address &&
a.computer_id === b.computer_id;
export const telemetryMiddleware = (store) => {
let telemetry: Telemetry;
let wasRequestedWithPayload: Telemetry | null;
let firstMutate: boolean = true;
return (next) => (action) => {
const { type, payload } = action;
// Handle telemetry requests
@@ -78,6 +88,18 @@ export const telemetryMiddleware = (store) => {
telemetry.connections.pop();
}
}
if (firstMutate) {
firstMutate = false;
store.dispatch(
getChatData({ ckey: client.ckey, token: client.chatlog_token }),
);
store.dispatch(
updateExportData({
ckey: client.ckey,
token: client.chatlog_token,
}),
);
}
// Save telemetry
if (telemetryMutated) {
logger.debug('saving telemetry to storage', telemetry);

View File

@@ -369,10 +369,10 @@
fileHandle.createWritable().then(function (writeableFileHandle) {
writeableFileHandle.write(blob).then(function () {
writeableFileHandle.close()
}).catch(function () { });
}).catch(function () { });
}).catch(function () { });
} catch (e) { }
}).catch(function (e) { console.error(e) });
}).catch(function (e) { console.error(e) });
}).catch(function (e) { console.error(e) });
} catch (e) { console.error(e) }
}
};

View File

@@ -3725,6 +3725,13 @@ __metadata:
languageName: node
linkType: hard
"@types/wicg-file-system-access@npm:^2023.10.5":
version: 2023.10.5
resolution: "@types/wicg-file-system-access@npm:2023.10.5"
checksum: 10c0/3c6099320a2517ab405bd65712eb19ba2c1f7f0c30f952744102b76809ed9ad5205f881c79ebe6f92ac7af7a13667df8c23dd59b0a5182119c74afa17335bfd3
languageName: node
linkType: hard
"@types/ws@npm:^8.5.13":
version: 8.5.14
resolution: "@types/ws@npm:8.5.14"
@@ -19180,6 +19187,7 @@ __metadata:
"@types/react": "npm:^18.3.12"
"@types/react-dom": "npm:^18.3.1"
"@types/webpack-env": "npm:^1.18.5"
"@types/wicg-file-system-access": "npm:^2023.10.5"
"@typescript-eslint/parser": "npm:^8.13.0"
"@typescript-eslint/utils": "npm:^8.13.0"
css-loader: "npm:^7.1.2"

View File

@@ -0,0 +1,7 @@
# PLEASE REFER TO THE SCHEMA IN THE MAIN REPOSITORY
# TO KNOW WHERE THE TABLES ARE AT (chatlogs / ckeys)
API_HOST=0.0.0.0
API_PORT=50000
DBCONFIG_PATH=/opt/myserver/config/dbconfig.txt

View File

@@ -0,0 +1,12 @@
The libvchatlog.so/vchatlog.dll file will fetch the required database credentials automatically from the
config/dbconfig.txt if the chatlogging database backend was enabled.
For the alwayson-backend you will have to create some kind of service to keep it running. With the supplied
.env.example you are able to set the port of the API. The configured chatlogging API URL (where you also turn it on)
you will have to supply the correct ip-address/domain and port on where this API is reachable.
For this you can set the DBCONFIG_PATH environment variable (or use the .env.example) to the exact path of the config/dbconfig.txt.
Do not forget to rename .env.example to .env of course, if you want to use a file.

Binary file not shown.

Binary file not shown.

BIN
vchatlog.dll Normal file

Binary file not shown.

View File

@@ -170,6 +170,7 @@
#include "code\__defines\typeids.dm"
#include "code\__defines\unit_tests.dm"
#include "code\__defines\update_icons.dm"
#include "code\__defines\vchatlog.dm"
#include "code\__defines\verb_manager.dm"
#include "code\__defines\visualnet.dm"
#include "code\__defines\vore.dm"