@pilaf/backends 1.0.2 → 1.2.1
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 +1170 -10
- package/lib/collectors/DockerLogCollector.js +334 -0
- package/lib/collectors/index.js +9 -0
- package/lib/core/CommandRouter.js +154 -0
- package/lib/core/CorrelationStrategy.js +172 -0
- package/lib/core/LogCollector.js +194 -0
- package/lib/core/LogParser.js +125 -0
- package/lib/core/index.js +26 -0
- package/lib/errors/index.js +363 -0
- package/lib/helpers/EventObserver.js +267 -0
- package/lib/helpers/QueryHelper.js +279 -0
- package/lib/helpers/index.js +13 -0
- package/lib/index.js +72 -1
- package/lib/mineflayer-backend.js +266 -1
- package/lib/monitoring/CircularBuffer.js +202 -0
- package/lib/monitoring/LogMonitor.js +303 -0
- package/lib/monitoring/correlations/TagCorrelationStrategy.js +214 -0
- package/lib/monitoring/correlations/UsernameCorrelationStrategy.js +233 -0
- package/lib/monitoring/correlations/index.js +13 -0
- package/lib/monitoring/index.js +16 -0
- package/lib/parsers/MinecraftLogParser.js +512 -0
- package/lib/parsers/PatternRegistry.js +366 -0
- package/lib/parsers/fixtures/minecraft-logs.js +188 -0
- package/lib/parsers/index.js +13 -0
- package/lib/rcon-backend.js +42 -26
- package/package.json +3 -2
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DockerLogCollector - Collect logs from Docker containers
|
|
3
|
+
*
|
|
4
|
+
* Streams log output from Docker containers using the Dockerode API.
|
|
5
|
+
* Handles reconnection with exponential backoff and supports both
|
|
6
|
+
* following and historical log retrieval.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities (MECE):
|
|
9
|
+
* - ONLY: Connect to Docker daemon and stream container logs
|
|
10
|
+
* - NOT: Parse log content (LogParser's responsibility)
|
|
11
|
+
* - NOT: Handle container lifecycle (user's responsibility)
|
|
12
|
+
*
|
|
13
|
+
* @class
|
|
14
|
+
* @extends LogCollector
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* const collector = new DockerLogCollector();
|
|
18
|
+
* await collector.connect({
|
|
19
|
+
* containerName: 'minecraft-server',
|
|
20
|
+
* follow: true,
|
|
21
|
+
* tail: 100
|
|
22
|
+
* });
|
|
23
|
+
* collector.on('data', (line) => console.log(line));
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { LogCollector } = require('../core/LogCollector.js');
|
|
27
|
+
const { DockerConnectionError } = require('../errors/index.js');
|
|
28
|
+
|
|
29
|
+
class DockerLogCollector extends LogCollector {
|
|
30
|
+
/**
|
|
31
|
+
* Create a DockerLogCollector
|
|
32
|
+
* @param {Object} options - Collector options
|
|
33
|
+
* @param {Object} [options.dockerodeOptions] - Options to pass to Dockerode
|
|
34
|
+
* @param {string} [options.dockerodeOptions.socketPath] - Docker socket path (default: /var/run/docker.sock)
|
|
35
|
+
* @param {number} [options.reconnectDelay=1000] - Initial reconnection delay in ms
|
|
36
|
+
* @param {number} [options.maxReconnectDelay=30000] - Maximum reconnection delay in ms
|
|
37
|
+
* @param {number} [options.reconnectAttempts=5] - Maximum reconnection attempts
|
|
38
|
+
*/
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
super();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Dockerode options
|
|
44
|
+
* @private
|
|
45
|
+
* @type {Object}
|
|
46
|
+
*/
|
|
47
|
+
this._dockerodeOptions = options?.dockerodeOptions || {};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reconnection configuration
|
|
51
|
+
* @private
|
|
52
|
+
* @type {Object}
|
|
53
|
+
*/
|
|
54
|
+
this._reconnectConfig = {
|
|
55
|
+
delay: options?.reconnectDelay || 1000,
|
|
56
|
+
maxDelay: options?.maxReconnectDelay || 30000,
|
|
57
|
+
attempts: options?.reconnectAttempts || 5
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Dockerode instance (lazy loaded)
|
|
62
|
+
* @private
|
|
63
|
+
* @type {Object|null}
|
|
64
|
+
*/
|
|
65
|
+
this._docker = null;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Docker container instance
|
|
69
|
+
* @private
|
|
70
|
+
* @type {Object|null}
|
|
71
|
+
*/
|
|
72
|
+
this._container = null;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Log stream instance
|
|
76
|
+
* @private
|
|
77
|
+
* @type {Object|null}
|
|
78
|
+
*/
|
|
79
|
+
this._logStream = null;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reconnection timeout ID
|
|
83
|
+
* @private
|
|
84
|
+
* @type {NodeJS.Timeout|null}
|
|
85
|
+
*/
|
|
86
|
+
this._reconnectTimeout = null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Current reconnection attempt count
|
|
90
|
+
* @private
|
|
91
|
+
* @type {number}
|
|
92
|
+
*/
|
|
93
|
+
this._reconnectCount = 0;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stream options for Docker logs
|
|
97
|
+
* @private
|
|
98
|
+
* @type {Object}
|
|
99
|
+
*/
|
|
100
|
+
this._streamOptions = {
|
|
101
|
+
follow: true,
|
|
102
|
+
stdout: true,
|
|
103
|
+
stderr: true,
|
|
104
|
+
tail: 0
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Connect to Docker container and stream logs
|
|
110
|
+
*
|
|
111
|
+
* @param {Object} config - Connection configuration
|
|
112
|
+
* @param {string} config.containerName - Name or ID of the container
|
|
113
|
+
* @param {boolean} [config.follow=true] - Follow log stream
|
|
114
|
+
* @param {number} [config.tail=0] - Number of lines from history (0 = all)
|
|
115
|
+
* @param {boolean} [config.stdout=true] - Include stdout
|
|
116
|
+
* @param {boolean} [config.stderr=true] - Include stderr
|
|
117
|
+
* @param {boolean} [config.disableAutoReconnect=false] - Disable automatic reconnection
|
|
118
|
+
* @returns {Promise<void>}
|
|
119
|
+
* @throws {DockerConnectionError} If Docker connection fails
|
|
120
|
+
*/
|
|
121
|
+
async connect(config) {
|
|
122
|
+
if (this._connected) {
|
|
123
|
+
await this.disconnect();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this._config = config;
|
|
127
|
+
this._streamOptions = {
|
|
128
|
+
follow: config?.follow !== false,
|
|
129
|
+
stdout: config?.stdout !== false,
|
|
130
|
+
stderr: config?.stderr !== false,
|
|
131
|
+
tail: config?.tail || 0
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// Lazy load Dockerode
|
|
136
|
+
if (!this._docker) {
|
|
137
|
+
const Docker = require('dockerode');
|
|
138
|
+
this._docker = new Docker(this._dockerodeOptions);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Get container
|
|
142
|
+
const containerName = config?.containerName;
|
|
143
|
+
if (!containerName) {
|
|
144
|
+
throw new DockerConnectionError(
|
|
145
|
+
'Container name is required',
|
|
146
|
+
{ config: this._streamOptions }
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this._container = this._docker.getContainer(containerName);
|
|
151
|
+
|
|
152
|
+
// Verify container exists
|
|
153
|
+
try {
|
|
154
|
+
await this._container.inspect();
|
|
155
|
+
} catch (inspectError) {
|
|
156
|
+
throw new DockerConnectionError(
|
|
157
|
+
`Container not found: ${containerName}`,
|
|
158
|
+
{ containerName },
|
|
159
|
+
inspectError
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Create log stream
|
|
164
|
+
this._logStream = await this._container.logs(this._streamOptions);
|
|
165
|
+
|
|
166
|
+
// Set up stream handlers
|
|
167
|
+
this._setupStreamHandlers(this._logStream);
|
|
168
|
+
|
|
169
|
+
this._connected = true;
|
|
170
|
+
// Only reset reconnect count on initial connection (not on reconnection)
|
|
171
|
+
if (!this._reconnectCount) {
|
|
172
|
+
this._reconnectCount = 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.emit('connected');
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// Re-throw to let caller handle the error
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Set up event handlers for the log stream
|
|
184
|
+
*
|
|
185
|
+
* @private
|
|
186
|
+
* @param {Object} stream - Docker log stream
|
|
187
|
+
* @returns {void}
|
|
188
|
+
*/
|
|
189
|
+
_setupStreamHandlers(stream) {
|
|
190
|
+
// Docker logs are Buffers, need to decode and strip ANSI codes
|
|
191
|
+
stream.on('data', (chunk) => {
|
|
192
|
+
if (!this._connected) return;
|
|
193
|
+
|
|
194
|
+
// Decode buffer to string
|
|
195
|
+
const line = chunk.toString('utf8').trim();
|
|
196
|
+
|
|
197
|
+
// Skip empty lines
|
|
198
|
+
if (!line) return;
|
|
199
|
+
|
|
200
|
+
// Strip ANSI color codes if present
|
|
201
|
+
const cleanLine = this._stripAnsiCodes(line);
|
|
202
|
+
|
|
203
|
+
this._emitData(cleanLine);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
stream.on('error', (error) => {
|
|
207
|
+
this._emitError(new DockerConnectionError(
|
|
208
|
+
'Log stream error',
|
|
209
|
+
{ containerName: this._config?.containerName },
|
|
210
|
+
error
|
|
211
|
+
));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
stream.on('end', () => {
|
|
215
|
+
this._handleStreamEnd();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
stream.on('close', () => {
|
|
219
|
+
this._handleStreamEnd();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle log stream termination
|
|
225
|
+
*
|
|
226
|
+
* @private
|
|
227
|
+
* @returns {void}
|
|
228
|
+
*/
|
|
229
|
+
_handleStreamEnd() {
|
|
230
|
+
const shouldReconnect = this._config?.disableAutoReconnect !== true &&
|
|
231
|
+
this._reconnectCount < this._reconnectConfig.attempts;
|
|
232
|
+
|
|
233
|
+
if (shouldReconnect && this._connected) {
|
|
234
|
+
this._scheduleReconnect();
|
|
235
|
+
} else {
|
|
236
|
+
this._emitEnd();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Schedule reconnection with exponential backoff
|
|
242
|
+
*
|
|
243
|
+
* @private
|
|
244
|
+
* @returns {void}
|
|
245
|
+
*/
|
|
246
|
+
_scheduleReconnect() {
|
|
247
|
+
if (this._reconnectTimeout) {
|
|
248
|
+
clearTimeout(this._reconnectTimeout);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Exponential backoff: delay * 2^attempt
|
|
252
|
+
const delay = Math.min(
|
|
253
|
+
this._reconnectConfig.delay * Math.pow(2, this._reconnectCount),
|
|
254
|
+
this._reconnectConfig.maxDelay
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
this._reconnectCount++;
|
|
258
|
+
|
|
259
|
+
this.emit('reconnecting', {
|
|
260
|
+
attempt: this._reconnectCount,
|
|
261
|
+
maxAttempts: this._reconnectConfig.attempts,
|
|
262
|
+
delay
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
this._reconnectTimeout = setTimeout(async () => {
|
|
266
|
+
try {
|
|
267
|
+
await this.connect(this._config);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
this._emitError(error);
|
|
270
|
+
}
|
|
271
|
+
}, delay);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Strip ANSI escape codes from log line
|
|
276
|
+
*
|
|
277
|
+
* @private
|
|
278
|
+
* @param {string} line - Line that may contain ANSI codes
|
|
279
|
+
* @returns {string} - Clean line without ANSI codes
|
|
280
|
+
*/
|
|
281
|
+
_stripAnsiCodes(line) {
|
|
282
|
+
// Remove ANSI escape sequences
|
|
283
|
+
// Format: ESC[...m where ESC is \x1b or \u001b
|
|
284
|
+
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
285
|
+
return line.replace(ansiRegex, '');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Disconnect from Docker container
|
|
290
|
+
*
|
|
291
|
+
* @returns {Promise<void>}
|
|
292
|
+
*/
|
|
293
|
+
async disconnect() {
|
|
294
|
+
// Cancel pending reconnection
|
|
295
|
+
if (this._reconnectTimeout) {
|
|
296
|
+
clearTimeout(this._reconnectTimeout);
|
|
297
|
+
this._reconnectTimeout = null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Destroy log stream
|
|
301
|
+
if (this._logStream) {
|
|
302
|
+
this._logStream.removeAllListeners();
|
|
303
|
+
if (typeof this._logStream.destroy === 'function') {
|
|
304
|
+
this._logStream.destroy();
|
|
305
|
+
}
|
|
306
|
+
this._logStream = null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Clear references
|
|
310
|
+
this._container = null;
|
|
311
|
+
this._docker = null;
|
|
312
|
+
this._connected = false;
|
|
313
|
+
|
|
314
|
+
this.emit('disconnected');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get current reconnection status
|
|
319
|
+
*
|
|
320
|
+
* @returns {Object} - Reconnection status
|
|
321
|
+
* @property {number} attempt - Current attempt number
|
|
322
|
+
* @property {number} maxAttempts - Maximum attempts
|
|
323
|
+
* @property {boolean} reconnecting - Whether reconnection is pending
|
|
324
|
+
*/
|
|
325
|
+
getReconnectStatus() {
|
|
326
|
+
return {
|
|
327
|
+
attempt: this._reconnectCount,
|
|
328
|
+
maxAttempts: this._reconnectConfig.attempts,
|
|
329
|
+
reconnecting: this._reconnectTimeout !== null
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = { DockerLogCollector };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandRouter - Abstract base class for command routing
|
|
3
|
+
*
|
|
4
|
+
* Defines the interface for routing commands to the appropriate channel
|
|
5
|
+
* (bot chat, RCON, or log monitoring). Concrete implementations must
|
|
6
|
+
* extend this class and implement the abstract route() method.
|
|
7
|
+
*
|
|
8
|
+
* @abstract
|
|
9
|
+
* @class
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities (MECE):
|
|
12
|
+
* - ONLY: Decide which channel should handle a command
|
|
13
|
+
* - NOT: Execute commands (backend's responsibility)
|
|
14
|
+
* - NOT: Parse responses (parser's responsibility)
|
|
15
|
+
*
|
|
16
|
+
* Routing Logic:
|
|
17
|
+
* - /data get commands → RCON (structured NBT responses)
|
|
18
|
+
* - /execute with 'run data' → RCON (structured queries)
|
|
19
|
+
* - useRcon option → RCON (forced routing)
|
|
20
|
+
* - expectLogResponse option → Log monitoring (event correlation)
|
|
21
|
+
* - Default → Bot chat (player commands)
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* class SmartCommandRouter extends CommandRouter {
|
|
25
|
+
* route(command, context) {
|
|
26
|
+
* // Analyze command and return { channel, options }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
class CommandRouter {
|
|
32
|
+
/**
|
|
33
|
+
* Routing channels
|
|
34
|
+
* @static
|
|
35
|
+
* @enum {string}
|
|
36
|
+
*/
|
|
37
|
+
static get CHANNELS() {
|
|
38
|
+
return {
|
|
39
|
+
BOT: 'bot', // Send via bot.chat()
|
|
40
|
+
RCON: 'rcon', // Send via RCON
|
|
41
|
+
LOG: 'log' // Send via bot and wait for log response
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a CommandRouter
|
|
47
|
+
* @param {Object} options - Router options
|
|
48
|
+
* @param {Object} [options.rules] - Custom routing rules
|
|
49
|
+
* @throws {Error} Direct instantiation of abstract class
|
|
50
|
+
*/
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
// Prevent direct instantiation of abstract class
|
|
53
|
+
if (this.constructor === CommandRouter) {
|
|
54
|
+
throw new Error('CommandRouter is abstract and cannot be instantiated directly');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Custom routing rules
|
|
59
|
+
* @protected
|
|
60
|
+
* @type {Array<{pattern: string|RegExp, channel: string}>}
|
|
61
|
+
*/
|
|
62
|
+
this._customRules = options?.rules ? Object.entries(options.rules).map(([pattern, channel]) => ({ pattern, channel })) : [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Route a command to the appropriate channel
|
|
67
|
+
*
|
|
68
|
+
* Abstract method that must be implemented by concrete classes.
|
|
69
|
+
* Should analyze the command and context to determine the best channel.
|
|
70
|
+
*
|
|
71
|
+
* @abstract
|
|
72
|
+
* @param {string} command - The command to route
|
|
73
|
+
* @param {Object} context - Routing context
|
|
74
|
+
* @param {Object} context.options - Command options (useRcon, expectLogResponse, etc.)
|
|
75
|
+
* @param {string} [context.username] - Bot username (for correlation)
|
|
76
|
+
* @param {Object} [context.backend] - Backend instance (for advanced routing)
|
|
77
|
+
* @returns {Object} Routing decision
|
|
78
|
+
* @property {string} channel - One of: 'bot', 'rcon', 'log'
|
|
79
|
+
* @property {Object} options - Options to pass to the channel
|
|
80
|
+
* @example
|
|
81
|
+
* // Returns:
|
|
82
|
+
* // { channel: 'rcon', options: { timeout: 10000 } }
|
|
83
|
+
*/
|
|
84
|
+
route(command, context) {
|
|
85
|
+
throw new Error('Method "route()" must be implemented by subclass');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Add a custom routing rule
|
|
90
|
+
*
|
|
91
|
+
* @param {string|RegExp} pattern - Command pattern to match
|
|
92
|
+
* @param {string} channel - Channel to route to ('bot', 'rcon', 'log')
|
|
93
|
+
* @returns {void}
|
|
94
|
+
* @throws {Error} If channel is invalid
|
|
95
|
+
*/
|
|
96
|
+
addRule(pattern, channel) {
|
|
97
|
+
const validChannels = Object.values(CommandRouter.CHANNELS);
|
|
98
|
+
if (!validChannels.includes(channel)) {
|
|
99
|
+
throw new Error(`Invalid channel: ${channel}. Must be one of: ${validChannels.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Remove existing rule with same pattern (if any)
|
|
103
|
+
this.removeRule(pattern);
|
|
104
|
+
|
|
105
|
+
// Add new rule
|
|
106
|
+
this._customRules.push({ pattern, channel });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Remove a custom routing rule
|
|
111
|
+
*
|
|
112
|
+
* @param {string|RegExp} pattern - Pattern to remove
|
|
113
|
+
* @returns {boolean} - True if rule was removed, false if not found
|
|
114
|
+
*/
|
|
115
|
+
removeRule(pattern) {
|
|
116
|
+
const initialLength = this._customRules.length;
|
|
117
|
+
this._customRules = this._customRules.filter(rule => {
|
|
118
|
+
// For RegExp, we need to compare by stringification
|
|
119
|
+
if (pattern instanceof RegExp && rule.pattern instanceof RegExp) {
|
|
120
|
+
return rule.pattern.toString() !== pattern.toString();
|
|
121
|
+
}
|
|
122
|
+
return rule.pattern !== pattern;
|
|
123
|
+
});
|
|
124
|
+
return this._customRules.length < initialLength;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if a command matches a pattern
|
|
129
|
+
*
|
|
130
|
+
* Protected helper method for concrete implementations.
|
|
131
|
+
*
|
|
132
|
+
* @protected
|
|
133
|
+
* @param {string} command - Command to check
|
|
134
|
+
* @param {string|RegExp} pattern - Pattern to match against
|
|
135
|
+
* @returns {boolean} - True if command matches pattern
|
|
136
|
+
*/
|
|
137
|
+
_matchesPattern(command, pattern) {
|
|
138
|
+
if (pattern instanceof RegExp) {
|
|
139
|
+
return pattern.test(command);
|
|
140
|
+
}
|
|
141
|
+
return command.startsWith(pattern);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get custom rules
|
|
146
|
+
*
|
|
147
|
+
* @returns {Array<{pattern: string|RegExp, channel: string}>} - Copy of custom rules array
|
|
148
|
+
*/
|
|
149
|
+
getRules() {
|
|
150
|
+
return [...this._customRules];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { CommandRouter };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CorrelationStrategy - Abstract base class for command response correlation
|
|
3
|
+
*
|
|
4
|
+
* Defines the interface for matching command responses in log streams.
|
|
5
|
+
* Concrete implementations must extend this class and implement the
|
|
6
|
+
* abstract correlate() method.
|
|
7
|
+
*
|
|
8
|
+
* @abstract
|
|
9
|
+
* @class
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities (MECE):
|
|
12
|
+
* - ONLY: Match log events to commands
|
|
13
|
+
* - NOT: Parse logs (LogParser's responsibility)
|
|
14
|
+
* - NOT: Execute commands (backend's responsibility)
|
|
15
|
+
* - NOT: Store events (LogMonitor's responsibility)
|
|
16
|
+
*
|
|
17
|
+
* Strategy Hierarchy (in order of reliability):
|
|
18
|
+
* 1. Tag-based - Most reliable (uses entity tags)
|
|
19
|
+
* 2. Username-based - Moderately reliable (uses username + timestamp)
|
|
20
|
+
* 3. Sequential - Fragile (assumes order preservation)
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* class TagCorrelationStrategy extends CorrelationStrategy {
|
|
24
|
+
* async correlate(command, eventStream, timeout) {
|
|
25
|
+
* // Inject tag and wait for matching response
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { ResponseTimeoutError } = require('../errors/index.js');
|
|
31
|
+
|
|
32
|
+
class CorrelationStrategy {
|
|
33
|
+
/**
|
|
34
|
+
* Create a CorrelationStrategy
|
|
35
|
+
* @param {Object} options - Strategy options
|
|
36
|
+
* @param {number} [options.timeout=5000] - Default timeout in milliseconds
|
|
37
|
+
* @param {number} [options.window=2000] - Time window for correlation (ms)
|
|
38
|
+
* @throws {Error} Direct instantiation of abstract class
|
|
39
|
+
*/
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
// Prevent direct instantiation of abstract class
|
|
42
|
+
if (this.constructor === CorrelationStrategy) {
|
|
43
|
+
throw new Error('CorrelationStrategy is abstract and cannot be instantiated directly');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default timeout for correlation
|
|
48
|
+
* @protected
|
|
49
|
+
* @type {number}
|
|
50
|
+
*/
|
|
51
|
+
this._defaultTimeout = options?.timeout || 5000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Time window for correlation (ms)
|
|
55
|
+
* Used by time-based strategies
|
|
56
|
+
* @protected
|
|
57
|
+
* @type {number}
|
|
58
|
+
*/
|
|
59
|
+
this._correlationWindow = options?.window || 2000;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Correlate a command with its response from the event stream
|
|
64
|
+
*
|
|
65
|
+
* Abstract method that must be implemented by concrete classes.
|
|
66
|
+
* Should wait for matching event and return the response, or throw
|
|
67
|
+
* CorrelationError if timeout occurs.
|
|
68
|
+
*
|
|
69
|
+
* @abstract
|
|
70
|
+
* @param {string} command - The command that was sent
|
|
71
|
+
* @param {AsyncIterable<Object>} eventStream - Stream of parsed events
|
|
72
|
+
* @param {number} [timeout] - Timeout in milliseconds (uses default if not provided)
|
|
73
|
+
* @returns {Promise<Object>} - The correlated response event
|
|
74
|
+
* @throws {ResponseTimeoutError} - If correlation fails or times out
|
|
75
|
+
* @example
|
|
76
|
+
* // Returns:
|
|
77
|
+
* // {
|
|
78
|
+
* // type: 'teleport',
|
|
79
|
+
* // data: { player: 'TestPlayer', position: { x: 100, y: 64, z: 100 } },
|
|
80
|
+
* // raw: '[12:34:56] Teleported TestPlayer to 100.0, 64.0, 100.0'
|
|
81
|
+
* // }
|
|
82
|
+
*/
|
|
83
|
+
async correlate(command, eventStream, timeout) {
|
|
84
|
+
throw new Error('Method "correlate()" must be implemented by subclass');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Generate a unique command ID for correlation
|
|
89
|
+
*
|
|
90
|
+
* Protected helper for concrete implementations.
|
|
91
|
+
* Generates a UUID-like string for tagging commands.
|
|
92
|
+
*
|
|
93
|
+
* @protected
|
|
94
|
+
* @returns {string} - Unique command ID
|
|
95
|
+
*/
|
|
96
|
+
_generateCommandId() {
|
|
97
|
+
return `pilaf-cmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Inject correlation marker into a command
|
|
102
|
+
*
|
|
103
|
+
* Protected helper for tag-based strategies.
|
|
104
|
+
* Modifies the command to include a tag or marker.
|
|
105
|
+
*
|
|
106
|
+
* @protected
|
|
107
|
+
* @param {string} command - Original command
|
|
108
|
+
* @param {string} marker - Correlation marker
|
|
109
|
+
* @returns {string} - Modified command with marker
|
|
110
|
+
* @example
|
|
111
|
+
* _injectMarker('/tp @s ~ ~ ~', 'pilaf-cmd-123')
|
|
112
|
+
* // Returns: '/tp @s[tag=pilaf-cmd-123] ~ ~ ~'
|
|
113
|
+
*/
|
|
114
|
+
_injectMarker(command, marker) {
|
|
115
|
+
// This is a default implementation - concrete classes may override
|
|
116
|
+
// For Minecraft commands, we can use entity tags
|
|
117
|
+
if (command.includes('@s')) {
|
|
118
|
+
return command.replace('@s', `@s[tag=${marker}]`);
|
|
119
|
+
}
|
|
120
|
+
if (command.includes('@p')) {
|
|
121
|
+
return command.replace('@p', `@p[tag=${marker}]`);
|
|
122
|
+
}
|
|
123
|
+
if (command.includes('@a')) {
|
|
124
|
+
return command.replace('@a', `@a[tag=${marker}]`);
|
|
125
|
+
}
|
|
126
|
+
if (command.includes('@e')) {
|
|
127
|
+
return command.replace('@e', `@e[tag=${marker}]`);
|
|
128
|
+
}
|
|
129
|
+
return command;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wait for timeout to expire
|
|
134
|
+
*
|
|
135
|
+
* Protected helper for timeout handling.
|
|
136
|
+
*
|
|
137
|
+
* @protected
|
|
138
|
+
* @param {number} timeout - Timeout in milliseconds
|
|
139
|
+
* @returns {Promise<never>} - Rejects after timeout
|
|
140
|
+
*/
|
|
141
|
+
async _waitForTimeout(timeout) {
|
|
142
|
+
return new Promise((_, reject) => {
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
reject(new ResponseTimeoutError('unknown', timeout));
|
|
145
|
+
}, timeout);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if an event matches correlation criteria
|
|
151
|
+
*
|
|
152
|
+
* Protected helper for concrete implementations.
|
|
153
|
+
* Provides a default implementation that checks for the
|
|
154
|
+
* correlation marker in the event data or raw text.
|
|
155
|
+
*
|
|
156
|
+
* @protected
|
|
157
|
+
* @param {Object} event - Parsed event to check
|
|
158
|
+
* @param {string} marker - Correlation marker to match
|
|
159
|
+
* @returns {boolean} - True if event matches
|
|
160
|
+
*/
|
|
161
|
+
_matchesEvent(event, marker) {
|
|
162
|
+
if (!event || !event.data) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check if marker exists anywhere in the event
|
|
167
|
+
const eventString = JSON.stringify(event);
|
|
168
|
+
return eventString.includes(marker);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { CorrelationStrategy };
|