seatalk-bot 0.0.0-alpha1

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.
Files changed (2) hide show
  1. package/index.js +2373 -0
  2. package/package.json +1 -0
package/index.js ADDED
@@ -0,0 +1,2373 @@
1
+ // index.js
2
+ // A basic framework for a SeaTalk Bot, handling configuration,
3
+ // webhook event parsing, command dispatching, and message sending.
4
+ //
5
+ // This implementation focuses on the core logic of receiving and processing
6
+ // events and commands. Note that actual network requests (like sending
7
+ // messages or running a webhook server) are represented conceptually or
8
+ // with stubs and simulations due to the constraint of using only
9
+ // standard JavaScript built-ins. Signature verification is also simulated.
10
+ // In a real Node.js application, you would use modules like 'http',
11
+ // 'https', 'crypto', and potentially 'events' from the Node.js standard
12
+ // library or external libraries like 'node-fetch', 'express', etc.
13
+
14
+ /**
15
+ * Custom error class for configuration issues.
16
+ */
17
+ class InvalidConfigurationError extends Error {
18
+ /**
19
+ * Creates an instance of InvalidConfigurationError.
20
+ * @param {string} message - The error message.
21
+ */
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'InvalidConfigurationError';
25
+ // Capture stack trace in V8 environments
26
+ if (typeof Error.captureStackTrace === 'function') {
27
+ Error.captureStackTrace(this, InvalidConfigurationError);
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Custom error class for message parsing issues.
34
+ */
35
+ class MessageParsingError extends Error {
36
+ /**
37
+ * Creates an instance of MessageParsingError.
38
+ * @param {string} message - The error message.
39
+ * @param {*} [originalError] - The original error if wrapping another exception.
40
+ */
41
+ constructor(message, originalError) {
42
+ super(message);
43
+ this.name = 'MessageParsingError';
44
+ this.originalError = originalError;
45
+ if (typeof Error.captureStackTrace === 'function') {
46
+ Error.captureStackTrace(this, MessageParsingError);
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Custom error class for issues during command registration or execution.
53
+ */
54
+ class CommandExecutionError extends Error {
55
+ /**
56
+ * Creates an instance of CommandExecutionError.
57
+ * @param {string} message - The error message.
58
+ * @param {*} [originalError] - The original error if wrapping another exception.
59
+ */
60
+ constructor(message, originalError) {
61
+ super(message);
62
+ this.name = 'CommandExecutionError';
63
+ this.originalError = originalError;
64
+ if (typeof Error.captureStackTrace === 'function') {
65
+ Error.captureStackTrace(this, CommandExecutionError);
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Custom error class for issues related to API interactions (conceptual in this version).
72
+ */
73
+ class ApiError extends Error {
74
+ /**
75
+ * Creates an instance of ApiError.
76
+ * @param {string} message - The error message.
77
+ * @param {number} [statusCode] - The HTTP status code if applicable (conceptual).
78
+ * @param {*} [details] - Additional details about the error.
79
+ */
80
+ constructor(message, statusCode, details) {
81
+ super(message);
82
+ this.name = 'ApiError';
83
+ this.statusCode = statusCode;
84
+ this.details = details;
85
+ if (typeof Error.captureStackTrace === 'function') {
86
+ Error.captureStackTrace(this, ApiError);
87
+ }
88
+ }
89
+ }
90
+
91
+ // --- Utility Functions ---
92
+
93
+ /**
94
+ * Checks if a value is a non-empty string after trimming whitespace.
95
+ * @param {*} value - The value to check.
96
+ * @returns {boolean} True if the value is a non-empty string, false otherwise.
97
+ */
98
+ function isValidString(value) {
99
+ return typeof value === 'string' && value.trim().length > 0;
100
+ }
101
+
102
+ /**
103
+ * Checks if a value is a non-negative integer.
104
+ * @param {*} value - The value to check.
105
+ * @returns {boolean} True if the value is a non-negative integer, false otherwise.
106
+ */
107
+ function isValidInteger(value) {
108
+ return Number.isInteger(value) && value >= 0;
109
+ }
110
+
111
+ /**
112
+ * Checks if a value is an array with at least one element.
113
+ * @param {*} value - The value to check.
114
+ * @returns {boolean} True if the value is a non-empty array, false otherwise.
115
+ */
116
+ function isValidArray(value) {
117
+ return Array.isArray(value) && value.length > 0;
118
+ }
119
+
120
+ /**
121
+ * Checks if a value is a plain object (non-null, non-array, typeof 'object').
122
+ * @param {*} value - The value to check.
123
+ * @returns {boolean} True if the value is a non-null object and not an array, false otherwise.
124
+ */
125
+ function isValidObject(value) {
126
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
127
+ }
128
+
129
+ /**
130
+ * Generates a timestamp in seconds.
131
+ * Useful for signature verification and message timestamps.
132
+ * @returns {number} Current time in seconds since the Unix epoch.
133
+ */
134
+ function getCurrentTimestampInSeconds() {
135
+ return Math.floor(Date.now() / 1000);
136
+ }
137
+
138
+ /**
139
+ * Simulates signature verification for webhook requests.
140
+ * In a real scenario, this would use HMAC-SHA256 with the app secret
141
+ * and compare against the 'X-Signature' header.
142
+ * Due to standard library constraint (no Node.js 'crypto'), this is a placeholder simulation.
143
+ * A real implementation MUST use cryptographic hashing.
144
+ * @param {string} rawBody - The raw request body string.
145
+ * @param {string} signatureHeader - The value of the 'X-Signature' header.
146
+ * @param {string} timestampHeader - The value of the 'X-Timestamp' header.
147
+ * @param {string} appSecret - The bot's app secret.
148
+ * @returns {boolean} True if the signature is valid (simulated), false otherwise.
149
+ */
150
+ function simulateSignatureVerification(rawBody, signatureHeader, timestampHeader, appSecret) {
151
+ // --- Placeholder for actual HMAC-SHA256 Signature Verification ---
152
+ // In a real scenario, this would look like:
153
+ // const crypto = require('crypto'); // Needs Node.js 'crypto'
154
+ // const hmac = crypto.createHmac('sha256', appSecret);
155
+ // hmac.update(timestampHeader + rawBody);
156
+ // const expectedSignature = hmac.digest('hex');
157
+ // return expectedSignature === signatureHeader;
158
+ // --- End Placeholder ---
159
+
160
+ if (!isValidString(rawBody) || !isValidString(signatureHeader) || !isValidString(timestampHeader) || !isValidString(appSecret)) {
161
+ console.warn('simulateSignatureVerification: Invalid input provided.');
162
+ return false; // Invalid input means verification fails
163
+ }
164
+
165
+ const providedSignature = signatureHeader.trim();
166
+ const providedTimestamp = parseInt(timestampHeader, 10);
167
+
168
+ if (isNaN(providedTimestamp)) {
169
+ console.warn('simulateSignatureVerification: Invalid timestamp format.');
170
+ return false; // Invalid timestamp format means verification fails
171
+ }
172
+
173
+ // Simulate timestamp freshness check
174
+ const currentTimestamp = getCurrentTimestampInSeconds();
175
+ const timestampTolerance = 300; // Allow for 5 minutes difference
176
+
177
+ if (Math.abs(currentTimestamp - providedTimestamp) > timestampTolerance) {
178
+ console.warn(`simulateSignatureVerification: Timestamp skew too large. Current: ${currentTimestamp}, Provided: ${providedTimestamp}`);
179
+ // In a real scenario, this would fail verification.
180
+ // For this simulation, we'll allow it to pass the timestamp check
181
+ // so we can focus on the 'signature' part of the simulation.
182
+ // In a real bot, `return false;` would likely be here.
183
+ }
184
+
185
+ // Simulate signature check: A very basic check to make the code structure look plausible
186
+ // without actual hashing. This is NOT secure.
187
+ // The simulation expects the signature header to start with 'valid_signature_for_'
188
+ // followed by the first 8 characters of the app secret.
189
+ const expectedPrefix = 'valid_signature_for_';
190
+ const expectedSignaturePart = appSecret.substring(0, 8);
191
+
192
+ const isSignatureMatchSimulated = providedSignature.startsWith(expectedPrefix + expectedSignaturePart);
193
+
194
+ if (!isSignatureMatchSimulated) {
195
+ console.warn(`simulateSignatureVerification: Signature mismatch simulated. Expected prefix: "${expectedPrefix + expectedSignaturePart}", Provided starts with: "${providedSignature.substring(0, expectedPrefix.length + expectedSignaturePart.length)}"`);
196
+ }
197
+
198
+ // In a real scenario, both timestamp and signature must be valid.
199
+ // Here, we return only the result of the simulated signature match.
200
+ return isSignatureMatchSimulated;
201
+ }
202
+
203
+ /**
204
+ * Parses a string message to identify if it's a command, and extracts command name and arguments.
205
+ * Assumes commands start with '/' followed by the command name, then space-separated arguments.
206
+ * @param {string} messageText - The message text to parse.
207
+ * @returns {{command: string|null, args: string[]|null}} An object with `command` (lowercase name without '/') and `args` (array of strings), or `{ command: null, args: null }` if not a command.
208
+ */
209
+ function parseCommand(messageText) {
210
+ if (!isValidString(messageText) || !messageText.startsWith('/')) {
211
+ return { command: null, args: null };
212
+ }
213
+
214
+ // Remove the leading slash and split the rest by one or more whitespace characters
215
+ const parts = messageText.substring(1).trim().split(/\s+/);
216
+
217
+ // If parts[0] is empty after trim (e.g., message was just '/'), it's not a valid command
218
+ if (parts.length === 0 || parts[0].length === 0) {
219
+ return { command: null, args: null };
220
+ }
221
+
222
+ // The first part is the command name, subsequent parts are arguments
223
+ const command = parts[0].toLowerCase(); // Normalize command name to lowercase
224
+ const args = parts.slice(1); // Array of argument strings
225
+
226
+ console.log(`Parsed command: "${command}" with arguments: [${args.map(arg => `"${arg}"`).join(', ')}]`);
227
+ return { command, args };
228
+ }
229
+
230
+ // --- Basic Event Emitter Implementation ---
231
+ // Since Node.js 'events' module is not a standard browser built-in,
232
+ // we implement a basic one here to enable event-driven architecture within the bot.
233
+
234
+ /**
235
+ * A basic implementation of an Event Emitter adhering to standard patterns.
236
+ */
237
+ class EventEmitter {
238
+ constructor() {
239
+ /**
240
+ * @private
241
+ * @type {Object.<string, Function[]>} Stores listeners for each event name.
242
+ */
243
+ this._listeners = {};
244
+ // Map to track 'once' wrappers to enable proper removal
245
+ /**
246
+ * @private
247
+ * @type {Map<Function, Function>} Maps original 'once' listeners to their wrapper functions.
248
+ */
249
+ this._onceWrappers = new Map();
250
+ }
251
+
252
+ /**
253
+ * Registers an event listener function.
254
+ * @param {string} eventName - The name of the event to listen for.
255
+ * @param {Function} listener - The callback function to execute when the event is emitted.
256
+ * @returns {EventEmitter} Returns the emitter for chaining.
257
+ */
258
+ on(eventName, listener) {
259
+ if (typeof listener !== 'function') {
260
+ console.error(`EventEmitter: Listener for event "${eventName}" must be a function.`);
261
+ return this; // Invalid listener provided
262
+ }
263
+ if (!isValidString(eventName)) {
264
+ console.error(`EventEmitter: Event name must be a non-empty string.`);
265
+ return this; // Invalid event name provided
266
+ }
267
+
268
+ if (!this._listeners[eventName]) {
269
+ this._listeners[eventName] = [];
270
+ }
271
+ // Prevent adding the *exact same function instance* multiple times for the same event
272
+ // This doesn't prevent adding different functions that do the same thing.
273
+ if (!this._listeners[eventName].includes(listener)) {
274
+ this._listeners[eventName].push(listener);
275
+ // console.debug(`Listener registered for event: "${eventName}"`); // Debugging
276
+ } else {
277
+ // console.debug(`Listener already registered for event: "${eventName}"`); // Debugging
278
+ }
279
+
280
+ return this;
281
+ }
282
+
283
+ /**
284
+ * Registers a one-time event listener. The listener will be invoked only the next time the event is emitted, and then it will be removed.
285
+ * @param {string} eventName - The name of the event to listen for.
286
+ * @param {Function} listener - The callback function to execute.
287
+ * @returns {EventEmitter} Returns the emitter for chaining.
288
+ */
289
+ once(eventName, listener) {
290
+ if (typeof listener !== 'function') {
291
+ console.error(`EventEmitter: Listener for event "${eventName}" must be a function.`);
292
+ return this; // Invalid listener provided
293
+ }
294
+ if (!isValidString(eventName)) {
295
+ console.error(`EventEmitter: Event name must be a non-empty string.`);
296
+ return this; // Invalid event name provided
297
+ }
298
+
299
+ const onceListener = (...args) => {
300
+ this.off(eventName, onceListener); // Remove this wrapper listener
301
+ this._onceWrappers.delete(listener); // Remove the mapping
302
+ listener.apply(this, args); // Call the original listener
303
+ };
304
+
305
+ // Store the mapping from original listener to the wrapper
306
+ this._onceWrappers.set(listener, onceListener);
307
+
308
+ // Register the wrapper listener
309
+ this.on(eventName, onceListener);
310
+
311
+ // console.debug(`'Once' listener registered for event: "${eventName}"`); // Debugging
312
+ return this;
313
+ }
314
+
315
+ /**
316
+ * Removes a specific event listener.
317
+ * @param {string} eventName - The name of the event.
318
+ * @param {Function} listener - The callback function to remove.
319
+ * @returns {EventEmitter} Returns the emitter for chaining.
320
+ */
321
+ off(eventName, listener) {
322
+ if (typeof listener !== 'function') {
323
+ console.error(`EventEmitter: Listener for event "${eventName}" must be a function.`);
324
+ return this; // Invalid listener provided
325
+ }
326
+ if (!isValidString(eventName)) {
327
+ console.error(`EventEmitter: Event name must be a non-empty string.`);
328
+ return this; // Invalid event name provided
329
+ }
330
+
331
+ const listeners = this._listeners[eventName];
332
+ if (!listeners) {
333
+ // console.debug(`No listeners found for event "${eventName}" to remove.`); // Debugging
334
+ return this; // No listeners for this event
335
+ }
336
+
337
+ // If the listener is a 'once' listener, find its wrapper to remove
338
+ const listenerToRemove = this._onceWrappers.get(listener) || listener;
339
+
340
+ // Filter out the specific listener function (or its wrapper)
341
+ const newListeners = listeners.filter(l => l !== listenerToRemove);
342
+
343
+ if (newListeners.length < listeners.length) {
344
+ // Listener was found and removed
345
+ // console.debug(`Listener removed for event: "${eventName}"`); // Debugging
346
+ this._listeners[eventName] = newListeners;
347
+
348
+ // If it was a 'once' listener, clean up the mapping
349
+ if (this._onceWrappers.has(listener)) {
350
+ this._onceWrappers.delete(listener);
351
+ }
352
+ } else {
353
+ // Listener was not found
354
+ // console.debug(`Specified listener not found for event: "${eventName}"`); // Debugging
355
+ }
356
+
357
+
358
+ // Clean up the event entry if no listeners remain
359
+ if (this._listeners[eventName].length === 0) {
360
+ delete this._listeners[eventName];
361
+ // console.debug(`All listeners removed for event: "${eventName}". Event entry deleted.`); // Debugging
362
+ }
363
+
364
+ return this;
365
+ }
366
+
367
+ /**
368
+ * Removes all listeners for a specific event, or all listeners if no event name is given.
369
+ * @param {string} [eventName] - The name of the event. If omitted or null/undefined, all listeners for all events are removed.
370
+ * @returns {EventEmitter} Returns the emitter for chaining.
371
+ */
372
+ removeAllListeners(eventName) {
373
+ if (eventName === undefined || eventName === null) {
374
+ console.log('Removing all listeners for all events.');
375
+ this._listeners = {}; // Remove all listeners
376
+ this._onceWrappers.clear(); // Clear all once mappings
377
+ } else if (isValidString(eventName)) {
378
+ console.log(`Removing all listeners for event: "${eventName}".`);
379
+ if (this._listeners[eventName]) {
380
+ // To properly clean up 'once' mappings, we need to iterate
381
+ this._listeners[eventName].forEach(listener => {
382
+ // Find the original listener associated with this wrapper (if it's a wrapper)
383
+ // This requires iterating _onceWrappers, which is inefficient.
384
+ // A better 'once' implementation might store the wrapper-to-original mapping.
385
+ // For this basic implementation, we'll just remove the event's listeners
386
+ // and let the _onceWrappers map potentially retain stale entries
387
+ // or clear the whole map if removing all listeners for all events.
388
+ // A more robust implementation would be needed for perfect cleanup.
389
+ });
390
+ delete this._listeners[eventName]; // Remove listeners for a specific event
391
+ }
392
+ // Note: Clearing specific event listeners doesn't clean up _onceWrappers efficiently here.
393
+ // Clearing all listeners with removeAllListeners() (no args) is the way to fully reset.
394
+ } else {
395
+ console.error(`EventEmitter: Invalid event name provided to removeAllListeners.`);
396
+ }
397
+ return this;
398
+ }
399
+
400
+ /**
401
+ * Emits an event, calling all registered listeners synchronously in the order they were registered.
402
+ * Arguments are passed directly to the listener functions.
403
+ * @param {string} eventName - The name of the event to emit.
404
+ * @param {...*} args - Arguments to pass to the listener functions.
405
+ * @returns {boolean} True if the event had listeners, false otherwise.
406
+ */
407
+ emit(eventName, ...args) {
408
+ if (!isValidString(eventName)) {
409
+ console.error(`EventEmitter: Cannot emit event with invalid name.`);
410
+ return false; // Cannot emit invalid event name
411
+ }
412
+
413
+ // Get listeners for the event. Clone the array *before* iterating
414
+ // in case listeners add or remove listeners during emission.
415
+ const listeners = this._listeners[eventName] ? [...this._listeners[eventName]] : [];
416
+
417
+ if (listeners.length === 0) {
418
+ // console.debug(`No listeners for event "${eventName}".`); // Can be noisy
419
+ return false; // No listeners
420
+ }
421
+
422
+ // console.debug(`Emitting event "${eventName}" with ${listeners.length} listener(s).`); // Debugging
423
+
424
+ // Call each listener
425
+ for (const listener of listeners) {
426
+ try {
427
+ // Call the listener with 'this' context being the emitter itself
428
+ listener.apply(this, args);
429
+ } catch (error) {
430
+ console.error(`EventEmitter: Error in listener for event "${eventName}":`, error);
431
+ // In many emitter implementations, an 'error' event is emitted here
432
+ // if a listener throws. We will emit one as well.
433
+ // Avoid infinite loops if the 'error' listener itself throws.
434
+ if (eventName !== 'error') {
435
+ try {
436
+ // Pass the error, the event name, and the arguments that triggered the error
437
+ this.emit('error', error, eventName, ...args);
438
+ } catch (errInErrorListener) {
439
+ console.error(`EventEmitter: Error in 'error' event listener for event "${eventName}":`, errInErrorListener);
440
+ // If the error listener fails, just log and stop.
441
+ }
442
+ } else {
443
+ // If the 'error' event listener itself throws, we just log.
444
+ console.error(`EventEmitter: An 'error' event listener threw an error.`);
445
+ }
446
+ }
447
+ }
448
+
449
+ return true; // Event had listeners
450
+ }
451
+
452
+ /**
453
+ * Returns an array listing the events for which listeners are currently registered.
454
+ * @returns {string[]} Array of event names.
455
+ */
456
+ eventNames() {
457
+ return Object.keys(this._listeners);
458
+ }
459
+
460
+ /**
461
+ * Returns the number of listeners for a given event name.
462
+ * @param {string} eventName - The name of the event.
463
+ * @returns {number} The number of listeners.
464
+ */
465
+ listenerCount(eventName) {
466
+ if (!isValidString(eventName)) {
467
+ console.error(`EventEmitter: Invalid event name provided to listenerCount.`);
468
+ return 0;
469
+ }
470
+ const listeners = this._listeners[eventName];
471
+ return listeners ? listeners.length : 0;
472
+ }
473
+
474
+ /**
475
+ * Returns a copy of the array of listeners for the specified event.
476
+ * @param {string} eventName - The name of the event.
477
+ * @returns {Function[]} Array of listener functions. Returns an empty array if no listeners are registered or eventName is invalid.
478
+ */
479
+ listeners(eventName) {
480
+ if (!isValidString(eventName)) {
481
+ console.error(`EventEmitter: Invalid event name provided to listeners.`);
482
+ return [];
483
+ }
484
+ const listeners = this._listeners[eventName];
485
+ return listeners ? [...listeners] : []; // Return a copy to prevent external modification
486
+ }
487
+ }
488
+
489
+
490
+ // --- Configuration Class ---
491
+
492
+ /**
493
+ * Manages bot configuration, including loading and validation.
494
+ */
495
+ class BotConfiguration {
496
+ /**
497
+ * Creates a BotConfiguration instance.
498
+ * Validates required configuration properties upon instantiation.
499
+ * @param {object} config - The configuration object.
500
+ * @param {string} config.appId - The SeaTalk App ID.
501
+ * @param {string} config.appSecret - The SeaTalk App Secret.
502
+ * @param {string} config.verificationToken - The SeaTalk Verification Token for webhooks.
503
+ * @param {string} [config.apiBaseUrl='https://openapi.seatalk.io/openapi/v2'] - The base URL for SeaTalk API calls (conceptual).
504
+ * @param {number} [config.webhookPort=8080] - The port to listen on for webhooks (conceptual). Must be between 1 and 65535.
505
+ * @throws {InvalidConfigurationError} If the provided configuration is invalid or missing required fields.
506
+ */
507
+ constructor(config) {
508
+ if (!isValidObject(config)) {
509
+ throw new InvalidConfigurationError('Configuration must be a valid object.');
510
+ }
511
+
512
+ /**
513
+ * @private
514
+ * @type {string}
515
+ */
516
+ this._appId = '';
517
+ /**
518
+ * @private
519
+ * @type {string}
520
+ */
521
+ this._appSecret = '';
522
+ /**
523
+ * @private
524
+ * @type {string}
525
+ */
526
+ this._verificationToken = '';
527
+ /**
528
+ * @private
529
+ * @type {string}
530
+ */
531
+ this._apiBaseUrl = 'https://openapi.seatalk.io/openapi/v2'; // Default SeaTalk Open API Base URL
532
+ /**
533
+ * @private
534
+ * @type {number}
535
+ */
536
+ this._webhookPort = 8080; // Default webhook port
537
+
538
+ this._loadConfig(config);
539
+ this._validateConfig(); // Validate after loading
540
+
541
+ console.log('BotConfiguration initialized.');
542
+ }
543
+
544
+ /**
545
+ * Loads properties from the provided config object into internal state.
546
+ * Applies default values for optional properties if not provided.
547
+ * @private
548
+ * @param {object} config - The raw configuration object.
549
+ */
550
+ _loadConfig(config) {
551
+ // Required fields
552
+ this._appId = config.appId || '';
553
+ this._appSecret = config.appSecret || '';
554
+ this._verificationToken = config.verificationToken || '';
555
+
556
+ // Optional configurations with defaults and validation checks
557
+ if (isValidString(config.apiBaseUrl)) {
558
+ this._apiBaseUrl = config.apiBaseUrl.trim();
559
+ // Ensure no trailing slash for consistency, unless it's just the root "/"
560
+ if (this._apiBaseUrl !== '/' && this._apiBaseUrl.endsWith('/')) {
561
+ this._apiBaseUrl = this._apiBaseUrl.slice(0, -1);
562
+ }
563
+ } else if (config.apiBaseUrl !== undefined && config.apiBaseUrl !== null && config.apiBaseUrl !== '') {
564
+ console.warn(`BotConfiguration: Invalid apiBaseUrl value "${config.apiBaseUrl}". Using default ${this._apiBaseUrl}.`);
565
+ }
566
+
567
+
568
+ if (typeof config.webhookPort === 'number' && Number.isInteger(config.webhookPort) && config.webhookPort > 0 && config.webhookPort <= 65535) {
569
+ this._webhookPort = config.webhookPort;
570
+ } else if (config.webhookPort !== undefined) {
571
+ console.warn(`BotConfiguration: Invalid webhookPort value "${config.webhookPort}". Using default ${this._webhookPort}. Port must be an integer between 1 and 65535.`);
572
+ }
573
+
574
+ // Log loaded status of key configs (careful with sensitive data)
575
+ console.log(`Config load status: appId(${isValidString(this._appId)}), appSecret(${isValidString(this._appSecret)}), verificationToken(${isValidString(this._verificationToken)}), apiBaseUrl(${this._apiBaseUrl}), webhookPort(${this._webhookPort})`);
576
+ }
577
+
578
+ /**
579
+ * Validates the loaded configuration properties.
580
+ * Checks for presence and basic type/format correctness of required fields.
581
+ * @private
582
+ * @throws {InvalidConfigurationError} If any required configuration is missing or invalid.
583
+ */
584
+ _validateConfig() {
585
+ if (!isValidString(this._appId)) {
586
+ throw new InvalidConfigurationError('Configuration missing or invalid required property: appId (must be a non-empty string).');
587
+ }
588
+ if (!isValidString(this._appSecret)) {
589
+ throw new InvalidConfigurationError('Configuration missing or invalid required property: appSecret (must be a non-empty string).');
590
+ }
591
+ if (!isValidString(this._verificationToken)) {
592
+ throw new InvalidConfigurationError('Configuration missing or invalid required property: verificationToken (must be a non-empty string).');
593
+ }
594
+ if (!isValidString(this._apiBaseUrl)) {
595
+ // This check should theoretically be covered by _loadConfig default, but included for robustness
596
+ throw new InvalidConfigurationError('Configuration property apiBaseUrl is invalid after loading.');
597
+ }
598
+ if (!isValidInteger(this._webhookPort) || this._webhookPort <= 0 || this._webhookPort > 65535) {
599
+ // This check should also be mostly covered by _loadConfig, but double-check final state
600
+ throw new InvalidConfigurationError('Configuration property webhookPort must be a valid port number (1-65535) integer.');
601
+ }
602
+
603
+ console.log('BotConfiguration validated successfully.');
604
+ }
605
+
606
+ /**
607
+ * Gets the App ID.
608
+ * @returns {string} The SeaTalk App ID.
609
+ */
610
+ getAppId() {
611
+ return this._appId;
612
+ }
613
+
614
+ /**
615
+ * Gets the App Secret.
616
+ * @returns {string} The SeaTalk App Secret.
617
+ */
618
+ getAppSecret() {
619
+ return this._appSecret;
620
+ }
621
+
622
+ /**
623
+ * Gets the Verification Token.
624
+ * @returns {string} The SeaTalk Verification Token.
625
+ */
626
+ getVerificationToken() {
627
+ return this._verificationToken;
628
+ }
629
+
630
+ /**
631
+ * Gets the API Base URL.
632
+ * @returns {string} The API Base URL.
633
+ */
634
+ getApiBaseUrl() {
635
+ return this._apiBaseUrl;
636
+ }
637
+
638
+ /**
639
+ * Gets the Webhook Port.
640
+ * @returns {number} The webhook port.
641
+ */
642
+ getWebhookPort() {
643
+ return this._webhookPort;
644
+ }
645
+
646
+ /**
647
+ * Returns a sanitized configuration object suitable for logging or external viewing.
648
+ * Hides sensitive information like secrets by replacing them with placeholders.
649
+ * @returns {object} A sanitized configuration object.
650
+ */
651
+ getSanitizedConfig() {
652
+ return {
653
+ appId: this._appId,
654
+ appSecret: this._appSecret ? '[HIDDEN]' : '[EMPTY]', // Indicate if secret was provided
655
+ verificationToken: this._verificationToken ? '[HIDDEN]' : '[EMPTY]', // Indicate if token was provided
656
+ apiBaseUrl: this._apiBaseUrl,
657
+ webhookPort: this._webhookPort
658
+ };
659
+ }
660
+ }
661
+
662
+ // --- Message and Event Data Structures ---
663
+ // Classes representing parsed incoming SeaTalk events and messages.
664
+
665
+ /**
666
+ * Base class for incoming webhook events.
667
+ * Provides common fields present in all SeaTalk webhook event types.
668
+ */
669
+ class SeaTalkEvent {
670
+ /**
671
+ * Constructs a base SeaTalkEvent instance.
672
+ * @param {object} data - Raw event data object from the parsed webhook payload.
673
+ * @param {string} data.event_type - Type of the event (e.g., 'message', 'callback', 'add_bot').
674
+ * @param {string} data.token - Verification token from the request payload body.
675
+ * @param {string} data.timestamp - Timestamp from the request payload body (usually string).
676
+ * @param {string} data.open_app_id - The app ID the event is for.
677
+ * @param {string} [data.open_chat_id] - The ID of the chat where the event occurred (if applicable, not all events have this).
678
+ * @throws {MessageParsingError} If the basic event data structure is invalid.
679
+ */
680
+ constructor(data) {
681
+ if (!isValidObject(data)) {
682
+ throw new MessageParsingError('Event data must be a valid object.');
683
+ }
684
+ if (!isValidString(data.event_type)) {
685
+ throw new MessageParsingError('Event data missing required "event_type" field.');
686
+ }
687
+ if (!isValidString(data.token)) {
688
+ throw new MessageParsingError('Event data missing required "token" field.');
689
+ }
690
+ if (!isValidString(data.timestamp)) {
691
+ throw new MessageParsingError('Event data missing required "timestamp" field.');
692
+ }
693
+ if (!isValidString(data.open_app_id)) {
694
+ throw new MessageParsingError('Event data missing required "open_app_id" field.');
695
+ }
696
+
697
+
698
+ /**
699
+ * The type of the event (e.g., 'message').
700
+ * @type {string}
701
+ */
702
+ this.eventType = data.event_type;
703
+ /**
704
+ * The verification token included in the payload.
705
+ * @type {string}
706
+ */
707
+ this.token = data.token;
708
+ /**
709
+ * The timestamp of the event (as a string, typically epoch seconds).
710
+ * @type {string}
711
+ */
712
+ this.timestamp = data.timestamp;
713
+ /**
714
+ * The open App ID the event was sent to.
715
+ * @type {string}
716
+ */
717
+ this.openAppId = data.open_app_id;
718
+ /**
719
+ * The open chat ID associated with the event (if applicable).
720
+ * @type {string|null}
721
+ */
722
+ this.openChatId = data.open_chat_id || null; // Optional field
723
+
724
+ // Store raw data for potential inspection or debugging
725
+ /**
726
+ * The original raw object payload data for this event.
727
+ * @type {object}
728
+ */
729
+ this.rawData = data;
730
+
731
+ console.debug(`Created base SeaTalkEvent (Type: ${this.eventType}, App: ${this.openAppId}, Chat: ${this.openChatId})`);
732
+ }
733
+
734
+ /**
735
+ * Validates the basic required fields for any event type.
736
+ * Note: This duplicates checks in the constructor but can be useful for post-instantiation checks.
737
+ * @returns {boolean} True if basic fields are present and valid strings.
738
+ */
739
+ isValid() {
740
+ return isValidString(this.eventType) &&
741
+ isValidString(this.token) &&
742
+ isValidString(this.timestamp) &&
743
+ isValidString(this.openAppId);
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Represents an incoming message event, inheriting from SeaTalkEvent.
749
+ * Includes message-specific details like sender, content, chat type, etc.
750
+ */
751
+ class SeaTalkMessageEvent extends SeaTalkEvent {
752
+ /**
753
+ * Constructs a SeaTalkMessageEvent instance.
754
+ * @param {object} data - Raw message event data from the parsed webhook payload.
755
+ * @param {object} data.message - The core message payload object.
756
+ * @param {string} data.message.message_id - Unique message ID.
757
+ * @param {string} data.message.chat_type - 'group', 'p2p', etc.
758
+ * @param {string} data.message.open_chat_id - The chat ID. This should match data.open_chat_id.
759
+ * @param {string} data.message.sender_type - 'user', 'bot', etc.
760
+ * @param {string} data.message.sender_user_id - User ID of the sender.
761
+ * @param {string} data.message.message_type - 'text', 'image', etc.
762
+ * @param {string} data.message.create_time - Message creation timestamp (usually string).
763
+ * @param {object} data.message.content - Message content depending on type.
764
+ * @param {object} [data.sender] - Optional sender details object.
765
+ * @param {string} [data.sender.tenant_user_id] - Tenant-specific user ID from sender details.
766
+ * // ... other message specific fields as documented by SeaTalk API
767
+ * @throws {MessageParsingError} If the message data structure is invalid or missing required message fields.
768
+ */
769
+ constructor(data) {
770
+ // Validate base event structure first
771
+ super(data);
772
+
773
+ // Ensure the event type is explicitly 'message'
774
+ if (this.eventType !== 'message') {
775
+ // This check should theoretically be done by the parser before calling this constructor,
776
+ // but included here for robustness.
777
+ throw new MessageParsingError(`Expected event_type 'message' for SeaTalkMessageEvent, but got '${this.eventType}'`);
778
+ }
779
+
780
+ const message = data.message;
781
+ if (!isValidObject(message)) {
782
+ throw new MessageParsingError('Message event data must contain a valid "message" object.');
783
+ }
784
+ // Also check required fields within the message object
785
+ if (!isValidString(message.message_id)) throw new MessageParsingError('Message object missing required "message_id" field.');
786
+ if (!isValidString(message.chat_type)) throw new MessageParsingError('Message object missing required "chat_type" field.');
787
+ if (!isValidString(message.open_chat_id)) throw new MessageParsingError('Message object missing required "open_chat_id" field.');
788
+ if (!isValidString(message.sender_type)) throw new MessageParsingError('Message object missing required "sender_type" field.');
789
+ if (!isValidString(message.sender_user_id)) throw new MessageParsingError('Message object missing required "sender_user_id" field.');
790
+ if (!isValidString(message.message_type)) throw new MessageParsingError('Message object missing required "message_type" field.');
791
+ if (!isValidString(message.create_time)) throw new MessageParsingError('Message object missing required "create_time" field.');
792
+ if (!isValidObject(message.content)) throw new MessageParsingError('Message object missing required "content" object.');
793
+
794
+
795
+ /**
796
+ * Unique identifier for the message.
797
+ * @type {string}
798
+ */
799
+ this.messageId = message.message_id;
800
+ /**
801
+ * Type of chat: 'group', 'p2p'.
802
+ * @type {string}
803
+ */
804
+ this.chatType = message.chat_type;
805
+ /**
806
+ * Type of sender: 'user', 'bot'.
807
+ * @type {string}
808
+ */
809
+ this.senderType = message.sender_type;
810
+ /**
811
+ * Unique user ID of the sender.
812
+ * @type {string}
813
+ */
814
+ this.senderUserId = message.sender_user_id;
815
+ /**
816
+ * Type of message content: 'text', 'image', 'file', 'card', etc.
817
+ * @type {string}
818
+ */
819
+ this.messageType = message.message_type;
820
+ /**
821
+ * Timestamp when the message was created (as a string).
822
+ * @type {string}
823
+ */
824
+ this.createTime = message.create_time; // Keep as string, conversion might be needed later
825
+
826
+ /**
827
+ * The content payload of the message. Structure varies based on `messageType`.
828
+ * @type {object}
829
+ */
830
+ this.content = message.content; // This structure varies by message type
831
+
832
+ // Optional fields from the top level data object related to the sender or chat context
833
+ /**
834
+ * Tenant-specific user ID from the sender details (if provided).
835
+ * @type {string|null}
836
+ */
837
+ this.tenantUserId = (data.sender && isValidString(data.sender.tenant_user_id)) ? data.sender.tenant_user_id : null;
838
+ // Add other potential sender/chat/etc. fields from rawData if needed
839
+
840
+ console.debug(`Created SeaTalkMessageEvent (ID: ${this.messageId}, Type: ${this.messageType}, From: ${this.senderUserId}, Chat: ${this.openChatId})`);
841
+ }
842
+
843
+ /**
844
+ * Validates the message event specific fields in addition to base event fields.
845
+ * Checks for presence and basic type correctness of all properties set in the constructor.
846
+ * @returns {boolean} True if all message specific fields are present and valid.
847
+ */
848
+ isMessageValid() {
849
+ return this.isValid() && // Validate base event fields
850
+ isValidString(this.messageId) &&
851
+ isValidString(this.chatType) &&
852
+ isValidString(this.senderType) &&
853
+ isValidString(this.senderUserId) &&
854
+ isValidString(this.messageType) &&
855
+ isValidString(this.createTime) &&
856
+ isValidObject(this.content); // Content must be an object, though its properties depend on type
857
+ }
858
+
859
+ /**
860
+ * Gets the text content if the message type is 'text'.
861
+ * Performs basic validation on the content structure for text messages.
862
+ * @returns {string|null} The text content string or null if not a text message or content is invalid.
863
+ */
864
+ getTextContent() {
865
+ if (this.messageType === 'text' && isValidObject(this.content) && typeof this.content.text === 'string') {
866
+ return this.content.text;
867
+ }
868
+ return null;
869
+ }
870
+
871
+ // Placeholder methods for accessing content of other message types (conceptual)
872
+ /**
873
+ * Gets the image content if the message type is 'image'.
874
+ * Placeholder for retrieving image-specific data structure.
875
+ * @returns {object|null} The image content object or null if not an image message or content is invalid.
876
+ */
877
+ getImageContent() {
878
+ if (this.messageType === 'image' && isValidObject(this.content)) {
879
+ // Return structured image data object (e.g., { image_key: '...', caption: '...' })
880
+ // Validate specific image properties here in a real implementation
881
+ if (typeof this.content.image_key === 'string') {
882
+ return this.content; // Return the content object as is for simplicity in placeholder
883
+ }
884
+ }
885
+ return null;
886
+ }
887
+
888
+ /**
889
+ * Gets the file content if the message type is 'file'.
890
+ * Placeholder for retrieving file-specific data structure.
891
+ * @returns {object|null} The file content object or null if not a file message or content is invalid.
892
+ */
893
+ getFileContent() {
894
+ if (this.messageType === 'file' && isValidObject(this.content)) {
895
+ // Return structured file data object (e.g., { file_key: '...', file_name: '...', file_size: ... })
896
+ // Validate specific file properties here in a real implementation
897
+ if (typeof this.content.file_key === 'string' && typeof this.content.file_name === 'string') {
898
+ return this.content; // Return the content object as is for simplicity in placeholder
899
+ }
900
+ }
901
+ return null;
902
+ }
903
+
904
+ // Add more methods for other message types like getCardContent(), etc.
905
+ }
906
+
907
+ // Add more specific event types like 'SeaTalkCallbackEvent', 'SeaTalkAddBotEvent', etc.
908
+ // based on SeaTalk API documentation, inheriting from SeaTalkEvent.
909
+ // class SeaTalkCallbackEvent extends SeaTalkEvent { /* ... validation and properties ... */ }
910
+ // class SeaTalkAddBotEvent extends SeaTalkEvent { /* ... validation and properties ... */ }
911
+
912
+
913
+ // --- Message Parser ---
914
+
915
+ /**
916
+ * Handles parsing raw webhook payload (JSON string) into structured SeaTalkEvent objects.
917
+ * Selects the appropriate event class based on the 'event_type'.
918
+ */
919
+ class MessageParser {
920
+ /**
921
+ * Creates a MessageParser instance.
922
+ */
923
+ constructor() {
924
+ console.log('MessageParser initialized.');
925
+ }
926
+
927
+ /**
928
+ * Parses a raw JSON string payload from a webhook request body into a specific SeaTalkEvent object.
929
+ * Dispatches to appropriate constructors based on `event_type`.
930
+ * @param {string} rawPayload - The raw JSON payload string.
931
+ * @returns {SeaTalkEvent|SeaTalkMessageEvent|null} A parsed and validated event object. Returns null only if the payload is empty string or whitespace.
932
+ * @throws {MessageParsingError} If the payload is invalid JSON, missing required top-level fields, or the specific event structure is invalid.
933
+ */
934
+ parseWebhookPayload(rawPayload) {
935
+ // Allow empty or whitespace raw payload to return null without error,
936
+ // as some webhook systems might send keepalives or invalid empty data.
937
+ if (!isValidString(rawPayload)) {
938
+ console.warn('MessageParser: Received empty or invalid raw payload string.');
939
+ return null;
940
+ }
941
+
942
+ let payloadObject;
943
+ try {
944
+ // Attempt to parse the string as JSON
945
+ payloadObject = JSON.parse(rawPayload);
946
+ console.debug('Payload parsed as JSON.');
947
+ } catch (error) {
948
+ // Catch JSON parsing errors
949
+ console.error('MessageParser: Failed to parse payload as JSON.', error);
950
+ throw new MessageParsingError('Failed to parse payload as JSON.', error);
951
+ }
952
+
953
+ // Check if the parsed result is a valid object
954
+ if (!isValidObject(payloadObject)) {
955
+ console.error('MessageParser: Parsed payload is not a valid object.');
956
+ throw new MessageParsingError('Parsed payload is not a valid object.');
957
+ }
958
+
959
+ // Check for the mandatory 'event_type' field at the top level
960
+ const eventType = payloadObject.event_type;
961
+ if (!isValidString(eventType)) {
962
+ console.error('MessageParser: Payload missing required "event_type" field.');
963
+ throw new MessageParsingError('Payload missing required "event_type" field.');
964
+ }
965
+
966
+ console.log(`MessageParser: Identifying event type "${eventType}".`);
967
+
968
+ // Dispatch based on event_type to the appropriate event class constructor
969
+ try {
970
+ let event;
971
+ switch (eventType) {
972
+ case 'message':
973
+ // Construct and validate a Message Event
974
+ event = new SeaTalkMessageEvent(payloadObject);
975
+ if (!event.isMessageValid()) {
976
+ // The constructor throws on critical validation failure,
977
+ // but this provides an extra layer or could be used for non-critical warnings.
978
+ console.warn('MessageParser: Parsed message event failed detailed validation.', event);
979
+ // Depending on strictness, could throw here:
980
+ // throw new MessageParsingError('Parsed message event failed detailed validation.');
981
+ }
982
+ console.log(`MessageParser: Successfully created SeaTalkMessageEvent (ID: ${event.messageId}).`);
983
+ return event;
984
+
985
+ // Add cases for other event types here:
986
+ // case 'callback':
987
+ // // Validate and return a SeaTalkCallbackEvent
988
+ // event = new SeaTalkCallbackEvent(payloadObject);
989
+ // if (!event.isValidCallback()) { ... } // Add type-specific validation
990
+ // return event;
991
+
992
+ // case 'add_bot':
993
+ // // Validate and return a SeaTalkAddBotEvent
994
+ // return new SeaTalkAddBotEvent(payloadObject);
995
+
996
+ default:
997
+ // For unhandled or unknown event types, return a generic SeaTalkEvent
998
+ // Ensure the basic structure is valid for the generic event.
999
+ console.warn(`MessageParser: Received unhandled event type: "${eventType}". Creating generic SeaTalkEvent.`);
1000
+ event = new SeaTalkEvent(payloadObject); // Base class constructor validates basic fields
1001
+ if (!event.isValid()) {
1002
+ // This check should theoretically be covered by the SeaTalkEvent constructor,
1003
+ // but added here for safety.
1004
+ console.warn('MessageParser: Parsed generic event failed basic validation.', event);
1005
+ throw new MessageParsingError(`Parsed generic event of type "${eventType}" is structurally invalid.`);
1006
+ }
1007
+ console.log(`MessageParser: Successfully created generic SeaTalkEvent (Type: ${event.eventType}).`);
1008
+ return event;
1009
+ }
1010
+ } catch (error) {
1011
+ // Catch errors thrown by the event constructors (like missing required fields)
1012
+ console.error(`MessageParser: Error creating event object for type "${eventType}":`, error);
1013
+ // Re-throw the parsing error with context
1014
+ if (error instanceof MessageParsingError) {
1015
+ throw error;
1016
+ } else {
1017
+ throw new MessageParsingError(`Failed to instantiate event object for type "${eventType}": ${error.message}`, error);
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ // --- Command Manager ---
1024
+
1025
+ /**
1026
+ * Manages registration and dispatching of command handlers.
1027
+ * A command handler is a function that executes specific logic in response to a command message.
1028
+ */
1029
+ class CommandManager {
1030
+ /**
1031
+ * Creates a CommandManager instance.
1032
+ */
1033
+ constructor() {
1034
+ /**
1035
+ * @private
1036
+ * @type {Object.<string, Function>} Stores command handler functions, keyed by the lowercase command name (without the leading '/').
1037
+ */
1038
+ this._commands = {};
1039
+ console.log('CommandManager initialized.');
1040
+ }
1041
+
1042
+ /**
1043
+ * Registers a handler function for a specific command name.
1044
+ * The command name should be provided WITHOUT the leading '/'. It is case-insensitive.
1045
+ * If a handler for the same command name is already registered, it will be overwritten.
1046
+ * @param {string} commandName - The name of the command (e.g., 'help', 'status', 'config'). Must be a non-empty string without '/'.
1047
+ * @param {Function} handler - The function to call when the command is received. This function should accept two arguments: `messageEvent` (the SeaTalkMessageEvent object) and `args` (an array of strings representing command arguments). The handler can be synchronous or asynchronous (return a Promise).
1048
+ * @throws {CommandRegistrationError} If the command name is invalid or the handler is not a function.
1049
+ */
1050
+ registerCommand(commandName, handler) {
1051
+ if (!isValidString(commandName)) {
1052
+ throw new CommandRegistrationError('Command name must be a non-empty string.');
1053
+ }
1054
+ if (commandName.startsWith('/')) {
1055
+ throw new CommandRegistrationError('Command name should not start with "/". Provide the name like "help", not "/help".');
1056
+ }
1057
+ if (typeof handler !== 'function') {
1058
+ throw new CommandRegistrationError(`Handler for command "${commandName}" must be a function.`);
1059
+ }
1060
+
1061
+ const normalizedCommandName = commandName.toLowerCase(); // Store command names lowercase
1062
+
1063
+ if (this._commands[normalizedCommandName]) {
1064
+ console.warn(`Command "${normalizedCommandName}" was already registered. Overwriting handler.`);
1065
+ }
1066
+
1067
+ this._commands[normalizedCommandName] = handler;
1068
+ console.log(`Command "${normalizedCommandName}" registered successfully.`);
1069
+ }
1070
+
1071
+ /**
1072
+ * Checks if a command is registered in the manager.
1073
+ * The input command name can optionally include the leading '/'. It is case-insensitive.
1074
+ * @param {string} commandName - The command name to check (e.g., 'help' or '/help').
1075
+ * @returns {boolean} True if a handler is registered for the given command name, false otherwise.
1076
+ */
1077
+ isCommandRegistered(commandName) {
1078
+ if (!isValidString(commandName)) {
1079
+ return false; // Invalid input is not a registered command
1080
+ }
1081
+ // Normalize the input command name for lookup
1082
+ const name = commandName.startsWith('/') ? commandName.substring(1) : commandName;
1083
+ return typeof this._commands[name.toLowerCase()] === 'function';
1084
+ }
1085
+
1086
+ /**
1087
+ * Dispatches a command to its registered handler function.
1088
+ * This method is called internally by the bot when a message is identified as a command.
1089
+ * It retrieves the handler and executes it with the message event and arguments.
1090
+ * @param {string} commandName - The name of the command to dispatch (WITHOUT the leading '/'). Case-insensitive matching is performed internally.
1091
+ * @param {string[]} args - An array of string arguments for the command.
1092
+ * @param {SeaTalkMessageEvent} messageEvent - The original message event object that triggered the command.
1093
+ * @returns {Promise<void>} A promise that resolves when the handler finishes execution (including async handlers), or rejects if the command is not registered or the handler throws an error.
1094
+ * @throws {CommandExecutionError} If the command is not registered or if the handler execution fails.
1095
+ */
1096
+ async dispatchCommand(commandName, args, messageEvent) {
1097
+ if (!isValidString(commandName)) {
1098
+ throw new CommandExecutionError('Invalid command name provided for dispatch.');
1099
+ }
1100
+ if (!Array.isArray(args)) {
1101
+ // Args should always be an array from parseCommand, but check for safety
1102
+ console.warn(`Command dispatch received non-array args for "${commandName}". Converting.`);
1103
+ args = []; // Default to empty array if invalid
1104
+ }
1105
+ if (!(messageEvent instanceof SeaTalkMessageEvent)) {
1106
+ throw new CommandExecutionError('Provided event is not a valid SeaTalkMessageEvent object for command dispatch.');
1107
+ }
1108
+
1109
+ const normalizedCommandName = commandName.toLowerCase(); // Look up using lowercase name
1110
+ const handler = this._commands[normalizedCommandName];
1111
+
1112
+ if (!handler) {
1113
+ // This scenario should ideally not be reached if dispatch is called after isCommandRegistered check.
1114
+ // It might happen if commands are unregistered dynamically between check and dispatch.
1115
+ console.error(`CommandManager: Attempted to dispatch unregistered command "${normalizedCommandName}".`);
1116
+ throw new CommandExecutionError(`No handler registered for command "${normalizedCommandName}".`);
1117
+ }
1118
+
1119
+ console.log(`CommandManager: Dispatching command "${normalizedCommandName}" for message ID ${messageEvent.messageId} with args: [${args.join(', ')}]`);
1120
+
1121
+ try {
1122
+ // Call the handler function. Use await Promise.resolve() to correctly handle
1123
+ // both synchronous and asynchronous (Promise-returning) handlers.
1124
+ await Promise.resolve(handler(messageEvent, args));
1125
+ console.log(`CommandManager: Command "${normalizedCommandName}" handler finished successfully.`);
1126
+ } catch (error) {
1127
+ // Catch errors thrown by the command handler
1128
+ console.error(`CommandManager: Error executing command "${normalizedCommandName}" handler:`, error);
1129
+ // Wrap and re-throw with context
1130
+ throw new CommandExecutionError(`Error executing command "${normalizedCommandName}".`, error);
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Returns a list of all command names that are currently registered.
1136
+ * Names are returned in lowercase without the leading '/'.
1137
+ * @returns {string[]} An array of registered command names. Returns an empty array if no commands are registered.
1138
+ */
1139
+ getRegisteredCommands() {
1140
+ return Object.keys(this._commands);
1141
+ }
1142
+ }
1143
+
1144
+ // --- SeaTalkBot Core Class ---
1145
+
1146
+ /**
1147
+ * The main class for the SeaTalk Bot.
1148
+ * It extends EventEmitter to allow emitting events for bot lifecycle,
1149
+ * incoming messages, commands, and errors.
1150
+ * Orchestrates configuration, webhook event processing, and command dispatching.
1151
+ */
1152
+ class SeaTalkBot extends EventEmitter {
1153
+ /**
1154
+ * Creates a SeaTalkBot instance.
1155
+ * Initializes configuration, message parser, and command manager.
1156
+ * Throws InvalidConfigurationError if the provided config is invalid.
1157
+ * @param {object} config - The bot configuration object. See BotConfiguration constructor for required fields.
1158
+ * @throws {InvalidConfigurationError} If the initial configuration is invalid.
1159
+ */
1160
+ constructor(config) {
1161
+ // Initialize the event emitter first
1162
+ super();
1163
+
1164
+ console.log('Initializing SeaTalkBot...');
1165
+
1166
+ try {
1167
+ /**
1168
+ * @private
1169
+ * @type {BotConfiguration} Manages bot configuration settings.
1170
+ */
1171
+ this._config = new BotConfiguration(config);
1172
+ } catch (error) {
1173
+ console.error('SeaTalkBot: Failed to initialize BotConfiguration:', error);
1174
+ // Configuration errors are critical and should prevent bot instantiation
1175
+ throw error; // Re-throw the InvalidConfigurationError
1176
+ }
1177
+
1178
+ /**
1179
+ * @private
1180
+ * @type {MessageParser} Handles parsing raw webhook payloads.
1181
+ */
1182
+ this._messageParser = new MessageParser();
1183
+
1184
+ /**
1185
+ * @private
1186
+ * @type {CommandManager} Manages and dispatches command handlers.
1187
+ */
1188
+ this._commandManager = new CommandManager();
1189
+
1190
+ // Internal state to track if the conceptual server is running
1191
+ /**
1192
+ * @private
1193
+ * @type {boolean} Indicates if the conceptual webhook server is running.
1194
+ */
1195
+ this._isRunning = false;
1196
+
1197
+ console.log('SeaTalkBot initialized successfully.');
1198
+ // Emit an event indicating that the bot instance has been successfully initialized
1199
+ this.emit('initialized');
1200
+ // Emit a 'ready' event, as the bot is now conceptually ready to handle processing requests
1201
+ // Note: In a real app, 'ready' might be emitted *after* the actual HTTP server starts listening.
1202
+ this.emit('ready');
1203
+ }
1204
+
1205
+ /**
1206
+ * Registers a command handler function with the bot's command manager.
1207
+ * This makes the bot respond to messages starting with `/commandName`.
1208
+ * @param {string} commandName - The name of the command (WITHOUT the leading '/'). Case-insensitive.
1209
+ * @param {Function} handler - The function to call when the command is received. It should accept `messageEvent` and `args` as parameters. Can be async.
1210
+ * @throws {CommandRegistrationError} If the command name or handler is invalid during registration.
1211
+ */
1212
+ registerCommand(commandName, handler) {
1213
+ try {
1214
+ this._commandManager.registerCommand(commandName, handler);
1215
+ // Emit an event after successful registration
1216
+ this.emit('command_registered', commandName);
1217
+ } catch (error) {
1218
+ console.error(`SeaTalkBot: Failed to register command "${commandName}":`, error);
1219
+ // Propagate the CommandRegistrationError
1220
+ throw error;
1221
+ }
1222
+ }
1223
+
1224
+ /**
1225
+ * Checks if the bot has a specific command registered by name.
1226
+ * Accepts command names with or without the leading '/'. Case-insensitive.
1227
+ * @param {string} commandName - The name of the command to check (e.g., 'help' or '/help').
1228
+ * @returns {boolean} True if the command is registered, false otherwise.
1229
+ */
1230
+ isCommandRegistered(commandName) {
1231
+ return this._commandManager.isCommandRegistered(commandName);
1232
+ }
1233
+
1234
+ /**
1235
+ * Gets a list of all command names currently registered with the bot.
1236
+ * Names are returned in lowercase without the leading '/'.
1237
+ * @returns {string[]} An array of registered command names.
1238
+ */
1239
+ getRegisteredCommands() {
1240
+ return this._commandManager.getRegisteredCommands();
1241
+ }
1242
+
1243
+ /**
1244
+ * Conceptual method to start a webhook server to receive events from SeaTalk.
1245
+ * In a real application, this would bind to a port and listen for HTTP POST requests.
1246
+ * Due to the standard library constraint, this is a placeholder that simulates the action
1247
+ * and state change, but does not create a real network server.
1248
+ * @fires SeaTalkBot#server_started
1249
+ * @fires SeaTalkBot#error on failure (conceptual)
1250
+ */
1251
+ startWebhookServer() {
1252
+ if (this._isRunning) {
1253
+ console.warn('SeaTalkBot: Conceptual webhook server is already conceptually running.');
1254
+ return;
1255
+ }
1256
+
1257
+ const port = this._config.getWebhookPort();
1258
+ console.log(`SeaTalkBot: Attempting to start conceptual webhook server on port ${port}...`);
1259
+
1260
+ // --- Placeholder for Actual Node.js HTTP Server Creation ---
1261
+ // const http = require('http'); // Requires Node.js 'http' module
1262
+ // try {
1263
+ // this._server = http.createServer((req, res) => {
1264
+ // // Read request body and headers, then call this.processWebhookEvent
1265
+ // let rawBody = '';
1266
+ // req.on('data', (chunk) => { rawBody += chunk.toString(); });
1267
+ // req.on('end', () => {
1268
+ // const signatureHeader = req.headers['x-signature'];
1269
+ // const timestampHeader = req.headers['x-timestamp'];
1270
+ // this.processWebhookEvent(rawBody, signatureHeader, timestampHeader)
1271
+ // .then(() => {
1272
+ // res.statusCode = 200;
1273
+ // res.setHeader('Content-Type', 'text/plain');
1274
+ // res.end('OK');
1275
+ // })
1276
+ // .catch((error) => {
1277
+ // console.error('Error processing webhook event:', error);
1278
+ // res.statusCode = error instanceof InvalidConfigurationError || error instanceof MessageParsingError || error instanceof ApiError && error.statusCode === 401 ? 400 : 500; // Use 400 for client/parsing errors, 401 for auth/sig failures, 500 for internal
1279
+ // res.setHeader('Content-Type', 'text/plain');
1280
+ // res.end(`Error: ${error.message}`); // Send a basic error message back
1281
+ // // Additional error handling/logging can happen in the 'error' event listener
1282
+ // });
1283
+ // });
1284
+ // req.on('error', (err) => {
1285
+ // console.error('Error reading incoming request:', err);
1286
+ // if (!res.headersSent) { // Prevent sending headers if already sent by end() in the catch block
1287
+ // res.statusCode = 500;
1288
+ // res.setHeader('Content-Type', 'text/plain');
1289
+ // res.end('Internal server error');
1290
+ // }
1291
+ // // Emit a bot error event
1292
+ // this.emit('error', new ApiError('Error reading incoming webhook request', 500, err));
1293
+ // });
1294
+ // });
1295
+ //
1296
+ // this._server.listen(port, () => {
1297
+ // console.log(`SeaTalkBot: Actual webhook server listening on port ${port}`);
1298
+ // this._isRunning = true;
1299
+ // this.emit('server_started', port); // Emit event for successful start
1300
+ // });
1301
+ //
1302
+ // this._server.on('error', (err) => {
1303
+ // console.error('SeaTalkBot: Actual webhook server error:', err);
1304
+ // this._isRunning = false; // Server failed or stopped due to error
1305
+ // // Emit a bot error event
1306
+ // this.emit('error', new ApiError(`Webhook server failed: ${err.message}`, null, err));
1307
+ // });
1308
+ //
1309
+ // } catch (err) {
1310
+ // console.error('SeaTalkBot: Could not create or start actual HTTP server:', err);
1311
+ // this._isRunning = false; // Ensure status is false
1312
+ // // Emit error event if server creation itself failed (unlikely with just listen)
1313
+ // this.emit('error', new ApiError('Could not create or start webhook server', null, err));
1314
+ // }
1315
+ // --- End Placeholder ---
1316
+
1317
+ // Simulate successful startup after a small delay to mimic asynchronous operations
1318
+ console.log(`SeaTalkBot: Simulating successful conceptual webhook server startup on port ${port}.`);
1319
+ this._isRunning = true;
1320
+ setTimeout(() => {
1321
+ /**
1322
+ * Server started event.
1323
+ * @event SeaTalkBot#server_started
1324
+ * @type {number} The port the server is listening on (conceptual).
1325
+ */
1326
+ this.emit('server_started', port);
1327
+ console.log('SeaTalkBot is now conceptually receiving webhook events.');
1328
+ }, 10); // Use a small timeout to make it async like a real server start
1329
+
1330
+ }
1331
+
1332
+ /**
1333
+ * Conceptual method to stop the webhook server.
1334
+ * Placeholder for real server shutdown logic (e.g., `server.close()`).
1335
+ * @fires SeaTalkBot#server_stopped
1336
+ */
1337
+ stopWebhookServer() {
1338
+ if (!this._isRunning) {
1339
+ console.warn('SeaTalkBot: Conceptual webhook server is not running.');
1340
+ return;
1341
+ }
1342
+
1343
+ console.log('SeaTalkBot: Attempting to stop conceptual webhook server...');
1344
+
1345
+ // --- Placeholder for Actual Node.js HTTP Server Shutdown ---
1346
+ // if (this._server) {
1347
+ // this._server.close(() => {
1348
+ // console.log('SeaTalkBot: Actual webhook server stopped.');
1349
+ // this._isRunning = false;
1350
+ // this.emit('server_stopped'); // Emit event for successful stop
1351
+ // this._server = null; // Clean up reference
1352
+ // });
1353
+ // this._server.on('error', (err) => {
1354
+ // console.error('SeaTalkBot: Error during server shutdown:', err);
1355
+ // // Emit error event if shutdown fails
1356
+ // this.emit('error', new ApiError('Webhook server shutdown failed', null, err));
1357
+ // });
1358
+ // } else {
1359
+ // console.warn('SeaTalkBot: No actual server instance found to stop.');
1360
+ // this._isRunning = false; // Ensure status is false
1361
+ // this.emit('server_stopped'); // Still emit event if state was inconsistent
1362
+ // }
1363
+ // --- End Placeholder ---
1364
+
1365
+ // Simulate successful shutdown after a small delay
1366
+ console.log('SeaTalkBot: Simulating successful conceptual webhook server shutdown.');
1367
+ this._isRunning = false;
1368
+ setTimeout(() => {
1369
+ /**
1370
+ * Server stopped event.
1371
+ * @event SeaTalkBot#server_stopped
1372
+ */
1373
+ this.emit('server_stopped');
1374
+ console.log('SeaTalkBot webhook server is conceptually stopped.');
1375
+ }, 10); // Use a small timeout
1376
+ }
1377
+
1378
+ /**
1379
+ * Processes a raw webhook event payload received from SeaTalk.
1380
+ * This is the core method that validates the request, parses the payload,
1381
+ * identifies the event type, and dispatches it to appropriate handlers or command managers.
1382
+ * Intended to be called by your webhook server's request handler.
1383
+ * @param {string} rawBody - The raw request body string received from SeaTalk.
1384
+ * @param {string} signatureHeader - The value of the 'X-Signature' HTTP header.
1385
+ * @param {string} timestampHeader - The value of the 'X-Timestamp' HTTP header.
1386
+ * @returns {Promise<void>} A promise that resolves when processing is complete, or rejects on error. The caller (your server handler) should catch rejections and send appropriate HTTP error responses (e.g., 400, 401, 500).
1387
+ * @fires SeaTalkBot#event
1388
+ * @fires SeaTalkBot#message
1389
+ * @fires SeaTalkBot#command
1390
+ * @fires SeaTalkBot#unregistered_command
1391
+ * @fires SeaTalkBot#event_[eventType] (e.g., event_message, event_callback)
1392
+ * @fires SeaTalkBot#message_[messageType] (e.g., message_text, message_image)
1393
+ * @fires SeaTalkBot#signature_verification_failed
1394
+ * @fires SeaTalkBot#token_verification_failed
1395
+ * @fires SeaTalkBot#parsing_error
1396
+ * @fires SeaTalkBot#validation_failed
1397
+ * @fires SeaTalkBot#error
1398
+ */
1399
+ async processWebhookEvent(rawBody, signatureHeader, timestampHeader) {
1400
+ console.log('SeaTalkBot: Starting processing of incoming webhook event...');
1401
+ // console.debug('Raw Body (first 100 chars):', rawBody ? rawBody.substring(0, 100) + (rawBody.length > 100 ? '...' : '') : '[empty]'); // Log safely
1402
+
1403
+ // 1. Validate Signature (Simulated)
1404
+ const appSecret = this._config.getAppSecret();
1405
+ const isSignatureValid = simulateSignatureVerification(rawBody, signatureHeader, timestampHeader, appSecret);
1406
+
1407
+ if (!isSignatureValid) {
1408
+ console.error('SeaTalkBot: Webhook signature verification failed.');
1409
+ /**
1410
+ * Emitted when the webhook signature verification fails.
1411
+ * @event SeaTalkBot#signature_verification_failed
1412
+ * @type {object} Context containing the raw request details.
1413
+ * @property {string} rawBody - The raw body of the request.
1414
+ * @property {string} signatureHeader - The received X-Signature header.
1415
+ * @property {string} timestampHeader - The received X-Timestamp header.
1416
+ */
1417
+ this.emit('signature_verification_failed', {
1418
+ rawBody,
1419
+ signatureHeader,
1420
+ timestampHeader
1421
+ });
1422
+ // Throw an ApiError with a 401 status code as signature failure implies unauthorized request
1423
+ throw new ApiError('Signature verification failed. Request rejected.', 401);
1424
+ }
1425
+ console.log('SeaTalkBot: Webhook signature verification simulated successfully.');
1426
+
1427
+
1428
+ // 2. Parse Payload
1429
+ let event;
1430
+ try {
1431
+ // Use the message parser to convert raw body to a structured event object
1432
+ event = this._messageParser.parseWebhookPayload(rawBody);
1433
+
1434
+ // If parser returns null (e.g., empty body), we just finish processing without error or event emission
1435
+ if (event === null) {
1436
+ console.log('SeaTalkBot: Payload parsed as null (likely empty body). Finishing processing.');
1437
+ return; // Stop processing
1438
+ }
1439
+
1440
+ // Validate verification token received in the payload against the configured one
1441
+ if (event.token !== this._config.getVerificationToken()) {
1442
+ console.error(`SeaTalkBot: Verification token mismatch. Configured: ${this._config.getVerificationToken()}, Received: ${event.token}`);
1443
+ /**
1444
+ * Emitted when the webhook verification token in the payload body does not match the configured token.
1445
+ * @event SeaTalkBot#token_verification_failed
1446
+ * @type {object} Context including received/expected tokens and the parsed event.
1447
+ * @property {string} receivedToken - The token from the payload.
1448
+ * @property {string} expectedToken - The configured token.
1449
+ * @property {SeaTalkEvent} event - The parsed event object.
1450
+ */
1451
+ this.emit('token_verification_failed', {
1452
+ receivedToken: event.token,
1453
+ expectedToken: this._config.getVerificationToken(),
1454
+ event
1455
+ });
1456
+ // Token mismatch implies unauthorized request
1457
+ throw new ApiError('Verification token mismatch. Request rejected.', 401);
1458
+ }
1459
+ console.log('SeaTalkBot: Verification token matched.');
1460
+
1461
+ // Perform basic validation on the parsed event object
1462
+ if (!event.isValid()) {
1463
+ console.error('SeaTalkBot: Parsed event object failed basic validation after token check.');
1464
+ /**
1465
+ * Emitted when a parsed event object fails basic validation checks.
1466
+ * @event SeaTalkBot#validation_failed
1467
+ * @type {SeaTalkEvent} The parsed event object.
1468
+ */
1469
+ this.emit('validation_failed', event);
1470
+ throw new MessageParsingError(`Parsed event object of type "${event.eventType}" is structurally invalid.`);
1471
+ }
1472
+
1473
+
1474
+ } catch (error) {
1475
+ // Catch errors during JSON parsing or event object instantiation/basic validation
1476
+ console.error('SeaTalkBot: Error parsing or validating webhook payload:', error);
1477
+ /**
1478
+ * Emitted when there is an error parsing the raw webhook payload or validating its basic structure.
1479
+ * @event SeaTalkBot#parsing_error
1480
+ * @type {Error} The parsing error.
1481
+ * @property {string} rawBody - The raw body that caused the error.
1482
+ */
1483
+ this.emit('parsing_error', error, rawBody);
1484
+ // Re-throw as MessageParsingError or wrap existing error, use 400 status code
1485
+ if (error instanceof MessageParsingError) {
1486
+ throw error; // Already a parsing error
1487
+ } else if (error instanceof ApiError && error.statusCode === 401) {
1488
+ throw error; // Re-throw 401 token error
1489
+ }
1490
+ else {
1491
+ // Wrap any other unexpected parsing/validation error
1492
+ throw new MessageParsingError(`Failed to parse or validate event payload: ${error.message}`, error);
1493
+ }
1494
+ }
1495
+
1496
+ // 3. Dispatch Event based on Type
1497
+ console.log(`SeaTalkBot: Dispatching event of type: ${event.eventType}`);
1498
+
1499
+ /**
1500
+ * Emitted for any incoming webhook event after successful parsing and token verification.
1501
+ * @event SeaTalkBot#event
1502
+ * @type {SeaTalkEvent} The parsed event object.
1503
+ */
1504
+ this.emit('event', event);
1505
+
1506
+ /**
1507
+ * Emitted for incoming events, specific to their event type.
1508
+ * The event name will be `event_[eventType]`.
1509
+ * @event SeaTalkBot#event_[eventType]
1510
+ * @type {SeaTalkEvent} The parsed event object.
1511
+ */
1512
+ this.emit(`event_${event.eventType}`, event);
1513
+
1514
+
1515
+ switch (event.eventType) {
1516
+ case 'message':
1517
+ // Handle incoming message events
1518
+ // Ensure it's a valid SeaTalkMessageEvent instance and passes its specific validations
1519
+ if (event instanceof SeaTalkMessageEvent && event.isMessageValid()) {
1520
+ console.log(`SeaTalkBot: Handling incoming message from ${event.senderUserId} in chat ${event.openChatId}, type ${event.messageType}.`);
1521
+ /**
1522
+ * Emitted specifically for incoming message events.
1523
+ * @event SeaTalkBot#message
1524
+ * @type {SeaTalkMessageEvent} The parsed message event object.
1525
+ */
1526
+ this.emit('message', event);
1527
+
1528
+ // Emit specific message type event (e.g., 'message_text', 'message_image')
1529
+ /**
1530
+ * Emitted for incoming messages, specific to their message type.
1531
+ * The event name will be `message_[messageType]`.
1532
+ * @event SeaTalkBot#message_[messageType]
1533
+ * @type {SeaTalkMessageEvent} The parsed message event object.
1534
+ */
1535
+ this.emit(`message_${event.messageType}`, event);
1536
+
1537
+ // Process message content for commands if it's a text message
1538
+ if (event.messageType === 'text') {
1539
+ const messageText = event.getTextContent();
1540
+ if (messageText !== null) {
1541
+ const commandInfo = parseCommand(messageText); // Use the utility to parse
1542
+
1543
+ if (commandInfo.command !== null) {
1544
+ // It looks like a command (starts with '/')
1545
+ console.log(`SeaTalkBot: Message identified as potential command: "${commandInfo.command}"`);
1546
+ if (this._commandManager.isCommandRegistered(commandInfo.command)) {
1547
+ console.log(`SeaTalkBot: Command "${commandInfo.command}" is registered. Dispatching.`);
1548
+ /**
1549
+ * Emitted when a message is identified as a registered command.
1550
+ * @event SeaTalkBot#command
1551
+ * @type {string} The command name (lowercase, no '/').
1552
+ * @type {string[]} The command arguments.
1553
+ * @type {SeaTalkMessageEvent} The original message event.
1554
+ */
1555
+ this.emit('command', commandInfo.command, commandInfo.args, event);
1556
+
1557
+ try {
1558
+ // Dispatch to the registered command handler. Await the handler's completion.
1559
+ await this._commandManager.dispatchCommand(commandInfo.command, commandInfo.args, event);
1560
+ console.log(`SeaTalkBot: Command "${commandInfo.command}" dispatched and handled.`);
1561
+ } catch (commandError) {
1562
+ // Catch errors thrown by the command handler
1563
+ console.error(`SeaTalkBot: Error during command "${commandInfo.command}" execution:`, commandError);
1564
+ // Emit a bot error event for handler errors
1565
+ this.emit('error', new CommandExecutionError(`Error handling command "${commandInfo.command}"`, commandError), { event, commandInfo });
1566
+ // Depending on desired behavior, you might re-throw or handle differently
1567
+ // Re-throw to potentially signal failure back to the conceptual server handler
1568
+ throw new CommandExecutionError(`Error executing command "${commandInfo.command}".`, commandError);
1569
+ }
1570
+ } else {
1571
+ // Command syntax used, but command is not registered
1572
+ console.warn(`SeaTalkBot: Received unregistered command: "${commandInfo.command}"`);
1573
+ /**
1574
+ * Emitted when a message starts with '/' but does not match a registered command.
1575
+ * @event SeaTalkBot#unregistered_command
1576
+ * @type {string} The command name (lowercase, no '/').
1577
+ * @type {string[]} The command arguments.
1578
+ * @type {SeaTalkMessageEvent} The original message event.
1579
+ */
1580
+ this.emit('unregistered_command', commandInfo.command, commandInfo.args, event);
1581
+ // Optionally, reply to the user that the command is not found
1582
+ // This would involve calling sendMessage, which is conceptual here.
1583
+ // Example: await this.sendMessage(event.openChatId, `Error: Command "${commandInfo.command}" not found.`);
1584
+ }
1585
+ } else {
1586
+ // The message is text but not a command (doesn't start with '/')
1587
+ console.log('SeaTalkBot: Message is text but not a command.');
1588
+ // You can add logic here to handle non-command text messages,
1589
+ // e.g., simple keyword responses, AI processing, etc.
1590
+ // Or let listeners on the 'message' or 'message_text' event handle it.
1591
+ }
1592
+ } else {
1593
+ // messageType was 'text', but getTextContent returned null (content.text missing or invalid)
1594
+ console.warn('SeaTalkBot: Received text message event with invalid text content structure.');
1595
+ // Optionally emit a more specific validation failure event
1596
+ this.emit('validation_failed', event);
1597
+ }
1598
+ } else {
1599
+ // Message type is not 'text'
1600
+ console.log(`SeaTalkBot: Message type is "${event.messageType}", no command processing needed.`);
1601
+ // Logic for handling non-text messages goes here or is handled by listeners
1602
+ // on the specific message type event ('message_image', etc.) or the general 'message' event.
1603
+ }
1604
+
1605
+ } else {
1606
+ // The event was type 'message', but the SeaTalkMessageEvent instance failed its detailed validation
1607
+ console.warn('SeaTalkBot: Received message event failed specific SeaTalkMessageEvent validation.', event);
1608
+ // Emit a validation failure event specifically for messages
1609
+ this.emit('validation_failed', event);
1610
+ // Do not process this invalid message further
1611
+ }
1612
+ break;
1613
+
1614
+ // Add cases for handling other specific SeaTalk event types here:
1615
+ // case 'callback':
1616
+ // // Handle incoming callback events (e.g., button clicks)
1617
+ // console.log('SeaTalkBot: Handling incoming callback event...');
1618
+ // // Ensure it's a valid callback event instance and passes its specific validations
1619
+ // if (event instanceof SeaTalkCallbackEvent && event.isValidCallback()) {
1620
+ // this.emit('callback', event); // Emit a specific callback event
1621
+ // // Add callback handling logic here or in listeners
1622
+ // } else {
1623
+ // console.warn('SeaTalkBot: Received callback event failed specific validation.', event);
1624
+ // this.emit('validation_failed', event);
1625
+ // }
1626
+ // break;
1627
+
1628
+ // case 'add_bot':
1629
+ // // Handle bot added to chat event
1630
+ // console.log('SeaTalkBot: Handling bot added event...');
1631
+ // if (event instanceof SeaTalkAddBotEvent && event.isValidAddBot()) {
1632
+ // this.emit('add_bot', event); // Emit a specific add_bot event
1633
+ // // Logic like sending a welcome message could go here or in listeners
1634
+ // } else {
1635
+ // console.warn('SeaTalkBot: Received add_bot event failed specific validation.', event);
1636
+ // this.emit('validation_failed', event);
1637
+ // }
1638
+ // break;
1639
+
1640
+ default:
1641
+ // For any other event type not explicitly handled above
1642
+ console.log(`SeaTalkBot: Received unhandled specific event type: ${event.eventType}. Generic event already emitted.`);
1643
+ // Additional processing for unhandled types can go here or be handled by listeners
1644
+ // on the general 'event' or specific 'event_[eventType]' events.
1645
+ break;
1646
+ }
1647
+
1648
+ console.log('SeaTalkBot: Webhook event processing finished.');
1649
+ }
1650
+
1651
+ /**
1652
+ * Conceptual method to send a text message to a specific chat ID in SeaTalk.
1653
+ * In a real application, this involves obtaining an access token (if not cached)
1654
+ * and making an HTTP POST request to the SeaTalk Send Message API endpoint.
1655
+ * Due to the standard library constraint, this is a placeholder simulation
1656
+ * that logs the intended action and returns a simulated response.
1657
+ * @param {string} chatId - The open_chat_id of the conversation to send the message to. Must be a non-empty string.
1658
+ * @param {string} text - The text content of the message to send. Must be a non-empty string.
1659
+ * @returns {Promise<object>} A promise resolving with a conceptual API success response object `{ code: 0, message: 'success', data: { ... } }` or rejecting with an `ApiError` on conceptual failure.
1660
+ * @throws {ApiError} If the chat ID or text is invalid, or if the conceptual API call fails.
1661
+ */
1662
+ async sendMessage(chatId, text) {
1663
+ // Basic input validation
1664
+ if (!isValidString(chatId)) {
1665
+ console.error('SeaTalkBot: Cannot send message: Invalid chat ID provided.');
1666
+ throw new ApiError('Cannot send message: Invalid chat ID. Must be a non-empty string.');
1667
+ }
1668
+ if (!isValidString(text)) {
1669
+ console.error('SeaTalkBot: Cannot send message: Text content is empty or invalid.');
1670
+ // Allow empty text if SeaTalk API allows? Usually, text messages must have content.
1671
+ throw new ApiError('Cannot send message: Text content must be a non-empty string.');
1672
+ }
1673
+
1674
+ console.log(`SeaTalkBot: Simulating sending text message to chat "${chatId}" with content: "${text.substring(0, 50) + (text.length > 50 ? '...' : '')}"`);
1675
+
1676
+ const apiEndpoint = `${this._config.getApiBaseUrl()}/message/send`;
1677
+ const payload = {
1678
+ open_chat_id: chatId,
1679
+ message_type: 'text',
1680
+ content: {
1681
+ text: text
1682
+ }
1683
+ // Add other necessary fields based on SeaTalk API for sending messages
1684
+ // e.g., 'msg_uuid' for idempotency, 'user_id' for P2P chat if needed differently
1685
+ };
1686
+
1687
+ // --- Placeholder for Actual HTTP POST request using Node.js http/https or fetch ---
1688
+ // In a real scenario, you would typically:
1689
+ // 1. Call an internal method like `_getAccessToken()` to get a valid token.
1690
+ // 2. Make an HTTP POST request using `fetch` or Node.js `https.request`.
1691
+ // 3. Set headers: `Content-Type: application/json`, `Authorization: Bearer <AccessToken>`.
1692
+ // 4. Send `JSON.stringify(payload)` as the request body.
1693
+ // 5. Handle the API response (check status code, parse JSON body, check SeaTalk API `code` field).
1694
+
1695
+ /*
1696
+ try {
1697
+ const accessToken = await this._getAccessToken(); // Assumes _getAccessToken exists and works
1698
+
1699
+ const response = await fetch(apiEndpoint, { // Requires 'node-fetch' or similar in Node.js context
1700
+ method: 'POST',
1701
+ headers: {
1702
+ 'Content-Type': 'application/json',
1703
+ 'Authorization': `Bearer ${accessToken}` // Use the obtained token
1704
+ },
1705
+ body: JSON.stringify(payload)
1706
+ });
1707
+
1708
+ const responseBody = await response.json(); // Parse the JSON response body
1709
+
1710
+ // Check HTTP status code first
1711
+ if (!response.ok) {
1712
+ console.error(`SeaTalkBot: API HTTP error sending message to ${chatId}: Status ${response.status}`, responseBody);
1713
+ // Throw an ApiError including the HTTP status and response body details
1714
+ throw new ApiError(`API call failed to send message (HTTP Status: ${response.status})`, response.status, responseBody);
1715
+ }
1716
+
1717
+ // Check SeaTalk API specific code for success
1718
+ if (responseBody.code !== 0) {
1719
+ console.error(`SeaTalkBot: SeaTalk API error sending message to ${chatId}: Code ${responseBody.code}`, responseBody);
1720
+ // Throw an ApiError based on SeaTalk's response structure
1721
+ throw new ApiError(`SeaTalk API returned error code ${responseBody.code} when sending message`, response.status, responseBody); // Include HTTP status even for API code errors
1722
+ }
1723
+
1724
+
1725
+ console.log(`SeaTalkBot: Message sent successfully to ${chatId}. Conceptual response data:`, responseBody.data);
1726
+ return responseBody; // Return the full API success response object
1727
+
1728
+ } catch (error) {
1729
+ // Catch network errors or errors thrown by fetch/request or parsing
1730
+ console.error(`SeaTalkBot: Error during conceptual sendMessage API call to ${apiEndpoint}:`, error);
1731
+ // Re-throw as ApiError if not already, preserving context
1732
+ if (error instanceof ApiError) {
1733
+ throw error; // Already an ApiError, just re-throw
1734
+ } else {
1735
+ // Wrap general errors (network issues, etc.) in an ApiError
1736
+ throw new ApiError(`Conceptual API call failed to send message: ${error.message}`, null, error);
1737
+ }
1738
+ }
1739
+ */
1740
+ // --- End Placeholder ---
1741
+
1742
+
1743
+ // Simulate a successful API response after a short delay
1744
+ // This promise simulates the asynchronous nature of an HTTP request.
1745
+ return new Promise((resolve, reject) => {
1746
+ setTimeout(() => {
1747
+ console.log('SeaTalkBot: Simulated API call for sendMessage completed.');
1748
+ // Simulate successful response structure according to SeaTalk API docs
1749
+ const simulatedResponse = {
1750
+ code: 0, // SeaTalk API success code
1751
+ message: 'success',
1752
+ data: {
1753
+ // Example data for a sent message response
1754
+ message_id: `simulated_msg_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`, // Simulate a unique message ID
1755
+ open_message_id: `simulated_om_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
1756
+ create_time: String(getCurrentTimestampInSeconds())
1757
+ }
1758
+ };
1759
+ console.log('SeaTalkBot: Simulating successful message send response:', simulatedResponse);
1760
+ resolve(simulatedResponse); // Resolve the promise with the simulated success response
1761
+
1762
+ // --- Example of how to simulate an API error ---
1763
+ /*
1764
+ const simulatedErrorResponse = {
1765
+ code: 1001, // Example SeaTalk error code
1766
+ message: 'Simulated API error: Invalid chat ID',
1767
+ details: { field: 'open_chat_id', reason: 'not_found' }
1768
+ };
1769
+ console.error('SeaTalkBot: Simulating API call failure:', simulatedErrorResponse);
1770
+ // Reject the promise with an ApiError
1771
+ reject(new ApiError('Simulated SeaTalk API Error sending message', 400, simulatedErrorResponse)); // Use an appropriate conceptual HTTP status code
1772
+ */
1773
+
1774
+ }, 50); // Simulate a small network delay (50 milliseconds)
1775
+ });
1776
+ }
1777
+
1778
+ /**
1779
+ * Conceptual method to obtain an Access Token from the SeaTalk API.
1780
+ * In a real application, this makes an HTTP POST request with App ID and App Secret.
1781
+ * Tokens are typically valid for a limited time and should be cached.
1782
+ * Due to the standard library constraint, this is a placeholder simulation.
1783
+ * @private
1784
+ * @returns {Promise<string>} A promise resolving with the access token string on conceptual success.
1785
+ * @throws {ApiError} If the conceptual API call for the token fails (e.g., invalid credentials simulation).
1786
+ */
1787
+ async _getAccessToken() {
1788
+ console.log('SeaTalkBot: Simulating getting Access Token...');
1789
+
1790
+ const tokenEndpoint = `${this._config.getApiBaseUrl()}/auth/access_token`;
1791
+ const appId = this._config.getAppId();
1792
+ const appSecret = this._config.getAppSecret();
1793
+
1794
+ // Validate that we conceptually have credentials to request a token
1795
+ if (!isValidString(appId) || !isValidString(appSecret)) {
1796
+ console.error('SeaTalkBot: Cannot get access token: App ID or App Secret is missing in configuration.');
1797
+ throw new ApiError('Cannot get access token: Missing required appId or appSecret in configuration.', null, { appId: appId, appSecret: appSecret ? '[PRESENT]' : '[MISSING]' });
1798
+ }
1799
+
1800
+
1801
+ // --- Placeholder for Actual HTTP POST request using fetch or Node.js http/https ---
1802
+ /*
1803
+ try {
1804
+ const response = await fetch(tokenEndpoint, { // Requires 'node-fetch' or similar
1805
+ method: 'POST',
1806
+ headers: {
1807
+ 'Content-Type': 'application/json'
1808
+ },
1809
+ body: JSON.stringify({
1810
+ app_id: appId,
1811
+ app_secret: appSecret
1812
+ })
1813
+ });
1814
+
1815
+ const responseBody = await response.json();
1816
+
1817
+ // Check HTTP status code
1818
+ if (!response.ok) {
1819
+ console.error(`SeaTalkBot: API HTTP error getting access token: Status ${response.status}`, responseBody);
1820
+ throw new ApiError(`API call failed to get access token (HTTP Status: ${response.status})`, response.status, responseBody);
1821
+ }
1822
+
1823
+ // Check SeaTalk API specific code and data structure for the token
1824
+ if (responseBody.code !== 0 || !isValidObject(responseBody.data) || !isValidString(responseBody.data.access_token)) {
1825
+ console.error(`SeaTalkBot: SeaTalk API error getting access token: Code ${responseBody.code}`, responseBody);
1826
+ throw new ApiError(`SeaTalk API returned error code ${responseBody.code} or invalid data getting access token`, response.status, responseBody);
1827
+ }
1828
+
1829
+ console.log('SeaTalkBot: Access Token obtained successfully.');
1830
+ // In a real implementation, you would cache responseBody.data.access_token
1831
+ // and responseBody.data.expires_in, and manage token refreshing.
1832
+ return responseBody.data.access_token;
1833
+
1834
+ } catch (error) {
1835
+ console.error(`SeaTalkBot: Error during conceptual _getAccessToken API call to ${tokenEndpoint}:`, error);
1836
+ if (error instanceof ApiError) {
1837
+ throw error;
1838
+ } else {
1839
+ throw new ApiError(`Conceptual API call failed to get access token: ${error.message}`, null, error);
1840
+ }
1841
+ }
1842
+ */
1843
+ // --- End Placeholder ---
1844
+
1845
+ // Simulate a successful response after a small delay
1846
+ return new Promise((resolve, reject) => {
1847
+ setTimeout(() => {
1848
+ console.log('SeaTalkBot: Simulated API call for Access Token completed.');
1849
+ // Simulate a successful response structure
1850
+ const simulatedResponse = {
1851
+ code: 0,
1852
+ message: 'success',
1853
+ data: {
1854
+ access_token: `simulated_token_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
1855
+ expires_in: 7200 // 2 hours validity
1856
+ }
1857
+ };
1858
+ console.log('SeaTalkBot: Simulating successful Access Token retrieval.');
1859
+ resolve(simulatedResponse.data.access_token); // Resolve with the simulated token string
1860
+
1861
+ // --- Example of how to simulate an API error for token retrieval ---
1862
+ /*
1863
+ const simulatedErrorResponse = {
1864
+ code: 10001, // Example auth error code
1865
+ message: 'Simulated invalid app_secret',
1866
+ details: {}
1867
+ };
1868
+ console.error('SeaTalkBot: Simulating API call failure for token:', simulatedErrorResponse);
1869
+ reject(new ApiError('Simulated SeaTalk API Error getting token', 401, simulatedErrorResponse)); // Use 401 for auth failures
1870
+ */
1871
+
1872
+ }, 50); // Simulate a small network delay
1873
+ });
1874
+ }
1875
+
1876
+
1877
+ /**
1878
+ * Gets the current running status of the conceptual webhook server.
1879
+ * @returns {boolean} True if `startWebhookServer` has been called and `stopWebhookServer` has not, false otherwise.
1880
+ */
1881
+ isRunning() {
1882
+ return this._isRunning;
1883
+ }
1884
+
1885
+ /**
1886
+ * Provides access to the bot's configuration, with sensitive details hidden.
1887
+ * @returns {object} A sanitized version of the bot configuration object.
1888
+ */
1889
+ getConfig() {
1890
+ return this._config.getSanitizedConfig();
1891
+ }
1892
+
1893
+ /**
1894
+ * Provides a helper method to structure a basic text message content object.
1895
+ * Useful when constructing payloads for methods like `sendMessage`.
1896
+ * @param {string} text - The text content for the message.
1897
+ * @returns {object|null} A message content object `{ text: string }` or null if the input text is invalid.
1898
+ */
1899
+ createTextContent(text) {
1900
+ if (!isValidString(text)) {
1901
+ console.warn('SeaTalkBot: createTextContent received invalid or empty text.');
1902
+ return null;
1903
+ }
1904
+ return {
1905
+ text: text
1906
+ };
1907
+ }
1908
+
1909
+ // Add more helper methods for creating content objects for other message types
1910
+ // /**
1911
+ // * Helper to structure a basic card message content object.
1912
+ // * @param {object} cardData - The data structure for the card according to SeaTalk API.
1913
+ // * @returns {object|null} A message content object `{ card: object }` or null if cardData is invalid.
1914
+ // */
1915
+ // createCardContent(cardData) {
1916
+ // if (!isValidObject(cardData)) {
1917
+ // console.warn('SeaTalkBot: createCardContent received invalid card data.');
1918
+ // return null;
1919
+ // }
1920
+ // // Basic check: Card data often has a 'config' and 'elements' property
1921
+ // if (!isValidObject(cardData.config) || !isValidArray(cardData.elements)) {
1922
+ // console.warn('SeaTalkBot: createCardContent received card data missing config or elements array.');
1923
+ // // Return null or return the data as is? Let's be strict and return null for invalid structure
1924
+ // return null;
1925
+ // }
1926
+ // return {
1927
+ // card: cardData
1928
+ // };
1929
+ // }
1930
+
1931
+ // Add placeholder methods for other potential API interactions (conceptual)
1932
+ // async getUserInfo(userId) { /* ... conceptual API call to /user/get ... */ }
1933
+ // async getChatInfo(chatId) { /* ... conceptual API call to /chat/get ... */ }
1934
+
1935
+
1936
+ /**
1937
+ * Provides a way to conceptually simulate an incoming webhook request
1938
+ * for testing purposes within this standard library constraint.
1939
+ * This method simulates receiving a raw body and headers and passes them
1940
+ * directly to the internal `processWebhookEvent` method, bypassing the need
1941
+ * for an actual HTTP server listener.
1942
+ * @param {string} rawBody - The raw JSON string payload simulating the request body.
1943
+ * @param {object} [headers={}] - A conceptual headers object simulating request headers. Should include 'x-signature' and 'x-timestamp'.
1944
+ * @returns {Promise<void>} A promise resolving after the simulated processing is complete, or rejecting on error if `processWebhookEvent` throws.
1945
+ * @fires SeaTalkBot#error if `processWebhookEvent` throws an error.
1946
+ */
1947
+ async simulateWebhookRequest(rawBody, headers = {}) {
1948
+ console.log('\n--- SeaTalkBot Simulation: Starting Incoming Webhook Request ---');
1949
+ console.log('Simulated Headers:', headers);
1950
+ // console.log('Simulated Raw Body:', rawBody); // Be careful with logging sensitive data
1951
+
1952
+ // Simulate the processWebhookEvent call that a real server handler would make
1953
+ try {
1954
+ // Call the core processing method directly
1955
+ await this.processWebhookEvent(rawBody, headers['x-signature'], headers['x-timestamp']);
1956
+ console.log('SeaTalkBot Simulation: Processing successful.');
1957
+ // Simulate sending a 200 OK response conceptually
1958
+ console.log('SeaTalkBot Simulation: Conceptual Response -> 200 OK');
1959
+
1960
+ } catch (error) {
1961
+ // Catch errors thrown by processWebhookEvent (validation, parsing, signature, token, command handler errors)
1962
+ console.error('SeaTalkBot Simulation: Processing failed:', error);
1963
+ // Determine a conceptual HTTP status code based on the error type
1964
+ const statusCode = error instanceof InvalidConfigurationError || error instanceof MessageParsingError ? 400 : // Client/parsing error
1965
+ error instanceof ApiError && error.statusCode === 401 ? 401 : // Authentication/Signature/Token error
1966
+ 500; // Any other unexpected error (CommandExecutionError, etc.)
1967
+ console.log(`SeaTalkBot Simulation: Conceptual Response -> ${statusCode} Error: ${error.message}`);
1968
+ // Re-throw the error so the caller of simulateWebhookRequest can handle it if needed
1969
+ throw error;
1970
+ } finally {
1971
+ console.log('--- SeaTalkBot Simulation: End Incoming Webhook Request ---');
1972
+ }
1973
+ }
1974
+
1975
+ } // End of SeaTalkBot class
1976
+
1977
+
1978
+ // --- Exports ---
1979
+
1980
+ /**
1981
+ * The main export of the package is the SeaTalkBot class.
1982
+ * Custom error classes are also exported to allow users to catch specific error types.
1983
+ * Other utility classes or functions could be exported if intended for public use,
1984
+ * but are kept internal by default unless explicitly added here.
1985
+ */
1986
+ module.exports = {
1987
+ SeaTalkBot,
1988
+ InvalidConfigurationError,
1989
+ MessageParsingError,
1990
+ CommandExecutionError,
1991
+ ApiError,
1992
+ // Optionally export event/message structures or managers if the user needs to work with them directly
1993
+ // SeaTalkEvent,
1994
+ // SeaTalkMessageEvent,
1995
+ // MessageParser, // Useful for testing raw payloads
1996
+ // CommandManager // Useful for external command management or introspection
1997
+ };
1998
+
1999
+ // --- Example Usage (Commented Out) ---
2000
+ // This section demonstrates how the exported SeaTalkBot class might be used.
2001
+ // It is commented out so the file only contains the library code.
2002
+ /*
2003
+ const botConfigExample = {
2004
+ appId: 'YOUR_SEATALK_APP_ID', // Replace with your actual App ID
2005
+ appSecret: 'YOUR_SEATALK_APP_SECRET', // Replace with your actual App Secret
2006
+ verificationToken: 'YOUR_SEATALK_VERIFICATION_TOKEN', // Replace with your actual Verification Token
2007
+ // webhookPort: 8080 // Optional, defaults to 8080
2008
+ };
2009
+
2010
+ // To run this example, you would uncomment the following code block
2011
+ // and replace placeholder values with your actual SeaTalk bot credentials.
2012
+ // Note that this example uses the simulateWebhookRequest method
2013
+ // and conceptual sendMessage/getAccessToken methods due to the
2014
+ // "standard library only" constraint. In a real app, you would
2015
+ // use a Node.js HTTP server and actual API calls.
2016
+
2017
+ try {
2018
+ // Create a new bot instance
2019
+ const bot = new SeaTalkBot(botConfigExample);
2020
+
2021
+ // --- Register Command Handlers ---
2022
+ // Define functions that will run when specific commands are received.
2023
+ bot.registerCommand('echo', async (messageEvent, args) => {
2024
+ console.log(`[Handler: /echo] Received command from ${messageEvent.senderUserId}. Args: ${args.join(' ')}`);
2025
+ const textToEcho = args.length > 0 ? args.join(' ') : 'Nothing to echo!';
2026
+ const responseText = `Echo: ${textToEcho}`;
2027
+ try {
2028
+ // Use the conceptual sendMessage method
2029
+ await bot.sendMessage(messageEvent.openChatId, responseText);
2030
+ console.log(`[Handler: /echo] Sent echo response to ${messageEvent.openChatId}.`);
2031
+ } catch (error) {
2032
+ console.error(`[Handler: /echo] Failed to send echo response:`, error);
2033
+ // The bot's 'error' event listener will also catch this ApiError
2034
+ }
2035
+ });
2036
+
2037
+ bot.registerCommand('help', async (messageEvent, args) => {
2038
+ console.log(`[Handler: /help] Received command from ${messageEvent.senderUserId}.`);
2039
+ const commands = bot.getRegisteredCommands().map(cmd => `/${cmd}`).join(', ');
2040
+ const responseText = `Hello! I'm a SeaTalk Bot. Available commands: ${commands}.`;
2041
+ try {
2042
+ await bot.sendMessage(messageEvent.openChatId, responseText);
2043
+ console.log(`[Handler: /help] Sent help response to ${messageEvent.openChatId}.`);
2044
+ } catch (error) {
2045
+ console.error(`[Handler: /help] Failed to send help response:`, error);
2046
+ // The bot's 'error' event listener will also catch this ApiError
2047
+ }
2048
+ });
2049
+
2050
+ // You can register more commands here...
2051
+ // bot.registerCommand('status', async (messageEvent, args) => { ... });
2052
+
2053
+
2054
+ // --- Listen for Bot Events ---
2055
+ // Attach listeners to events emitted by the bot instance.
2056
+
2057
+ bot.on('initialized', () => {
2058
+ console.log('[Event: initialized] Bot instance successfully created.');
2059
+ console.log('Bot Configuration:', bot.getConfig());
2060
+ });
2061
+
2062
+ bot.on('ready', () => {
2063
+ console.log('[Event: ready] Bot is conceptually ready to process events.');
2064
+ // In a real app, this is where you would start your actual HTTP server
2065
+ // to listen for incoming SeaTalk webhook requests.
2066
+ // Example: bot.startWebhookServer(); // This method is conceptual here
2067
+ });
2068
+
2069
+ // Listen for any incoming processed event
2070
+ bot.on('event', (event) => {
2071
+ console.log(`[Event: event] Received a processed event (Type: ${event.eventType}, Chat: ${event.openChatId || 'N/A'})`);
2072
+ // This general event is useful for logging all incoming events regardless of type.
2073
+ });
2074
+
2075
+ // Listen specifically for message events
2076
+ bot.on('message', (messageEvent) => {
2077
+ console.log(`[Event: message] Received a message event (ID: ${messageEvent.messageId}, Type: ${messageEvent.messageType}) from ${messageEvent.senderUserId} in chat ${messageEvent.openChatId}`);
2078
+ // This is a good place for logic that applies to all messages (e.g., logging, basic filtering)
2079
+ // or handling non-command text messages if not handled elsewhere.
2080
+ const text = messageEvent.getTextContent();
2081
+ if (messageEvent.messageType === 'text' && text !== null) {
2082
+ const commandInfo = parseCommand(text);
2083
+ if (!commandInfo.command) {
2084
+ console.log(`[Event: message] Handling non-command text: "${text.substring(0, 50)}..."`);
2085
+ // Example: Simple auto-reply to any non-command text
2086
+ // bot.sendMessage(messageEvent.openChatId, "Thanks! I received your message. Try typing /help to see what I can do.");
2087
+ }
2088
+ }
2089
+ });
2090
+
2091
+ // Listen specifically for text messages (more specific than 'message')
2092
+ bot.on('message_text', (messageEvent) => {
2093
+ console.log(`[Event: message_text] Received a text message: "${messageEvent.getTextContent().substring(0, 50)}..."`);
2094
+ // Useful if you have different logic for different message types.
2095
+ });
2096
+
2097
+ // Listen specifically for image messages
2098
+ bot.on('message_image', (messageEvent) => {
2099
+ console.log(`[Event: message_image] Received an image message. Content:`, messageEvent.getImageContent());
2100
+ // Handle image messages here (e.g., process image, store key, etc.)
2101
+ });
2102
+
2103
+
2104
+ // Listen specifically for command events (before they are dispatched to handlers)
2105
+ bot.on('command', (commandName, args, messageEvent) => {
2106
+ console.log(`[Event: command] Detected command "${commandName}" from ${messageEvent.senderUserId}. Preparing to dispatch...`);
2107
+ // Useful for logging all commands received or applying pre-dispatch logic (e.g., permission checks)
2108
+ });
2109
+
2110
+ // Listen for commands that were received but not registered
2111
+ bot.on('unregistered_command', (commandName, args, messageEvent) => {
2112
+ console.warn(`[Event: unregistered_command] User ${messageEvent.senderUserId} sent unrecognized command: /${commandName}`);
2113
+ // Inform the user the command is not found (conceptual sendMessage)
2114
+ // try {
2115
+ // await bot.sendMessage(messageEvent.openChatId, `Sorry, I don't recognize the command "/${commandName}". Type /help for a list of commands.`);
2116
+ // } catch (error) {
2117
+ // console.error('Failed to send unregistered command message:', error);
2118
+ // }
2119
+ });
2120
+
2121
+
2122
+ // Listen for any errors that occur within the bot's operations
2123
+ bot.on('error', (error, context) => {
2124
+ console.error('[Event: error] Bot encountered an error:', error);
2125
+ if (context) {
2126
+ console.error('Error context:', context);
2127
+ }
2128
+ // Implement robust error logging, monitoring, alerting, etc. here.
2129
+ // For webhook processing errors (signature, parsing, token), the processing method
2130
+ // also throws the error, which your HTTP server handler should catch to send a response.
2131
+ // This event provides an additional asynchronous notification mechanism.
2132
+ });
2133
+
2134
+ // Listen for specific security-related failures
2135
+ bot.on('signature_verification_failed', (context) => {
2136
+ console.error('[Event: signature_verification_failed] Webhook failed signature check! This could indicate a security issue.');
2137
+ console.error('Failed signature context:', context);
2138
+ // Log context securely, trigger alerts.
2139
+ });
2140
+
2141
+ bot.on('token_verification_failed', (context) => {
2142
+ console.error('[Event: token_verification_failed] Webhook verification token mismatch!');
2143
+ console.error('Failed token context:', context);
2144
+ // Log context, trigger alerts.
2145
+ });
2146
+
2147
+ bot.on('parsing_error', (error, rawBody) => {
2148
+ console.error('[Event: parsing_error] Failed to parse or validate webhook payload:', error);
2149
+ // Log rawBody carefully if needed for debugging, but avoid logging sensitive info in production logs.
2150
+ });
2151
+
2152
+
2153
+ // --- Simulate Incoming Webhook Requests (for testing within constraint) ---
2154
+ // In a real application, your HTTP server would receive requests and call
2155
+ // bot.processWebhookEvent(rawBody, signatureHeader, timestampHeader).
2156
+ // Here, we simulate that process.
2157
+
2158
+ // Example Simulated Webhook Payload: A text message containing a command
2159
+ const exampleCommandPayload = {
2160
+ "event_type": "message",
2161
+ "token": botConfigExample.verificationToken, // MUST match configured token
2162
+ "timestamp": String(getCurrentTimestampInSeconds()), // MUST be relatively fresh
2163
+ "open_chat_id": "oc_example_chat_id", // Example chat ID
2164
+ "open_app_id": botConfigExample.appId, // MUST match configured appId
2165
+ "message": {
2166
+ "message_id": "om_example_msg_id_1",
2167
+ "chat_type": "p2p", // or "group"
2168
+ "open_chat_id": "oc_example_chat_id",
2169
+ "sender_type": "user", // or "bot"
2170
+ "sender_user_id": "ou_example_user_id", // Example user ID
2171
+ "message_type": "text",
2172
+ "create_time": String(getCurrentTimestampInSeconds()),
2173
+ "content": {
2174
+ "text": "/echo Hello SeaTalk Bot!" // The command text
2175
+ },
2176
+ "parent_id": "", // if a reply
2177
+ "open_message_id": "om_example_msg_id_1" // typically same as message_id
2178
+ },
2179
+ "sender": {
2180
+ "tenant_user_id": "example_tenant_user" // Example tenant user ID
2181
+ // other sender details might be here
2182
+ }
2183
+ };
2184
+
2185
+ const exampleCommandRawBody = JSON.stringify(exampleCommandPayload);
2186
+ // Simulate signature header - MUST match the logic in simulateSignatureVerification
2187
+ // Our simulation expects 'valid_signature_for_' + first 8 chars of app secret
2188
+ const exampleCommandSignature = 'valid_signature_for_' + botConfigExample.appSecret.substring(0, 8);
2189
+ const exampleCommandTimestamp = exampleCommandPayload.timestamp; // Use the timestamp from the payload
2190
+
2191
+ console.log('\n--- Running Simulation: Command Message ---');
2192
+ bot.simulateWebhookRequest(exampleCommandRawBody, {
2193
+ 'x-signature': exampleCommandSignature,
2194
+ 'x-timestamp': exampleCommandTimestamp
2195
+ })
2196
+ .then(() => console.log('Simulation Complete: Command Message.'))
2197
+ .catch(err => console.error('Simulation Ended with Error: Command Message:', err));
2198
+
2199
+
2200
+ // Example Simulated Webhook Payload: A plain text message (not a command)
2201
+ const examplePlainMessagePayload = {
2202
+ "event_type": "message",
2203
+ "token": botConfigExample.verificationToken,
2204
+ "timestamp": String(getCurrentTimestampInSeconds() + 1), // Slightly different timestamp
2205
+ "open_chat_id": "oc_another_chat",
2206
+ "open_app_id": botConfigExample.appId,
2207
+ "message": {
2208
+ "message_id": "om_example_msg_id_2",
2209
+ "chat_type": "group",
2210
+ "open_chat_id": "oc_another_chat",
2211
+ "sender_type": "user",
2212
+ "sender_user_id": "ou_another_user_id",
2213
+ "message_type": "text",
2214
+ "create_time": String(getCurrentTimestampInSeconds() + 1),
2215
+ "content": {
2216
+ "text": "Just saying hi!" // Plain text
2217
+ },
2218
+ "parent_id": "",
2219
+ "open_message_id": "om_example_msg_id_2"
2220
+ },
2221
+ "sender": {
2222
+ "tenant_user_id": "another_tenant_user"
2223
+ }
2224
+ };
2225
+ const examplePlainMessageRawBody = JSON.stringify(examplePlainMessagePayload);
2226
+ // Simulate signature and timestamp
2227
+ const examplePlainMessageSignature = 'valid_signature_for_' + botConfigExample.appSecret.substring(0, 8);
2228
+ const examplePlainMessageTimestamp = examplePlainMessagePayload.timestamp;
2229
+
2230
+ console.log('\n--- Running Simulation: Plain Message ---');
2231
+ bot.simulateWebhookRequest(examplePlainMessageRawBody, {
2232
+ 'x-signature': examplePlainMessageSignature,
2233
+ 'x-timestamp': examplePlainMessageTimestamp
2234
+ })
2235
+ .then(() => console.log('Simulation Complete: Plain Message.'))
2236
+ .catch(err => console.error('Simulation Ended with Error: Plain Message:', err));
2237
+
2238
+
2239
+ // Example Simulated Webhook Payload: Unregistered command
2240
+ const exampleUnregisteredCommandPayload = {
2241
+ "event_type": "message",
2242
+ "token": botConfigExample.verificationToken,
2243
+ "timestamp": String(getCurrentTimestampInSeconds() + 2),
2244
+ "open_chat_id": "oc_another_chat",
2245
+ "open_app_id": botConfigExample.appId,
2246
+ "message": {
2247
+ "message_id": "om_example_msg_id_3",
2248
+ "chat_type": "p2p",
2249
+ "open_chat_id": "oc_another_chat",
2250
+ "sender_type": "user",
2251
+ "sender_user_id": "ou_another_user_id",
2252
+ "message_type": "text",
2253
+ "create_time": String(getCurrentTimestampInSeconds() + 2),
2254
+ "content": {
2255
+ "text": "/unknowncommand arg1" // Unregistered command
2256
+ },
2257
+ "parent_id": "",
2258
+ "open_message_id": "om_example_msg_id_3"
2259
+ },
2260
+ "sender": {
2261
+ "tenant_user_id": "another_tenant_user"
2262
+ }
2263
+ };
2264
+ const exampleUnregisteredCommandRawBody = JSON.stringify(exampleUnregisteredCommandPayload);
2265
+ const exampleUnregisteredCommandSignature = 'valid_signature_for_' + botConfigExample.appSecret.substring(0, 8);
2266
+ const exampleUnregisteredCommandTimestamp = exampleUnregisteredCommandPayload.timestamp;
2267
+
2268
+ console.log('\n--- Running Simulation: Unregistered Command ---');
2269
+ bot.simulateWebhookRequest(exampleUnregisteredCommandRawBody, {
2270
+ 'x-signature': exampleUnregisteredCommandSignature,
2271
+ 'x-timestamp': exampleUnregisteredCommandTimestamp
2272
+ })
2273
+ .then(() => console.log('Simulation Complete: Unregistered Command.'))
2274
+ .catch(err => console.error('Simulation Ended with Error: Unregistered Command:', err));
2275
+
2276
+
2277
+ // Example Simulated Webhook Request: Bad Signature
2278
+ const exampleBadSignaturePayload = {
2279
+ "event_type": "message",
2280
+ "token": botConfigExample.verificationToken,
2281
+ "timestamp": String(getCurrentTimestampInSeconds() + 3),
2282
+ "open_chat_id": "oc_bad_sig_chat",
2283
+ "open_app_id": botConfigExample.appId,
2284
+ "message": { // Message content doesn't really matter for signature failure
2285
+ "message_id": "om_example_msg_id_4",
2286
+ "chat_type": "p2p",
2287
+ "open_chat_id": "oc_bad_sig_chat",
2288
+ "sender_type": "user",
2289
+ "sender_user_id": "ou_bad_sig_user_id",
2290
+ "message_type": "text",
2291
+ "create_time": String(getCurrentTimestampInSeconds() + 3),
2292
+ "content": { "text": "This should fail signature check." },
2293
+ "parent_id": "",
2294
+ "open_message_id": "om_example_msg_id_4"
2295
+ },
2296
+ "sender": { "tenant_user_id": "bad_sig_user" }
2297
+ };
2298
+ const exampleBadSignatureRawBody = JSON.stringify(exampleBadSignaturePayload);
2299
+ // Simulate a BAD signature that doesn't match the expected format
2300
+ const exampleBadSignature = 'invalid_signature_prefix' + botConfigExample.appSecret.substring(0, 8); // Intentional mismatch
2301
+ const exampleBadSignatureTimestamp = exampleBadSignaturePayload.timestamp;
2302
+
2303
+ console.log('\n--- Running Simulation: Bad Signature ---');
2304
+ bot.simulateWebhookRequest(exampleBadSignatureRawBody, {
2305
+ 'x-signature': exampleBadSignature,
2306
+ 'x-timestamp': exampleBadSignatureTimestamp
2307
+ })
2308
+ .then(() => console.log('Simulation Complete: Bad Signature (Processed).')) // processWebhookEvent will throw, not resolve
2309
+ .catch(err => console.error('Simulation Ended with Error: Bad Signature:', err));
2310
+
2311
+
2312
+ // Example Simulated Webhook Request: Bad Verification Token in Payload
2313
+ const exampleBadTokenPayload = {
2314
+ "event_type": "message",
2315
+ "token": "THIS_IS_AN_INVALID_TOKEN", // Intentional mismatch
2316
+ "timestamp": String(getCurrentTimestampInSeconds() + 4),
2317
+ "open_chat_id": "oc_bad_token_chat",
2318
+ "open_app_id": botConfigExample.appId,
2319
+ "message": {
2320
+ "message_id": "om_example_msg_id_5",
2321
+ "chat_type": "p2p",
2322
+ "open_chat_id": "oc_bad_token_chat",
2323
+ "sender_type": "user",
2324
+ "sender_user_id": "ou_bad_token_user_id",
2325
+ "message_type": "text",
2326
+ "create_time": String(getCurrentTimestampInSeconds() + 4),
2327
+ "content": { "text": "This should fail token check." },
2328
+ "parent_id": "",
2329
+ "open_message_id": "om_example_msg_id_5"
2330
+ },
2331
+ "sender": { "tenant_user_id": "bad_token_user" }
2332
+ };
2333
+ const exampleBadTokenRawBody = JSON.stringify(exampleBadTokenPayload);
2334
+ // Signature should still be correct to reach token check in this simulation flow
2335
+ const exampleBadTokenSignature = 'valid_signature_for_' + botConfigExample.appSecret.substring(0, 8);
2336
+ const exampleBadTokenTimestamp = exampleBadTokenPayload.timestamp;
2337
+
2338
+ console.log('\n--- Running Simulation: Bad Verification Token ---');
2339
+ bot.simulateWebhookRequest(exampleBadTokenRawBody, {
2340
+ 'x-signature': exampleBadTokenSignature,
2341
+ 'x-timestamp': exampleBadTokenTimestamp
2342
+ })
2343
+ .then(() => console.log('Simulation Complete: Bad Verification Token (Processed).'))
2344
+ .catch(err => console.error('Simulation Ended with Error: Bad Verification Token:', err));
2345
+
2346
+
2347
+ // Example Simulated Webhook Request: Invalid JSON Body
2348
+ const exampleInvalidJsonRawBody = '{"event_type": "message", "token": "' + botConfigExample.verificationToken + '", "timestamp": "' + String(getCurrentTimestampInSeconds() + 5) + '", invalid_json}'; // Malformed JSON
2349
+ // Signature and timestamp headers might not even be checked if body reading/parsing fails first in a real server
2350
+ const exampleInvalidJsonSignature = 'valid_signature_for_' + botConfigExample.appSecret.substring(0, 8);
2351
+ const exampleInvalidJsonTimestamp = String(getCurrentTimestampInSeconds() + 5);
2352
+
2353
+ console.log('\n--- Running Simulation: Invalid JSON Body ---');
2354
+ bot.simulateWebhookRequest(exampleInvalidJsonRawBody, {
2355
+ 'x-signature': exampleInvalidJsonSignature,
2356
+ 'x-timestamp': exampleInvalidJsonTimestamp
2357
+ })
2358
+ .then(() => console.log('Simulation Complete: Invalid JSON (Processed).'))
2359
+ .catch(err => console.error('Simulation Ended with Error: Invalid JSON:', err));
2360
+
2361
+
2362
+ } catch (initError) {
2363
+ console.error('Failed to initialize SeaTalk Bot instance:', initError);
2364
+ // Bot could not be created, possibly due to invalid configuration
2365
+ }
2366
+
2367
+ // In a real application, after setting up event listeners and registering commands,
2368
+ // you would typically start the webhook server here:
2369
+ // bot.startWebhookServer(); // This is the conceptual method defined above
2370
+
2371
+ // The process would then wait for incoming HTTP requests handled by the server.
2372
+ */
2373
+ // End of Example Usage