import { Injectable, Logger } from '@nestjs/common'; import axios, { AxiosInstance } from 'axios'; interface SignalAccount { number: string; uuid: string; username?: string; } export interface SignalStatus { isConnected: boolean; isLinked: boolean; phoneNumber: string | null; error?: string; } export interface QRCodeResponse { qrcode: string; expiresAt?: number; } @Injectable() export class SignalService { private readonly logger = new Logger(SignalService.name); private readonly client: AxiosInstance; private readonly baseUrl: string; constructor() { this.baseUrl = process.env.SIGNAL_API_URL || 'http://localhost:8080'; this.client = axios.create({ baseURL: this.baseUrl, timeout: 30000, }); } /** * Check if Signal API is available and get connection status */ async getStatus(): Promise { try { // Check if API is reachable const response = await this.client.get('/v1/about'); // Try to get registered accounts // API returns array of phone number strings: ["+1234567890"] const accountsResponse = await this.client.get('/v1/accounts'); const accounts: string[] = accountsResponse.data; if (accounts.length > 0) { return { isConnected: true, isLinked: true, phoneNumber: accounts[0], }; } return { isConnected: true, isLinked: false, phoneNumber: null, }; } catch (error: any) { this.logger.error('Failed to connect to Signal API:', error.message); return { isConnected: false, isLinked: false, phoneNumber: null, error: error.code === 'ECONNREFUSED' ? 'Signal API container is not running' : error.message, }; } } /** * Get QR code for linking a new device */ async getQRCodeLink(deviceName: string = 'VIP Coordinator'): Promise { try { // First check if already linked const status = await this.getStatus(); if (status.isLinked) { this.logger.warn('Device already linked to Signal'); return null; } // Request QR code for device linking - returns raw PNG image const response = await this.client.get('/v1/qrcodelink', { params: { device_name: deviceName }, timeout: 60000, // QR generation can take a moment responseType: 'arraybuffer', // Get raw binary data }); // Convert to base64 const base64 = Buffer.from(response.data, 'binary').toString('base64'); return { qrcode: base64, }; } catch (error: any) { this.logger.error('Failed to get QR code:', error.message); throw error; } } /** * Register a new phone number (requires verification) */ async registerNumber(phoneNumber: string, captcha?: string): Promise<{ success: boolean; message: string; captchaRequired?: boolean; captchaUrl?: string; }> { try { const response = await this.client.post(`/v1/register/${phoneNumber}`, { captcha, use_voice: false, }); return { success: true, message: 'Verification code sent. Check your phone.', }; } catch (error: any) { const errorMessage = error.response?.data?.error || error.message; this.logger.error('Failed to register number:', errorMessage); // Check if CAPTCHA is required const isCaptchaRequired = errorMessage.toLowerCase().includes('captcha') || error.response?.status === 402; // Signal uses 402 for captcha requirement if (isCaptchaRequired) { return { success: false, captchaRequired: true, captchaUrl: 'https://signalcaptchas.org/registration/generate.html', message: 'CAPTCHA verification required. Please solve the CAPTCHA and submit the token.', }; } return { success: false, message: errorMessage, }; } } /** * Verify a phone number with the code received */ async verifyNumber(phoneNumber: string, verificationCode: string): Promise<{ success: boolean; message: string }> { try { const response = await this.client.post(`/v1/register/${phoneNumber}/verify/${verificationCode}`); return { success: true, message: 'Phone number verified and linked successfully!', }; } catch (error: any) { this.logger.error('Failed to verify number:', error.message); return { success: false, message: error.response?.data?.error || error.message, }; } } /** * Unlink/unregister the current account */ async unlinkAccount(phoneNumber: string): Promise<{ success: boolean; message: string }> { try { // Use POST /v1/unregister/{number} - the correct Signal API endpoint await this.client.post(`/v1/unregister/${phoneNumber}`); return { success: true, message: 'Account unlinked successfully', }; } catch (error: any) { this.logger.error('Failed to unlink account:', error.message); return { success: false, message: error.response?.data?.error || error.message, }; } } /** * Send a message to a recipient */ async sendMessage( fromNumber: string, toNumber: string, message: string, ): Promise<{ success: boolean; timestamp?: number; error?: string }> { try { const response = await this.client.post(`/v2/send`, { number: fromNumber, recipients: [toNumber], message, }); this.logger.log(`Message sent to ${toNumber}`); return { success: true, timestamp: response.data.timestamp, }; } catch (error: any) { this.logger.error(`Failed to send message to ${toNumber}:`, error.message); return { success: false, error: error.response?.data?.error || error.message, }; } } /** * Send a message to multiple recipients */ async sendBulkMessage( fromNumber: string, toNumbers: string[], message: string, ): Promise<{ success: boolean; sent: number; failed: number; errors: string[] }> { const results = { success: true, sent: 0, failed: 0, errors: [] as string[], }; for (const toNumber of toNumbers) { const result = await this.sendMessage(fromNumber, toNumber, message); if (result.success) { results.sent++; } else { results.failed++; results.errors.push(`${toNumber}: ${result.error}`); } } results.success = results.failed === 0; return results; } /** * Get the linked phone number (if any) */ async getLinkedNumber(): Promise { try { const response = await this.client.get('/v1/accounts'); // API returns array of phone number strings directly: ["+1234567890"] const accounts: string[] = response.data; if (accounts.length > 0) { return accounts[0]; } return null; } catch (error) { return null; } } /** * Format phone number for Signal (must include country code) */ formatPhoneNumber(phone: string): string { // Remove all non-digit characters let cleaned = phone.replace(/\D/g, ''); // Add US country code if not present if (cleaned.length === 10) { cleaned = '1' + cleaned; } // Add + prefix if (!cleaned.startsWith('+')) { cleaned = '+' + cleaned; } return cleaned; } /** * Receive pending messages for the account * This fetches and removes messages from Signal's queue */ async receiveMessages(phoneNumber: string): Promise { try { const response = await this.client.get(`/v1/receive/${phoneNumber}`, { timeout: 10000, }); // Response is an array of message envelopes return response.data || []; } catch (error: any) { // Don't log timeout errors or empty responses as errors if (error.code === 'ECONNABORTED' || error.response?.status === 204) { return []; } throw error; } } /** * Send a message with a file attachment (PDF, image, etc.) * @param fromNumber - The sender's phone number * @param toNumber - The recipient's phone number * @param message - Optional text message to accompany the attachment * @param attachment - Base64 encoded file data * @param filename - Name for the file * @param mimeType - MIME type of the file (e.g., 'application/pdf') */ async sendMessageWithAttachment( fromNumber: string, toNumber: string, message: string, attachment: string, filename: string, mimeType: string = 'application/pdf', ): Promise<{ success: boolean; timestamp?: number; error?: string }> { try { // Format: data:;filename=;base64, const base64Attachment = `data:${mimeType};filename=${filename};base64,${attachment}`; const response = await this.client.post(`/v2/send`, { number: fromNumber, recipients: [toNumber], message: message || '', base64_attachments: [base64Attachment], }); this.logger.log(`Message with attachment sent to ${toNumber}: ${filename}`); return { success: true, timestamp: response.data.timestamp, }; } catch (error: any) { this.logger.error(`Failed to send attachment to ${toNumber}:`, error.message); return { success: false, error: error.response?.data?.error || error.message, }; } } }