signal-sdk 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -9
- package/dist/SignalBot.d.ts +108 -0
- package/dist/SignalBot.js +811 -0
- package/dist/SignalCli.d.ts +135 -0
- package/dist/SignalCli.js +711 -0
- package/dist/__tests__/SignalBot.test.d.ts +1 -0
- package/dist/__tests__/SignalBot.test.js +102 -0
- package/dist/__tests__/SignalCli.test.d.ts +1 -0
- package/dist/__tests__/SignalCli.test.js +235 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +22 -0
- package/dist/interfaces.d.ts +938 -0
- package/dist/interfaces.js +11 -0
- package/package.json +5 -6
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SignalBot = void 0;
|
|
37
|
+
const events_1 = require("events");
|
|
38
|
+
const SignalCli_1 = require("./SignalCli");
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const https = __importStar(require("https"));
|
|
42
|
+
const http = __importStar(require("http"));
|
|
43
|
+
class SignalBot extends events_1.EventEmitter {
|
|
44
|
+
constructor(config, signalCliPath) {
|
|
45
|
+
super();
|
|
46
|
+
this.commands = new Map();
|
|
47
|
+
this.isRunning = false;
|
|
48
|
+
this.botGroupId = null;
|
|
49
|
+
this.userCooldowns = new Map();
|
|
50
|
+
this.actionQueue = [];
|
|
51
|
+
this.isProcessingQueue = false;
|
|
52
|
+
this.incomingMessageBuffer = [];
|
|
53
|
+
this.config = {
|
|
54
|
+
phoneNumber: config.phoneNumber,
|
|
55
|
+
admins: config.admins || [],
|
|
56
|
+
settings: {
|
|
57
|
+
commandPrefix: config.settings?.commandPrefix || '/',
|
|
58
|
+
autoReact: config.settings?.autoReact ?? false,
|
|
59
|
+
logMessages: config.settings?.logMessages ?? true,
|
|
60
|
+
welcomeNewMembers: config.settings?.welcomeNewMembers ?? true,
|
|
61
|
+
cooldownSeconds: config.settings?.cooldownSeconds || 2,
|
|
62
|
+
maxMessageLength: config.settings?.maxMessageLength || 1000,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
if (config.group) {
|
|
66
|
+
this.config.group = {
|
|
67
|
+
name: config.group.name,
|
|
68
|
+
description: config.group.description || '- Group managed by Signal Bot',
|
|
69
|
+
createIfNotExists: config.group.createIfNotExists ?? true,
|
|
70
|
+
initialMembers: config.group.initialMembers || [],
|
|
71
|
+
avatar: config.group.avatar, // Add the missing avatar property
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
this.signalCli = new SignalCli_1.SignalCli(signalCliPath, this.config.phoneNumber);
|
|
75
|
+
this.stats = {
|
|
76
|
+
messagesReceived: 0,
|
|
77
|
+
commandsExecuted: 0,
|
|
78
|
+
startTime: Date.now(),
|
|
79
|
+
lastActivity: Date.now(),
|
|
80
|
+
activeUsers: 0
|
|
81
|
+
};
|
|
82
|
+
this.setupDefaultCommands();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Downloads an image from URL to a temporary file
|
|
86
|
+
* @param imageUrl URL of the image to download
|
|
87
|
+
* @returns Path to the temporary file
|
|
88
|
+
*/
|
|
89
|
+
async downloadImage(imageUrl) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const tempFileName = `bot_avatar_${Date.now()}.jpg`;
|
|
92
|
+
const tempFilePath = path.join(process.cwd(), tempFileName);
|
|
93
|
+
const file = fs.createWriteStream(tempFilePath);
|
|
94
|
+
const client = imageUrl.startsWith('https:') ? https : http;
|
|
95
|
+
client.get(imageUrl, (response) => {
|
|
96
|
+
if (response.statusCode !== 200) {
|
|
97
|
+
fs.unlink(tempFilePath, () => { }); // Clean up on error
|
|
98
|
+
reject(new Error(`Failed to download image: ${response.statusCode}`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
response.pipe(file);
|
|
102
|
+
file.on('finish', () => {
|
|
103
|
+
file.close();
|
|
104
|
+
resolve(tempFilePath);
|
|
105
|
+
});
|
|
106
|
+
file.on('error', (err) => {
|
|
107
|
+
fs.unlink(tempFilePath, () => { }); // Clean up on error
|
|
108
|
+
reject(err);
|
|
109
|
+
});
|
|
110
|
+
}).on('error', (err) => {
|
|
111
|
+
fs.unlink(tempFilePath, () => { }); // Clean up on error
|
|
112
|
+
reject(err);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Downloads an image from URL for commands (like NASA images)
|
|
118
|
+
* @param imageUrl URL of the image to download
|
|
119
|
+
* @param prefix Optional prefix for the temp file name
|
|
120
|
+
* @returns Path to the temporary file
|
|
121
|
+
*/
|
|
122
|
+
async downloadImageFromUrl(imageUrl, prefix = 'bot_image') {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const downloadWithRedirect = (url, maxRedirects = 5) => {
|
|
125
|
+
const client = url.startsWith('https:') ? https : http;
|
|
126
|
+
client.get(url, (response) => {
|
|
127
|
+
// Handle redirections (3xx status codes)
|
|
128
|
+
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {
|
|
129
|
+
if (maxRedirects <= 0) {
|
|
130
|
+
reject(new Error(`Too many redirects for image: ${url}`));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const redirectUrl = response.headers.location;
|
|
134
|
+
if (!redirectUrl) {
|
|
135
|
+
reject(new Error(`Redirect without location header: ${response.statusCode}`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Resolve relative URLs
|
|
139
|
+
const finalUrl = redirectUrl.startsWith('http') ? redirectUrl : new URL(redirectUrl, url).href;
|
|
140
|
+
this.log(`🔄 Following redirect to: ${finalUrl}`, 'DEBUG');
|
|
141
|
+
downloadWithRedirect(finalUrl, maxRedirects - 1);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Handle non-success status codes
|
|
145
|
+
if (response.statusCode !== 200) {
|
|
146
|
+
reject(new Error(`Failed to download image: ${response.statusCode}`));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Success - create file and pipe response
|
|
150
|
+
const urlObj = new URL(url);
|
|
151
|
+
const extension = path.extname(urlObj.pathname) || '.jpg';
|
|
152
|
+
const tempFileName = `${prefix}_${Date.now()}${extension}`;
|
|
153
|
+
const tempFilePath = path.join(process.cwd(), tempFileName);
|
|
154
|
+
const file = fs.createWriteStream(tempFilePath);
|
|
155
|
+
response.pipe(file);
|
|
156
|
+
file.on('finish', () => {
|
|
157
|
+
file.close();
|
|
158
|
+
resolve(tempFilePath);
|
|
159
|
+
});
|
|
160
|
+
file.on('error', (err) => {
|
|
161
|
+
fs.unlink(tempFilePath, () => { }); // Clean up on error
|
|
162
|
+
reject(err);
|
|
163
|
+
});
|
|
164
|
+
}).on('error', (err) => {
|
|
165
|
+
reject(err);
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
// Start the download with redirect handling
|
|
169
|
+
downloadWithRedirect(imageUrl);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Sends a message with downloaded image attachment
|
|
174
|
+
* @param recipient Recipient to send to
|
|
175
|
+
* @param message Text message to send
|
|
176
|
+
* @param imageUrl URL of the image to download and send
|
|
177
|
+
* @param prefix Optional prefix for the temp file name
|
|
178
|
+
*/
|
|
179
|
+
async sendMessageWithImage(recipient, message, imageUrl, prefix = 'bot_image') {
|
|
180
|
+
let tempFilePath = null;
|
|
181
|
+
try {
|
|
182
|
+
this.log(`- Downloading image from ${imageUrl}...`, 'DEBUG');
|
|
183
|
+
tempFilePath = await this.downloadImageFromUrl(imageUrl, prefix);
|
|
184
|
+
this.log(`- Image downloaded to: ${tempFilePath}`, 'DEBUG');
|
|
185
|
+
// Add to the action queue with cleanup info
|
|
186
|
+
this.actionQueue.push({
|
|
187
|
+
type: 'sendMessageWithAttachment',
|
|
188
|
+
recipient,
|
|
189
|
+
message,
|
|
190
|
+
attachments: [tempFilePath],
|
|
191
|
+
cleanup: [tempFilePath] // Mark files for cleanup after sending
|
|
192
|
+
});
|
|
193
|
+
this.processActionQueue();
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
this.log(`ERROR: Failed to download and send image: ${error?.message}`, 'ERROR');
|
|
197
|
+
// Clean up on download error
|
|
198
|
+
if (tempFilePath) {
|
|
199
|
+
this.cleanupTempFile(tempFilePath);
|
|
200
|
+
}
|
|
201
|
+
// Fallback to text message with URL
|
|
202
|
+
await this.sendMessage(recipient, `${message}\n\n- Image: ${imageUrl}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Cleans up a temporary file
|
|
207
|
+
* @param filePath Path to the file to delete
|
|
208
|
+
*/
|
|
209
|
+
cleanupTempFile(filePath) {
|
|
210
|
+
try {
|
|
211
|
+
if (fs.existsSync(filePath)) {
|
|
212
|
+
fs.unlinkSync(filePath);
|
|
213
|
+
this.log(`- Cleaned up temporary file: ${filePath}`, 'DEBUG');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
this.log(`WARNING: Could not cleanup temp file ${filePath}: ${error?.message}`, 'DEBUG');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Processes group avatar configuration
|
|
222
|
+
* @param avatar Avatar configuration (URL, file path, or base64)
|
|
223
|
+
* @returns Path to the avatar file or null if no avatar
|
|
224
|
+
*/
|
|
225
|
+
async processGroupAvatar(avatar) {
|
|
226
|
+
if (!avatar) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
// Check if it's a URL
|
|
230
|
+
if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
|
|
231
|
+
try {
|
|
232
|
+
this.log(`- Downloading group avatar from URL...`, 'INFO');
|
|
233
|
+
const tempPath = await this.downloadImage(avatar);
|
|
234
|
+
this.log(`- Avatar downloaded to: ${tempPath}`, 'DEBUG');
|
|
235
|
+
return tempPath;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
this.log(`ERROR: Failed to download avatar: ${error?.message}`, 'ERROR');
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Check if it's a file path
|
|
243
|
+
if (fs.existsSync(avatar)) {
|
|
244
|
+
this.log(`- Using local avatar file: ${avatar}`, 'INFO');
|
|
245
|
+
return avatar;
|
|
246
|
+
}
|
|
247
|
+
// If it's base64 or other format, save it as a temporary file
|
|
248
|
+
if (avatar.startsWith('data:image/')) {
|
|
249
|
+
try {
|
|
250
|
+
const base64Data = avatar.split(',')[1];
|
|
251
|
+
const tempFileName = `bot_avatar_${Date.now()}.jpg`;
|
|
252
|
+
const tempFilePath = path.join(process.cwd(), tempFileName);
|
|
253
|
+
fs.writeFileSync(tempFilePath, base64Data, 'base64');
|
|
254
|
+
this.log(`- Saved base64 avatar to: ${tempFilePath}`, 'DEBUG');
|
|
255
|
+
return tempFilePath;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
this.log(`ERROR: Failed to process base64 avatar: ${error?.message}`, 'ERROR');
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
this.log(`WARNING: Unsupported avatar format: ${avatar.substring(0, 50)}...`, 'WARN');
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Adds a custom command to the bot
|
|
267
|
+
*/
|
|
268
|
+
addCommand(command) {
|
|
269
|
+
this.commands.set(command.name.toLowerCase(), command);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Removes a command from the bot
|
|
273
|
+
*/
|
|
274
|
+
removeCommand(name) {
|
|
275
|
+
return this.commands.delete(name.toLowerCase());
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Gets all available commands
|
|
279
|
+
*/
|
|
280
|
+
getCommands() {
|
|
281
|
+
return Array.from(this.commands.values());
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Starts the bot
|
|
285
|
+
*/
|
|
286
|
+
async start() {
|
|
287
|
+
try {
|
|
288
|
+
this.log('- Starting Signal Bot...', 'INFO');
|
|
289
|
+
await this.signalCli.connect();
|
|
290
|
+
this.log('- Connected to signal-cli in JSON-RPC mode');
|
|
291
|
+
// Check that the bot is linked
|
|
292
|
+
const devices = await this.signalCli.listDevices();
|
|
293
|
+
if (devices.length === 0) {
|
|
294
|
+
throw new Error('No device found. Please link the bot first.');
|
|
295
|
+
}
|
|
296
|
+
this.log(`- Bot linked with ${devices.length} device(s)`);
|
|
297
|
+
// Set up or find the bot group
|
|
298
|
+
if (this.config.group && this.config.group.createIfNotExists) {
|
|
299
|
+
await this.setupBotGroup();
|
|
300
|
+
}
|
|
301
|
+
// Set up event handlers
|
|
302
|
+
this.setupEventHandlers();
|
|
303
|
+
this.isRunning = true;
|
|
304
|
+
this.log('- Signal Bot started successfully!');
|
|
305
|
+
this.emit('ready');
|
|
306
|
+
// Send welcome message to admins
|
|
307
|
+
await this.sendWelcomeMessage();
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
this.log(`ERROR: Error during startup: ${error?.message || error}`, 'ERROR');
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Stops the bot
|
|
316
|
+
*/
|
|
317
|
+
async stop() {
|
|
318
|
+
this.log('- Stopping Signal Bot...');
|
|
319
|
+
this.isRunning = false;
|
|
320
|
+
this.signalCli.disconnect();
|
|
321
|
+
this.emit('stopped');
|
|
322
|
+
this.log('- Bot stopped');
|
|
323
|
+
}
|
|
324
|
+
async gracefulShutdown() {
|
|
325
|
+
this.log('- Gracefully shutting down Signal Bot...');
|
|
326
|
+
this.isRunning = false;
|
|
327
|
+
try {
|
|
328
|
+
await this.signalCli.gracefulShutdown();
|
|
329
|
+
this.log('- Signal Bot shutdown completed gracefully');
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
this.log(`ERROR: Error during graceful shutdown: ${error?.message || error}`, 'ERROR');
|
|
333
|
+
}
|
|
334
|
+
this.emit('stopped');
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Sends a message
|
|
338
|
+
*/
|
|
339
|
+
async sendMessage(recipient, message) {
|
|
340
|
+
if (message.length > (this.config.settings.maxMessageLength || 1000)) {
|
|
341
|
+
message = message.substring(0, (this.config.settings.maxMessageLength || 1000) - 3) + '...';
|
|
342
|
+
}
|
|
343
|
+
// Add to the action queue
|
|
344
|
+
this.actionQueue.push({ type: 'sendMessage', recipient, message });
|
|
345
|
+
this.processActionQueue();
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Sends a reaction to a message
|
|
349
|
+
*/
|
|
350
|
+
async sendReaction(recipient, targetAuthor, targetTimestamp, emoji) {
|
|
351
|
+
// Add to the action queue
|
|
352
|
+
this.actionQueue.push({ type: 'sendReaction', recipient, targetAuthor, targetTimestamp, emoji });
|
|
353
|
+
this.processActionQueue();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Sends a message with file attachments
|
|
357
|
+
*/
|
|
358
|
+
async sendMessageWithAttachment(recipient, message, attachments, cleanup) {
|
|
359
|
+
if (message.length > (this.config.settings.maxMessageLength || 1000)) {
|
|
360
|
+
message = message.substring(0, (this.config.settings.maxMessageLength || 1000) - 3) + '...';
|
|
361
|
+
}
|
|
362
|
+
// Add to the action queue
|
|
363
|
+
this.actionQueue.push({ type: 'sendMessageWithAttachment', recipient, message, attachments, cleanup });
|
|
364
|
+
this.processActionQueue();
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Gets bot statistics
|
|
368
|
+
*/
|
|
369
|
+
getStats() {
|
|
370
|
+
return {
|
|
371
|
+
...this.stats,
|
|
372
|
+
activeUsers: this.userCooldowns.size
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Checks if a user is an admin
|
|
377
|
+
*/
|
|
378
|
+
isAdmin(phoneNumber) {
|
|
379
|
+
return this.config.admins.includes(phoneNumber);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Gets the bot group ID
|
|
383
|
+
*/
|
|
384
|
+
getBotGroupId() {
|
|
385
|
+
return this.botGroupId;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Gets the underlying SignalCli instance for advanced operations
|
|
389
|
+
*/
|
|
390
|
+
getSignalCli() {
|
|
391
|
+
return this.signalCli;
|
|
392
|
+
}
|
|
393
|
+
setupDefaultCommands() {
|
|
394
|
+
// Help command
|
|
395
|
+
this.addCommand({
|
|
396
|
+
name: 'help',
|
|
397
|
+
description: 'Displays available commands',
|
|
398
|
+
handler: async (message, args) => {
|
|
399
|
+
const userCommands = Array.from(this.commands.values())
|
|
400
|
+
.filter(cmd => !cmd.adminOnly || message.isFromAdmin)
|
|
401
|
+
.map(cmd => `${this.config.settings.commandPrefix}${cmd.name} - ${cmd.description}`)
|
|
402
|
+
.join('\n');
|
|
403
|
+
return `Signal Bot Commands\n\n${userCommands}\n\n${message.isFromAdmin ? '| You have admin privileges' : ''}`;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
// Stats command
|
|
407
|
+
this.addCommand({
|
|
408
|
+
name: 'stats',
|
|
409
|
+
description: 'Displays bot statistics',
|
|
410
|
+
handler: async () => {
|
|
411
|
+
const stats = this.getStats();
|
|
412
|
+
const uptime = this.formatUptime(Date.now() - stats.startTime);
|
|
413
|
+
return `Bot Statistics\n\n` +
|
|
414
|
+
`1. Messages Received: ${stats.messagesReceived}\n` +
|
|
415
|
+
`2. Commands Executed: ${stats.commandsExecuted}\n` +
|
|
416
|
+
`3. Uptime: ${uptime}\n` +
|
|
417
|
+
`4. Active Users: ${stats.activeUsers}`;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
// Ping command
|
|
421
|
+
this.addCommand({
|
|
422
|
+
name: 'ping',
|
|
423
|
+
description: 'Tests bot responsiveness',
|
|
424
|
+
handler: async (message) => {
|
|
425
|
+
const responseTime = Date.now() - message.timestamp;
|
|
426
|
+
return `Pong! Response time: ${responseTime}ms`;
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// Info command (admin only)
|
|
430
|
+
this.addCommand({
|
|
431
|
+
name: 'info',
|
|
432
|
+
description: 'Detailed bot information (admin)',
|
|
433
|
+
adminOnly: true,
|
|
434
|
+
handler: async () => {
|
|
435
|
+
return `Bot Information\n\n` +
|
|
436
|
+
`- Number: ${this.config.phoneNumber}\n` +
|
|
437
|
+
`- Group: ${this.config.group ? this.config.group.name : 'N/A'}\n` +
|
|
438
|
+
`- Admins: ${this.config.admins.length}\n` +
|
|
439
|
+
`- Commands: ${this.commands.size}\n` +
|
|
440
|
+
`- Prefix: ${this.config.settings.commandPrefix}`;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async setupBotGroup() {
|
|
445
|
+
if (!this.config.group) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
let avatarPath = null;
|
|
449
|
+
let isTemporaryAvatar = false;
|
|
450
|
+
try {
|
|
451
|
+
// Process avatar if configured
|
|
452
|
+
if (this.config.group.avatar) {
|
|
453
|
+
avatarPath = await this.processGroupAvatar(this.config.group.avatar);
|
|
454
|
+
isTemporaryAvatar = avatarPath !== this.config.group.avatar; // It's temporary if it was processed
|
|
455
|
+
}
|
|
456
|
+
// Search for an existing group
|
|
457
|
+
const groups = await this.signalCli.listGroups();
|
|
458
|
+
const existingGroup = groups.find(group => group.name === this.config.group.name && group.isMember);
|
|
459
|
+
if (existingGroup) {
|
|
460
|
+
// Try different possible field names for the group ID
|
|
461
|
+
const groupData = existingGroup;
|
|
462
|
+
const possibleGroupId = existingGroup.groupId || groupData.id || groupData.Id;
|
|
463
|
+
this.botGroupId = possibleGroupId;
|
|
464
|
+
this.log(`- Existing group found: ${this.config.group.name} (${possibleGroupId})`);
|
|
465
|
+
// Check if all admins are in the group and add them if not
|
|
466
|
+
const currentMembers = existingGroup.members?.map((m) => m.number || m) || [];
|
|
467
|
+
const missingAdmins = this.config.admins.filter(admin => !currentMembers.includes(admin));
|
|
468
|
+
// Prepare update options
|
|
469
|
+
const updateOptions = {};
|
|
470
|
+
if (missingAdmins.length > 0) {
|
|
471
|
+
updateOptions.addMembers = missingAdmins;
|
|
472
|
+
}
|
|
473
|
+
if (avatarPath) {
|
|
474
|
+
updateOptions.avatar = avatarPath;
|
|
475
|
+
}
|
|
476
|
+
// Always apply avatar if configured, even for existing groups
|
|
477
|
+
if (Object.keys(updateOptions).length > 0 && this.botGroupId) {
|
|
478
|
+
try {
|
|
479
|
+
if (missingAdmins.length > 0) {
|
|
480
|
+
this.log(`- Adding missing admins to group: ${missingAdmins.join(', ')}`, 'INFO');
|
|
481
|
+
}
|
|
482
|
+
if (avatarPath) {
|
|
483
|
+
this.log(`- Setting group avatar...`, 'INFO');
|
|
484
|
+
}
|
|
485
|
+
await this.signalCli.updateGroup(this.botGroupId, updateOptions);
|
|
486
|
+
if (missingAdmins.length > 0) {
|
|
487
|
+
this.log(`- Successfully added admins to the group`, 'INFO');
|
|
488
|
+
}
|
|
489
|
+
if (avatarPath) {
|
|
490
|
+
this.log(`- Group avatar set successfully`, 'INFO');
|
|
491
|
+
}
|
|
492
|
+
// Send welcome message to the group only if admins were added
|
|
493
|
+
if (missingAdmins.length > 0) {
|
|
494
|
+
await this.sendMessage(this.botGroupId, `Welcome to ${this.config.group.name}!\n\n` +
|
|
495
|
+
`This group is managed by Signal Bot.\n` +
|
|
496
|
+
`Type ${this.config.settings.commandPrefix}help to see available commands.`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch (updateError) {
|
|
500
|
+
this.log(`ERROR: Error updating group: ${updateError?.message || updateError}`, 'ERROR');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
this.log(`- All admins are already in the group`, 'INFO');
|
|
505
|
+
if (!avatarPath) {
|
|
506
|
+
this.log(`INFO: No avatar configured for group`, 'INFO');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Create a new group only if none exists
|
|
512
|
+
this.log(`- Creating group: ${this.config.group.name}`);
|
|
513
|
+
const initialMembers = [
|
|
514
|
+
...this.config.admins,
|
|
515
|
+
...(this.config.group.initialMembers || [])
|
|
516
|
+
].filter((member, index, array) => array.indexOf(member) === index);
|
|
517
|
+
try {
|
|
518
|
+
const newGroup = await this.signalCli.createGroup(this.config.group.name, initialMembers);
|
|
519
|
+
const newGroupData = newGroup;
|
|
520
|
+
this.botGroupId = newGroup.groupId || newGroupData.id || newGroupData.Id;
|
|
521
|
+
if (this.botGroupId) {
|
|
522
|
+
this.log(`- Group created: ${this.config.group.name} (${this.botGroupId})`);
|
|
523
|
+
// Configure the group with description, permissions, and avatar
|
|
524
|
+
const configOptions = {
|
|
525
|
+
description: this.config.group.description,
|
|
526
|
+
permissionAddMember: 'ONLY_ADMINS',
|
|
527
|
+
permissionEditDetails: 'ONLY_ADMINS'
|
|
528
|
+
};
|
|
529
|
+
if (avatarPath) {
|
|
530
|
+
configOptions.avatar = avatarPath;
|
|
531
|
+
this.log(`- Setting avatar for new group...`, 'INFO');
|
|
532
|
+
}
|
|
533
|
+
await this.signalCli.updateGroup(this.botGroupId, configOptions);
|
|
534
|
+
if (avatarPath) {
|
|
535
|
+
this.log(`- Group avatar set successfully`, 'INFO');
|
|
536
|
+
}
|
|
537
|
+
// Send a welcome message to the group
|
|
538
|
+
await this.sendMessage(this.botGroupId, `Welcome to ${this.config.group.name}!\n\n` +
|
|
539
|
+
`This group is managed by Signal Bot.\n` +
|
|
540
|
+
`Type ${this.config.settings.commandPrefix}help to see available commands.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch (createError) {
|
|
544
|
+
if (createError.message && createError.message.includes('Method not implemented')) {
|
|
545
|
+
// signal-cli doesn't support group creation via JSON-RPC
|
|
546
|
+
this.log(`ERROR: Group creation not supported by signal-cli version.`, 'ERROR');
|
|
547
|
+
this.log(`INSTRUCTIONS: To fix this issue:`, 'INFO');
|
|
548
|
+
this.log(` 1. Create a group named "${this.config.group.name}" manually in Signal`, 'INFO');
|
|
549
|
+
this.log(` 2. Add the bot number (${this.config.phoneNumber}) to the group`, 'INFO');
|
|
550
|
+
this.log(` 3. Add all admins to the group: ${this.config.admins.join(', ')}`, 'INFO');
|
|
551
|
+
this.log(` 4. Restart the bot`, 'INFO');
|
|
552
|
+
this.log(` Available groups: ${groups.map(g => g.name).join(', ')}`, 'INFO');
|
|
553
|
+
// Don't use a fallback group - this is misleading behavior
|
|
554
|
+
throw new Error(`Group "${this.config.group.name}" does not exist and cannot be created automatically. Please create it manually as described above.`);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
// Re-throw the original error for other types of failures
|
|
558
|
+
this.log(`ERROR: Failed to create group: ${createError?.message || createError}`, 'ERROR');
|
|
559
|
+
throw createError;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
this.log(`ERROR: Error configuring group: ${error?.message || error}`, 'ERROR');
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
// Clean up temporary avatar file if it was downloaded/created
|
|
568
|
+
if (avatarPath && isTemporaryAvatar) {
|
|
569
|
+
this.cleanupTempFile(avatarPath);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
setupEventHandlers() {
|
|
574
|
+
this.signalCli.on('message', (messageData) => {
|
|
575
|
+
if (this.isProcessingQueue) {
|
|
576
|
+
this.log('Queue is processing, buffering incoming message...', 'DEBUG');
|
|
577
|
+
this.incomingMessageBuffer.push(messageData);
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
this.handleMessage(messageData);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
this.signalCli.on('close', (code) => {
|
|
584
|
+
// Only log as warning if exit code indicates an error
|
|
585
|
+
if (code === 0) {
|
|
586
|
+
this.log(`Signal daemon closed gracefully`, 'INFO');
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
this.log(`Signal daemon closed with error code ${code}`, 'ERROR');
|
|
590
|
+
}
|
|
591
|
+
this.emit('daemon-closed', code);
|
|
592
|
+
});
|
|
593
|
+
this.signalCli.on('error', (error) => {
|
|
594
|
+
this.log(`Daemon error: ${error.message}`, 'ERROR');
|
|
595
|
+
this.emit('error', error);
|
|
596
|
+
});
|
|
597
|
+
this.signalCli.on('log', (logData) => {
|
|
598
|
+
// Handle non-error stderr messages from signal-cli
|
|
599
|
+
this.log(`[signal-cli ${logData.level.toUpperCase()}] ${logData.message}`, 'DEBUG');
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
async handleMessage(messageData) {
|
|
603
|
+
try {
|
|
604
|
+
if (!messageData.envelope?.dataMessage) {
|
|
605
|
+
return; // Ignore non-data messages (receipts, typing indicators, etc.)
|
|
606
|
+
}
|
|
607
|
+
const parsedMessage = {
|
|
608
|
+
id: messageData.envelope.timestamp.toString(),
|
|
609
|
+
source: messageData.envelope.sourceNumber || messageData.envelope.source,
|
|
610
|
+
text: messageData.envelope.dataMessage.message || '',
|
|
611
|
+
timestamp: messageData.envelope.timestamp,
|
|
612
|
+
groupInfo: messageData.envelope.dataMessage.groupInfo,
|
|
613
|
+
isFromAdmin: this.isAdmin(messageData.envelope.sourceNumber || messageData.envelope.source)
|
|
614
|
+
};
|
|
615
|
+
// Ignore own messages
|
|
616
|
+
if (parsedMessage.source === this.config.phoneNumber) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
// Ignore empty messages (like reactions only, media without text, etc.)
|
|
620
|
+
if (!parsedMessage.text && !messageData.envelope.dataMessage.attachments) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
// If bot is configured for group mode, ignore private messages
|
|
624
|
+
if (this.botGroupId && !parsedMessage.groupInfo) {
|
|
625
|
+
// Only log if there's actual text content to avoid spam
|
|
626
|
+
if (parsedMessage.text.trim()) {
|
|
627
|
+
this.log(`IGNORE: Ignoring private message from ${parsedMessage.source} (bot is in group mode)`, 'DEBUG');
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// Check if the group is authorized
|
|
632
|
+
if (parsedMessage.groupInfo && this.botGroupId) {
|
|
633
|
+
const groupId = parsedMessage.groupInfo.id || parsedMessage.groupInfo.groupId;
|
|
634
|
+
if (groupId !== this.botGroupId) {
|
|
635
|
+
return; // Ignore messages from other groups
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Update statistics
|
|
639
|
+
this.stats.messagesReceived++;
|
|
640
|
+
this.stats.lastActivity = Date.now();
|
|
641
|
+
this.log(`Message from ${parsedMessage.source}: ${parsedMessage.text.substring(0, 50)}...`);
|
|
642
|
+
// Emit message event
|
|
643
|
+
this.emit('message', parsedMessage);
|
|
644
|
+
// Send read receipt automatically
|
|
645
|
+
try {
|
|
646
|
+
await this.signalCli.sendReceipt(parsedMessage.source, parsedMessage.timestamp, 'read');
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
this.log(`ERROR: Error sending read receipt: ${error?.message || error}`, 'DEBUG');
|
|
650
|
+
}
|
|
651
|
+
// Handle commands
|
|
652
|
+
if (parsedMessage.text.startsWith(this.config.settings.commandPrefix || '/')) {
|
|
653
|
+
await this.handleCommand(parsedMessage);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
this.log(`ERROR: Error processing message: ${error?.message || error}`, 'ERROR');
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
async handleCommand(message) {
|
|
661
|
+
const commandPrefix = this.config.settings.commandPrefix || '/';
|
|
662
|
+
const commandText = message.text.substring(commandPrefix.length);
|
|
663
|
+
const [commandName, ...args] = commandText.split(' ');
|
|
664
|
+
const command = this.commands.get(commandName.toLowerCase());
|
|
665
|
+
if (!command) {
|
|
666
|
+
return; // Unknown command, ignore
|
|
667
|
+
}
|
|
668
|
+
// Check admin permissions
|
|
669
|
+
if (command.adminOnly && !message.isFromAdmin) {
|
|
670
|
+
await this.sendCommandResponse(message, 'ERROR: This command requires admin privileges');
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (this.isOnCooldown(message.source)) {
|
|
674
|
+
this.log(`COOLDOWN: Cooldown active for ${message.source} on command "${commandName}"`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.stats.commandsExecuted++;
|
|
678
|
+
this.emit('command', { command: commandName, user: message.source, args });
|
|
679
|
+
this.userCooldowns.set(message.source, Date.now());
|
|
680
|
+
this.log(`- Executing command: "${command.name}" by ${message.source}`);
|
|
681
|
+
try {
|
|
682
|
+
const response = await command.handler(message, args, this);
|
|
683
|
+
if (response) {
|
|
684
|
+
await this.sendCommandResponse(message, response);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
this.log(`ERROR: Error executing command "${command.name}": ${error?.message || error}`, 'ERROR');
|
|
689
|
+
await this.sendCommandResponse(message, `ERROR: An error occurred while running the command: ${command.name}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async sendCommandResponse(message, response) {
|
|
693
|
+
const recipient = message.groupInfo ?
|
|
694
|
+
(message.groupInfo.id || message.groupInfo.groupId || message.source) : message.source;
|
|
695
|
+
await this.sendMessage(recipient, response);
|
|
696
|
+
}
|
|
697
|
+
isOnCooldown(userId) {
|
|
698
|
+
const lastCommand = this.userCooldowns.get(userId);
|
|
699
|
+
if (!lastCommand)
|
|
700
|
+
return false;
|
|
701
|
+
const now = Date.now();
|
|
702
|
+
const cooldownMs = (this.config.settings.cooldownSeconds || 2) * 1000;
|
|
703
|
+
return (now - lastCommand) < cooldownMs;
|
|
704
|
+
}
|
|
705
|
+
async processActionQueue() {
|
|
706
|
+
if (this.isProcessingQueue || this.actionQueue.length === 0) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
this.isProcessingQueue = true;
|
|
710
|
+
this.log(`Processing action queue with ${this.actionQueue.length} action(s)...`, 'DEBUG');
|
|
711
|
+
try {
|
|
712
|
+
while (this.actionQueue.length > 0) {
|
|
713
|
+
const action = this.actionQueue.shift();
|
|
714
|
+
try {
|
|
715
|
+
switch (action.type) {
|
|
716
|
+
case 'sendMessage':
|
|
717
|
+
this.log(`Executing sendMessage to ${action.recipient}...`, 'DEBUG');
|
|
718
|
+
await this.signalCli.sendMessage(action.recipient, action.message);
|
|
719
|
+
break;
|
|
720
|
+
case 'sendMessageWithAttachment':
|
|
721
|
+
this.log(`Executing sendMessageWithAttachment to ${action.recipient} with ${action.attachments.length} file(s)...`, 'DEBUG');
|
|
722
|
+
await this.signalCli.sendMessage(action.recipient, action.message, {
|
|
723
|
+
attachments: action.attachments
|
|
724
|
+
});
|
|
725
|
+
// Wait a bit for signal-cli to finish processing the files before cleanup
|
|
726
|
+
// signal-cli responds immediately but continues processing files in background
|
|
727
|
+
if (action.cleanup && action.cleanup.length > 0) {
|
|
728
|
+
setTimeout(() => {
|
|
729
|
+
action.cleanup.forEach(filePath => {
|
|
730
|
+
this.cleanupTempFile(filePath);
|
|
731
|
+
});
|
|
732
|
+
}, 2000); // Wait 2 seconds for signal-cli to upload files
|
|
733
|
+
}
|
|
734
|
+
break;
|
|
735
|
+
case 'sendReaction':
|
|
736
|
+
this.log(`Executing sendReaction to ${action.recipient}...`, 'DEBUG');
|
|
737
|
+
await this.signalCli.sendReaction(action.recipient, action.targetAuthor, action.targetTimestamp, action.emoji);
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
// Wait a bit between actions to be safe
|
|
741
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
this.log(`ERROR: Failed to execute action ${action.type}: ${error?.message || error}`, 'ERROR');
|
|
745
|
+
// Clean up temporary files even on error
|
|
746
|
+
if (action.type === 'sendMessageWithAttachment' && action.cleanup && action.cleanup.length > 0) {
|
|
747
|
+
action.cleanup.forEach(filePath => {
|
|
748
|
+
this.cleanupTempFile(filePath);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
this.isProcessingQueue = false;
|
|
756
|
+
this.log('Action queue processed.', 'DEBUG');
|
|
757
|
+
// Process any buffered messages
|
|
758
|
+
while (this.incomingMessageBuffer.length > 0) {
|
|
759
|
+
this.log('Processing buffered message...', 'DEBUG');
|
|
760
|
+
const bufferedMessage = this.incomingMessageBuffer.shift();
|
|
761
|
+
this.handleMessage(bufferedMessage);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
async sendWelcomeMessage() {
|
|
766
|
+
const welcomeText = `Signal Bot Started!
|
|
767
|
+
|
|
768
|
+
` +
|
|
769
|
+
`Bot is now active
|
|
770
|
+
` +
|
|
771
|
+
`Number: ${this.config.phoneNumber}
|
|
772
|
+
` +
|
|
773
|
+
`Group: ${this.config.group ? this.config.group.name : 'None'}
|
|
774
|
+
` +
|
|
775
|
+
`Use ${this.config.settings.commandPrefix}help to see commands
|
|
776
|
+
|
|
777
|
+
` +
|
|
778
|
+
`Happy chatting!`;
|
|
779
|
+
for (const admin of this.config.admins) {
|
|
780
|
+
try {
|
|
781
|
+
await this.sendMessage(admin, welcomeText);
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
this.log(`ERROR: Error sending welcome message to ${admin}: ${error?.message || error}`, 'ERROR');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
formatUptime(ms) {
|
|
789
|
+
const seconds = Math.floor(ms / 1000);
|
|
790
|
+
const minutes = Math.floor(seconds / 60);
|
|
791
|
+
const hours = Math.floor(minutes / 60);
|
|
792
|
+
const days = Math.floor(hours / 24);
|
|
793
|
+
if (days > 0)
|
|
794
|
+
return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
795
|
+
if (hours > 0)
|
|
796
|
+
return `${hours}h ${minutes % 60}m`;
|
|
797
|
+
if (minutes > 0)
|
|
798
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
799
|
+
return `${seconds}s`;
|
|
800
|
+
}
|
|
801
|
+
log(message, level = 'INFO') {
|
|
802
|
+
const timestamp = new Date().toISOString();
|
|
803
|
+
const logMessage = `[${timestamp}] [${level}] ${message}`;
|
|
804
|
+
console.log(logMessage);
|
|
805
|
+
if (this.config.settings.logMessages) {
|
|
806
|
+
// In production, you might want to write to a file
|
|
807
|
+
// fs.appendFileSync('bot.log', logMessage + '\n');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
exports.SignalBot = SignalBot;
|