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.
- package/index.js +2373 -0
- 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
|