- Add GPS module with Traccar client service for device management - Add driver enrollment flow with QR code generation - Add real-time location tracking on driver profiles - Add GPS settings configuration in admin tools - Add Auth0 OpenID Connect setup script for Traccar - Add deployment configs for production server - Update nginx configs for SSL on GPS port 5055 - Add timezone setting support - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
351 lines
9.5 KiB
TypeScript
351 lines
9.5 KiB
TypeScript
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<SignalStatus> {
|
|
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<QRCodeResponse | null> {
|
|
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<string | null> {
|
|
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<any[]> {
|
|
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:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
}
|