cubyz-discord-relay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # Cubyz Discord Relay
2
+
3
+ CLI tool that streams Cubyz game server chat events into a Discord channel in near real time.
4
+
5
+ ## Features
6
+ - Watches the Cubyz `latest.log` file without locking it
7
+ - Detects join, leave, death, and chat events
8
+ - Cleans Cubyz markdown-style usernames before relaying
9
+ - Filters events based on configuration
10
+ - Graceful shutdown via `q` + Enter or `Ctrl+C`
11
+
12
+ ## Prerequisites
13
+ - Node.js 18 or newer
14
+ - Discord bot token with permission to post in the target channel
15
+
16
+ ## Installation
17
+ Install via npm:
18
+
19
+ ```bash
20
+ npm install -g cubyz-discord-relay
21
+
22
+ # Start the relay (defaults to ./config.json)
23
+ cubyz-discord-relay
24
+
25
+ # Provide a custom config path
26
+ cubyz-discord-relay /path/to/config.json
27
+ ```
28
+
29
+ You can also run once without a global install via `npx cubyz-discord-relay`.
30
+
31
+ ## Development
32
+
33
+ ### Setup
34
+ ```bash
35
+ npm install
36
+ ```
37
+
38
+ ### Configuration
39
+ 1. Copy `config.example.json` to `config.json`.
40
+ 2. Update the fields:
41
+ - `cubyzLogPath`: absolute path to Cubyz `latest.log`
42
+ - `discord.token`: bot token
43
+ - `discord.channelId`: target channel ID
44
+ - `events`: event types to relay (`join`, `leave`, `death`, `chat`)
45
+ - `updateIntervalMs`: polling interval in milliseconds
46
+
47
+ > First run convenience: if `config.json` is missing, the CLI writes a fresh template in your working directory and exits so you can fill it in before retrying.
48
+
49
+ ### Usage
50
+ ```bash
51
+ npm run dev # Run directly with tsx (recompiles on change)
52
+ npm run build # Compile TypeScript to dist/
53
+ npm start # Run compiled output (after build)
54
+ ```
55
+
56
+ During execution press `q` + Enter to exit gracefully.
57
+
58
+ ## Troubleshooting
59
+ - **Bot not posting**: verify bot token, channel ID, and permissions
60
+ - **No events forwarded**: ensure `events` include the desired types and the log is updating
61
+ - **Missing log file**: the tool waits for `cubyzLogPath` to appear and resumes automatically
@@ -0,0 +1,9 @@
1
+ {
2
+ "cubyzLogPath": "/path/to/cubyz/logs/latest.log",
3
+ "discord": {
4
+ "token": "YOUR_DISCORD_BOT_TOKEN",
5
+ "channelId": "YOUR_CHANNEL_ID"
6
+ },
7
+ "events": ["join", "leave", "death", "chat"],
8
+ "updateIntervalMs": 1000
9
+ }
@@ -0,0 +1,10 @@
1
+ import type { Config, EventType } from "./types.js";
2
+ declare const DEFAULT_EVENTS: EventType[];
3
+ declare const DEFAULT_INTERVAL_MS = 1000;
4
+ export declare class ConfigTemplateCreatedError extends Error {
5
+ readonly configPath: string;
6
+ constructor(configPath: string);
7
+ }
8
+ export declare function validateConfig(config: Config): void;
9
+ export declare function loadConfig(configPath: string): Promise<Config>;
10
+ export { DEFAULT_EVENTS, DEFAULT_INTERVAL_MS };
package/dist/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import { access, copyFile, mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ const DEFAULT_EVENTS = ["join", "leave", "death", "chat"];
6
+ const DEFAULT_INTERVAL_MS = 1000;
7
+ const CONFIG_TEMPLATE_PATH = fileURLToPath(new URL("../config.example.json", import.meta.url));
8
+ const isNotFoundError = (error) => typeof error === "object" &&
9
+ error !== null &&
10
+ "code" in error &&
11
+ error.code === "ENOENT";
12
+ export class ConfigTemplateCreatedError extends Error {
13
+ configPath;
14
+ constructor(configPath) {
15
+ super(`Configuration file not found. A template has been created at ${configPath}. Update it and rerun the cli.`);
16
+ this.name = "ConfigTemplateCreatedError";
17
+ this.configPath = configPath;
18
+ }
19
+ }
20
+ function applyDefaults(partial) {
21
+ const events = Array.isArray(partial.events) && partial.events.length > 0
22
+ ? [...partial.events]
23
+ : DEFAULT_EVENTS;
24
+ return {
25
+ cubyzLogPath: partial.cubyzLogPath ?? "",
26
+ discord: {
27
+ token: partial.discord?.token ?? "",
28
+ channelId: partial.discord?.channelId ?? "",
29
+ },
30
+ events: events,
31
+ updateIntervalMs: typeof partial.updateIntervalMs === "number" &&
32
+ partial.updateIntervalMs > 0
33
+ ? Math.floor(partial.updateIntervalMs)
34
+ : DEFAULT_INTERVAL_MS,
35
+ };
36
+ }
37
+ async function ensureConfigFile(resolvedPath) {
38
+ try {
39
+ await access(resolvedPath);
40
+ }
41
+ catch (error) {
42
+ if (isNotFoundError(error)) {
43
+ await mkdir(path.dirname(resolvedPath), { recursive: true });
44
+ await copyFile(CONFIG_TEMPLATE_PATH, resolvedPath);
45
+ throw new ConfigTemplateCreatedError(resolvedPath);
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+ export function validateConfig(config) {
51
+ if (!config.cubyzLogPath || typeof config.cubyzLogPath !== "string") {
52
+ throw new Error('Configuration error: "cubyzLogPath" must be a non-empty string.');
53
+ }
54
+ if (!config.discord?.token || typeof config.discord.token !== "string") {
55
+ throw new Error('Configuration error: "discord.token" must be provided.');
56
+ }
57
+ if (!config.discord?.channelId ||
58
+ typeof config.discord.channelId !== "string") {
59
+ throw new Error('Configuration error: "discord.channelId" must be provided.');
60
+ }
61
+ if (!Array.isArray(config.events) || config.events.length === 0) {
62
+ throw new Error('Configuration error: "events" must include at least one supported event type.');
63
+ }
64
+ const unknownEvents = config.events.filter((event) => !DEFAULT_EVENTS.includes(event));
65
+ if (unknownEvents.length > 0) {
66
+ throw new Error(`Configuration error: unsupported event types: ${unknownEvents.join(", ")}.`);
67
+ }
68
+ if (typeof config.updateIntervalMs !== "number" ||
69
+ config.updateIntervalMs <= 0) {
70
+ throw new Error('Configuration error: "updateIntervalMs" must be a positive number.');
71
+ }
72
+ }
73
+ export async function loadConfig(configPath) {
74
+ const resolvedPath = path.resolve(process.cwd(), configPath);
75
+ await ensureConfigFile(resolvedPath);
76
+ const raw = await readFile(resolvedPath, "utf8");
77
+ const parsed = JSON.parse(raw);
78
+ const config = applyDefaults(parsed);
79
+ validateConfig(config);
80
+ return config;
81
+ }
82
+ export { DEFAULT_EVENTS, DEFAULT_INTERVAL_MS };
@@ -0,0 +1,4 @@
1
+ import { Client } from "discord.js";
2
+ export declare function initializeDiscordClient(token: string): Promise<Client<boolean>>;
3
+ export declare function sendMessage(channelId: string, message: string): Promise<void>;
4
+ export declare function cleanup(): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { Client, GatewayIntentBits } from "discord.js";
2
+ const channelCache = new Map();
3
+ let clientInstance = null;
4
+ function ensureClient() {
5
+ if (!clientInstance) {
6
+ throw new Error("Discord client has not been initialized.");
7
+ }
8
+ return clientInstance;
9
+ }
10
+ function assertSendable(channel, channelId) {
11
+ if (typeof channel.send !== "function") {
12
+ throw new Error(`Channel ${channelId} cannot send messages.`);
13
+ }
14
+ }
15
+ async function getChannel(channelId) {
16
+ const client = ensureClient();
17
+ const cached = channelCache.get(channelId);
18
+ if (cached) {
19
+ return cached;
20
+ }
21
+ const channel = await client.channels.fetch(channelId);
22
+ if (!channel || !channel.isTextBased()) {
23
+ throw new Error(`Channel ${channelId} is not a text-based channel or could not be fetched.`);
24
+ }
25
+ assertSendable(channel, channelId);
26
+ channelCache.set(channelId, channel);
27
+ return channel;
28
+ }
29
+ export async function initializeDiscordClient(token) {
30
+ if (clientInstance) {
31
+ return clientInstance;
32
+ }
33
+ clientInstance = new Client({
34
+ intents: [GatewayIntentBits.Guilds],
35
+ });
36
+ await clientInstance.login(token);
37
+ return clientInstance;
38
+ }
39
+ export async function sendMessage(channelId, message) {
40
+ const channel = await getChannel(channelId);
41
+ const maxAttempts = 3;
42
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
43
+ try {
44
+ await channel.send(message);
45
+ return;
46
+ }
47
+ catch (error) {
48
+ if (attempt === maxAttempts) {
49
+ throw error;
50
+ }
51
+ const delay = 500 * attempt;
52
+ await new Promise((resolve) => setTimeout(resolve, delay));
53
+ }
54
+ }
55
+ }
56
+ export async function cleanup() {
57
+ if (!clientInstance) {
58
+ return;
59
+ }
60
+ channelCache.clear();
61
+ await clientInstance.destroy();
62
+ clientInstance = null;
63
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import readline from "node:readline";
4
+ import { ConfigTemplateCreatedError, loadConfig } from "./config.js";
5
+ import { cleanup, initializeDiscordClient, sendMessage, } from "./discordClient.js";
6
+ import { initializePosition, parseChatLine, readNewLines, } from "./logParser.js";
7
+ import { formatMessage, shouldRelayEvent } from "./messageFormatter.js";
8
+ const DEFAULT_CONFIG_PATH = "config.json";
9
+ let isRunning = true;
10
+ let isShuttingDown = false;
11
+ let readlineInterface = null;
12
+ const delay = (ms) => new Promise((resolve) => {
13
+ setTimeout(resolve, ms);
14
+ });
15
+ function getConfigPath() {
16
+ const [, , providedPath] = process.argv;
17
+ return providedPath ?? DEFAULT_CONFIG_PATH;
18
+ }
19
+ async function relayMessages(config, chatMessages) {
20
+ for (const chatMessage of chatMessages) {
21
+ if (!shouldRelayEvent(chatMessage.type, config)) {
22
+ continue;
23
+ }
24
+ const payload = formatMessage(chatMessage);
25
+ try {
26
+ await sendMessage(config.discord.channelId, payload);
27
+ }
28
+ catch (error) {
29
+ console.error("Failed to send message to Discord:", error);
30
+ }
31
+ }
32
+ }
33
+ async function pollLoop(config) {
34
+ let lastPosition = await initializePosition(config.cubyzLogPath);
35
+ let warnedMissingFile = false;
36
+ while (isRunning) {
37
+ try {
38
+ const previousPosition = lastPosition;
39
+ const { lines, newPosition, fileMissing } = await readNewLines(config.cubyzLogPath, lastPosition);
40
+ if (fileMissing && !warnedMissingFile) {
41
+ console.warn("Log file not found. Waiting for it to appear...");
42
+ warnedMissingFile = true;
43
+ }
44
+ if (!fileMissing && warnedMissingFile) {
45
+ console.info("Log file detected. Resuming monitoring.");
46
+ warnedMissingFile = false;
47
+ lastPosition = await initializePosition(config.cubyzLogPath);
48
+ await delay(config.updateIntervalMs);
49
+ continue;
50
+ }
51
+ if (!fileMissing && newPosition < previousPosition) {
52
+ console.info("Log file size decreased. Assuming rotation and continuing from new end.");
53
+ }
54
+ lastPosition = newPosition;
55
+ if (lines.length > 0) {
56
+ const messages = [];
57
+ for (const line of lines) {
58
+ const chatMessage = parseChatLine(line);
59
+ if (chatMessage) {
60
+ messages.push(chatMessage);
61
+ }
62
+ }
63
+ if (messages.length > 0) {
64
+ await relayMessages(config, messages);
65
+ }
66
+ }
67
+ }
68
+ catch (error) {
69
+ console.error("Error while processing log file:", error);
70
+ }
71
+ await delay(config.updateIntervalMs);
72
+ }
73
+ }
74
+ async function shutdown() {
75
+ if (isShuttingDown) {
76
+ return;
77
+ }
78
+ isShuttingDown = true;
79
+ isRunning = false;
80
+ if (readlineInterface) {
81
+ readlineInterface.close();
82
+ readlineInterface = null;
83
+ }
84
+ try {
85
+ await cleanup();
86
+ }
87
+ catch (error) {
88
+ console.error("Error during cleanup:", error);
89
+ }
90
+ }
91
+ function setupQuitHandler() {
92
+ readlineInterface = readline.createInterface({
93
+ input: process.stdin,
94
+ output: process.stdout,
95
+ terminal: false,
96
+ });
97
+ readlineInterface.on("line", (input) => {
98
+ if (input.trim().toLowerCase() === "q") {
99
+ void shutdown();
100
+ }
101
+ });
102
+ process.on("SIGINT", () => {
103
+ console.log("\nReceived SIGINT. Shutting down...");
104
+ void shutdown();
105
+ });
106
+ console.log("Press q + Enter to quit.");
107
+ }
108
+ async function main() {
109
+ try {
110
+ const configPath = getConfigPath();
111
+ const config = await loadConfig(configPath);
112
+ console.log("Connecting to Discord...");
113
+ await initializeDiscordClient(config.discord.token);
114
+ console.log("Connected to Discord.");
115
+ setupQuitHandler();
116
+ console.log(`Monitoring log file: ${config.cubyzLogPath}`);
117
+ await pollLoop(config);
118
+ }
119
+ catch (error) {
120
+ if (error instanceof ConfigTemplateCreatedError) {
121
+ console.warn(error.message);
122
+ console.warn("Update the generated config file and run the command again.");
123
+ process.exitCode = 1;
124
+ }
125
+ else {
126
+ console.error("Fatal error:", error);
127
+ process.exitCode = 1;
128
+ }
129
+ }
130
+ finally {
131
+ await shutdown();
132
+ }
133
+ }
134
+ void main();
@@ -0,0 +1,8 @@
1
+ import type { ChatMessage } from "./types.js";
2
+ export declare function initializePosition(filePath: string): Promise<number>;
3
+ export declare function readNewLines(filePath: string, fromPosition: number): Promise<{
4
+ lines: string[];
5
+ newPosition: number;
6
+ fileMissing: boolean;
7
+ }>;
8
+ export declare function parseChatLine(rawLine: string): ChatMessage | null;
@@ -0,0 +1,103 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { open, stat } from "node:fs/promises";
3
+ import { cleanUsername } from "./messageFormatter.js";
4
+ const CHAT_PATTERN = /\[info\]:\s*(?:User \[info\]:\s*)?Chat:\s*(.+)/i;
5
+ const isNotFoundError = (error) => typeof error === "object" &&
6
+ error !== null &&
7
+ "code" in error &&
8
+ error.code === "ENOENT";
9
+ export async function initializePosition(filePath) {
10
+ try {
11
+ const fileStats = await stat(filePath);
12
+ return fileStats.size;
13
+ }
14
+ catch (error) {
15
+ if (isNotFoundError(error)) {
16
+ return 0;
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+ export async function readNewLines(filePath, fromPosition) {
22
+ try {
23
+ const fileHandle = await open(filePath, "r");
24
+ const fileStats = await fileHandle.stat();
25
+ if (fileStats.size < fromPosition) {
26
+ await fileHandle.close();
27
+ return { lines: [], newPosition: fileStats.size, fileMissing: false };
28
+ }
29
+ if (fileStats.size === fromPosition) {
30
+ await fileHandle.close();
31
+ return { lines: [], newPosition: fromPosition, fileMissing: false };
32
+ }
33
+ const bytesToRead = fileStats.size - fromPosition;
34
+ const buffer = Buffer.alloc(bytesToRead);
35
+ await fileHandle.read(buffer, 0, bytesToRead, fromPosition);
36
+ await fileHandle.close();
37
+ const content = buffer.toString("utf8");
38
+ const normalized = content.replace(/\r\n/g, "\n");
39
+ const lines = normalized
40
+ .split("\n")
41
+ .filter((line) => line.length > 0);
42
+ return { lines, newPosition: fileStats.size, fileMissing: false };
43
+ }
44
+ catch (error) {
45
+ if (isNotFoundError(error)) {
46
+ return { lines: [], newPosition: 0, fileMissing: true };
47
+ }
48
+ throw error;
49
+ }
50
+ }
51
+ export function parseChatLine(rawLine) {
52
+ const match = CHAT_PATTERN.exec(rawLine);
53
+ if (!match) {
54
+ return null;
55
+ }
56
+ const payload = match[1].trim();
57
+ const timestamp = new Date();
58
+ const chatMatch = /^\[(.+?)\]\s*(.*)$/.exec(payload);
59
+ if (chatMatch) {
60
+ const rawUsername = chatMatch[1].trim();
61
+ const message = chatMatch[2].trim();
62
+ return {
63
+ type: "chat",
64
+ rawUsername,
65
+ username: cleanUsername(rawUsername),
66
+ message,
67
+ timestamp,
68
+ };
69
+ }
70
+ const joinMatch = /^(.+?) joined$/.exec(payload);
71
+ if (joinMatch) {
72
+ const rawUsername = joinMatch[1].trim();
73
+ return {
74
+ type: "join",
75
+ rawUsername,
76
+ username: cleanUsername(rawUsername),
77
+ timestamp,
78
+ };
79
+ }
80
+ const leaveMatch = /^(.+?) left$/.exec(payload);
81
+ if (leaveMatch) {
82
+ const rawUsername = leaveMatch[1].trim();
83
+ return {
84
+ type: "leave",
85
+ rawUsername,
86
+ username: cleanUsername(rawUsername),
87
+ timestamp,
88
+ };
89
+ }
90
+ const deathMatch = /^(.+?) died(.*)$/.exec(payload);
91
+ if (deathMatch) {
92
+ const rawUsername = deathMatch[1].trim();
93
+ const message = `died${deathMatch[2]}`.trim();
94
+ return {
95
+ type: "death",
96
+ rawUsername,
97
+ username: cleanUsername(rawUsername),
98
+ message,
99
+ timestamp,
100
+ };
101
+ }
102
+ return null;
103
+ }
@@ -0,0 +1,5 @@
1
+ import type { ChatMessage, Config, EventType } from "./types.js";
2
+ export declare function isFormattedUsername(username: string): boolean;
3
+ export declare function cleanUsername(raw: string): string;
4
+ export declare function formatMessage(chatMessage: ChatMessage): string;
5
+ export declare function shouldRelayEvent(eventType: EventType, config: Config): boolean;
@@ -0,0 +1,36 @@
1
+ const FORMATTED_USERNAME_PATTERN = /(?:\*+|_+|~+)(?:#[0-9A-Fa-f]{6}.)+(?:\*+|_+|~+)(?:ยง#[0-9A-Fa-f]{6})?/;
2
+ export function isFormattedUsername(username) {
3
+ return FORMATTED_USERNAME_PATTERN.test(username);
4
+ }
5
+ export function cleanUsername(raw) {
6
+ const withoutSuffix = raw.replace(/ยง#[0-9A-Fa-f]{6}$/g, "");
7
+ const colorCharMatches = [
8
+ ...withoutSuffix.matchAll(/#[0-9A-Fa-f]{6}([A-Za-z0-9_])/g),
9
+ ];
10
+ if (colorCharMatches.length > 0) {
11
+ return colorCharMatches.map((match) => match[1]).join("");
12
+ }
13
+ const stripped = withoutSuffix
14
+ .replace(/[*~_[\]]/g, "")
15
+ .replace(/#[0-9A-Fa-f]{6}/g, "")
16
+ .trim();
17
+ return stripped;
18
+ }
19
+ export function formatMessage(chatMessage) {
20
+ const username = chatMessage.username || cleanUsername(chatMessage.rawUsername);
21
+ switch (chatMessage.type) {
22
+ case "join":
23
+ return `๐Ÿ‘‹ ${username} joined the game`;
24
+ case "leave":
25
+ return `๐Ÿ‘‹ ${username} left the game`;
26
+ case "death":
27
+ return `๐Ÿ’€ ${username} ${chatMessage.message ?? "died"}`;
28
+ case "chat":
29
+ return `${username}: ${chatMessage.message ?? ""}`;
30
+ default:
31
+ return `${username}: ${chatMessage.message ?? ""}`;
32
+ }
33
+ }
34
+ export function shouldRelayEvent(eventType, config) {
35
+ return config.events.includes(eventType);
36
+ }
@@ -0,0 +1,17 @@
1
+ export type EventType = "join" | "leave" | "death" | "chat";
2
+ export interface Config {
3
+ cubyzLogPath: string;
4
+ discord: {
5
+ token: string;
6
+ channelId: string;
7
+ };
8
+ events: EventType[];
9
+ updateIntervalMs: number;
10
+ }
11
+ export interface ChatMessage {
12
+ type: EventType;
13
+ username: string;
14
+ rawUsername: string;
15
+ message?: string;
16
+ timestamp: Date;
17
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "cubyz-discord-relay",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool that relays Cubyz server chat events to Discord in real time.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "cubyz-discord-relay": "dist/index.js"
10
+ },
11
+ "scripts": {
12
+ "check": "biome check .",
13
+ "check:write": "biome check --write .",
14
+ "build": "tsc",
15
+ "start": "node dist/index.js",
16
+ "dev": "tsx src/index.ts",
17
+ "watch": "tsc --watch",
18
+ "test": "tsx test/messageFormatter.test.ts",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "cubyz",
23
+ "discord",
24
+ "relay",
25
+ "cli"
26
+ ],
27
+ "author": "",
28
+ "license": "MIT",
29
+ "files": [
30
+ "dist",
31
+ "config.example.json",
32
+ "README.md"
33
+ ],
34
+ "dependencies": {
35
+ "discord.js": "^14.14.1"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "2.2.6",
39
+ "@types/node": "^20.10.0",
40
+ "tsx": "^4.7.0",
41
+ "typescript": "^5.3.0"
42
+ }
43
+ }