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 +61 -0
- package/config.example.json +9 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +82 -0
- package/dist/discordClient.d.ts +4 -0
- package/dist/discordClient.js +63 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +134 -0
- package/dist/logParser.d.ts +8 -0
- package/dist/logParser.js +103 -0
- package/dist/messageFormatter.d.ts +5 -0
- package/dist/messageFormatter.js +36 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.js +1 -0
- package/package.json +43 -0
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
|
package/dist/config.d.ts
ADDED
|
@@ -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,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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|