import { BadRequestException, Injectable, Logger, ServiceUnavailableException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; export type SmsSendResult = { serverId: string; }; export type SmsBulkSendResult = { serverIds: string[]; recipientCount: number; }; export type SmsSendOptions = { /** Gama shortcode override. When omitted, uses SMS_GAMA_SOURCE_SERVICE. */ source?: string; }; /** Default Meshkee advertising shortcode when a business has no own SMS number. */ export const DEFAULT_ADVERTISING_SMS_SOURCE = '5000990009'; /** Gama batch size limit is undocumented; keep chunks conservative. */ const GAMA_BULK_CHUNK_SIZE = 100; type GamaQuickResponse = { success?: boolean; message?: string | null; errors?: unknown; body?: number | string | null; }; type GamaBulkResponse = { success?: boolean; message?: string | null; errors?: unknown; body?: Array | null; }; @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); constructor(private readonly config: ConfigService) {} isEnabled(): boolean { return this.config.get('SMS_ENABLED', 'false') === 'true'; } /** Advertising / customer-broadcast fallback shortcode. */ getAdvertisingSource(): string { return ( this.config.get('SMS_GAMA_SOURCE_ADVERTISE')?.trim() || DEFAULT_ADVERTISING_SMS_SOURCE ); } /** Meshkee service shortcode (OTP + verified SMS templates). */ getServiceSource(): string { return ( this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim() || '5000110005' ); } /** * Best-effort account credit from Gama. Returns null when the provider * does not expose credit over REST (check the Gama panel instead). */ async getAccountCredit(): Promise<{ balance: number; currency?: string } | null> { if (!this.isEnabled() || this.isProxyConfigured()) { return null; } const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); const baseUrl = ( this.config.get('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1' ).replace(/\/$/, ''); if (!username || !password) return null; // Gama REST docs do not document a credit endpoint; try common paths quietly. for (const path of ['/account/credit', '/credit', '/user/credit']) { try { const response = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ username, password }), signal: AbortSignal.timeout(8_000), }); if (!response.ok) continue; const payload = (await response.json()) as { success?: boolean; body?: number | { credit?: number; balance?: number }; }; if (!payload.success) continue; const raw = typeof payload.body === 'number' ? payload.body : payload.body?.credit ?? payload.body?.balance; if (typeof raw === 'number' && Number.isFinite(raw)) { return { balance: raw }; } } catch { // ignore and try next path } } return null; } async sendVerificationCode( cellNumber: string, code: string, businessNameFa?: string, ): Promise { if (!this.isEnabled()) { this.logger.warn( `SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`, ); return { serverId: 'disabled' }; } const brand = businessNameFa?.trim(); const message = brand ? `کد تایید شما: ${code}\n${brand}` : `کد تایید شما: ${code}`; return this.sendMessage(cellNumber, message); } async sendMessage( cellNumber: string, message: string, options?: SmsSendOptions, ): Promise { if (!this.isEnabled()) { this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`); return { serverId: 'disabled' }; } const text = message.trim(); if (!text) { throw new BadRequestException('Message is empty'); } if (text.length > 700) { throw new BadRequestException('Message is too long (max 700 characters)'); } const destination = toGamaMsisdn(cellNumber); if (!destination) { throw new BadRequestException('Invalid cellphone number'); } const sourceOverride = options?.source?.trim() || undefined; // Local proxy path has no per-sender shortcode unless we pass source through. if (this.isProxyConfigured()) { return this.sendViaProxy(destination, text, sourceOverride); } return this.sendQuick(destination, text, sourceOverride); } /** * One message → many destinations via Gama SendBulk (`/send/bulk`). * Falls back to sequential SendQuick / proxy when only one number or proxy is configured. */ async sendBulkMessage( cellNumbers: string[], message: string, options?: SmsSendOptions, ): Promise { if (!this.isEnabled()) { this.logger.warn( `SMS disabled — bulk message for ${cellNumbers.length} recipients not sent: ${message}`, ); return { serverIds: ['disabled'], recipientCount: cellNumbers.length }; } const text = message.trim(); if (!text) { throw new BadRequestException('Message is empty'); } if (text.length > 700) { throw new BadRequestException('Message is too long (max 700 characters)'); } const destinations: string[] = []; const seen = new Set(); for (const raw of cellNumbers) { const msisdn = toGamaMsisdn(raw); if (!msisdn || seen.has(msisdn)) continue; seen.add(msisdn); destinations.push(msisdn); } if (destinations.length === 0) { throw new BadRequestException('No valid cellphone numbers to send to'); } const sourceOverride = options?.source?.trim() || undefined; if (destinations.length === 1) { const single = await this.sendMessage(destinations[0], text, options); return { serverIds: [single.serverId], recipientCount: 1 }; } // Local proxy only supports single destination — send sequentially. if (this.isProxyConfigured()) { const serverIds: string[] = []; for (const destination of destinations) { const result = await this.sendViaProxy(destination, text, sourceOverride); serverIds.push(result.serverId); } return { serverIds, recipientCount: destinations.length }; } const serverIds: string[] = []; for (let i = 0; i < destinations.length; i += GAMA_BULK_CHUNK_SIZE) { const chunk = destinations.slice(i, i + GAMA_BULK_CHUNK_SIZE); const chunkIds = await this.sendBulk(chunk, text, sourceOverride); serverIds.push(...chunkIds); } return { serverIds, recipientCount: destinations.length }; } private isProxyConfigured(): boolean { const url = this.config.get('SMS_PROXY_URL')?.trim(); const apiKey = this.config.get('SMS_PROXY_API_KEY')?.trim(); const domain = this.config.get('SMS_PROXY_DOMAIN')?.trim(); return Boolean(url && apiKey && domain); } /** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */ private async sendViaProxy( destination: string, message: string, source?: string, ): Promise { const url = this.config.get('SMS_PROXY_URL')!.trim(); const apiKey = this.config.get('SMS_PROXY_API_KEY')!.trim(); const domain = this.config.get('SMS_PROXY_DOMAIN')!.trim(); const masked = maskMsisdn(destination); let response: Response; try { response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Api-Key': apiKey, }, body: JSON.stringify({ domain, to: destination, message, ...(source ? { source } : {}), }), signal: AbortSignal.timeout(20_000), }); } catch (err) { this.logger.error( `SMS proxy network error for ${masked}: ${err instanceof Error ? err.message : err}`, ); throw new ServiceUnavailableException('SMS provider is unreachable'); } let payload: { success?: boolean; serverId?: string; message?: string } = {}; try { payload = (await response.json()) as typeof payload; } catch { this.logger.error(`SMS proxy returned non-JSON for ${masked} (HTTP ${response.status})`); throw new ServiceUnavailableException('SMS provider returned an invalid response'); } if (!response.ok || !payload.success) { const providerMessage = typeof payload.message === 'string' && payload.message.trim() ? payload.message.trim() : `HTTP ${response.status}`; this.logger.warn(`SMS proxy failed for ${masked}: ${providerMessage}`); if (response.status === 400) { throw new BadRequestException(providerMessage || 'Invalid SMS destination or sender'); } if (response.status === 401) { throw new ServiceUnavailableException('SMS provider authentication failed'); } throw new ServiceUnavailableException( providerMessage || 'SMS provider rejected the request', ); } const serverId = String(payload.serverId ?? ''); if (!serverId) { this.logger.warn(`SMS proxy succeeded without serverId for ${masked}`); throw new ServiceUnavailableException('SMS provider returned an empty server id'); } this.logger.log(`SMS sent to ${masked} via proxy (serverId=${serverId})`); return { serverId }; } private async sendQuick( destination: string, message: string, sourceOverride?: string, ): Promise { const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); const source = sourceOverride?.trim() || this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim(); const baseUrl = ( this.config.get('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1' ).replace(/\/$/, ''); if (!username || !password || !source) { this.logger.error('SMS provider credentials are not configured'); throw new ServiceUnavailableException('SMS provider is not configured yet'); } const url = `${baseUrl}/send/quick`; const masked = maskMsisdn(destination); let response: Response; try { response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ username, password, message, source, destination, delay: 0, expire: null, }), signal: AbortSignal.timeout(15_000), }); } catch (err) { this.logger.error( `Gama SendQuick network error for ${masked}: ${err instanceof Error ? err.message : err}`, ); throw new ServiceUnavailableException('SMS provider is unreachable'); } let payload: GamaQuickResponse; try { payload = (await response.json()) as GamaQuickResponse; } catch { this.logger.error(`Gama SendQuick returned non-JSON for ${masked} (HTTP ${response.status})`); throw new ServiceUnavailableException('SMS provider returned an invalid response'); } if (!response.ok || !payload.success) { const providerMessage = typeof payload.message === 'string' && payload.message.trim() ? payload.message.trim() : `HTTP ${response.status}`; this.logger.warn(`Gama SendQuick failed for ${masked}: ${providerMessage}`); throw mapGamaError(providerMessage); } const serverId = String(payload.body ?? ''); if (!serverId) { this.logger.warn(`Gama SendQuick succeeded without serverId for ${masked}`); throw new ServiceUnavailableException('SMS provider returned an empty server id'); } this.logger.log( `SMS sent to ${masked} via Gama source=${source} (serverId=${serverId})`, ); return { serverId }; } /** Gama SendBulk — one message, many MSISDNs (`destinations` array). */ private async sendBulk( destinations: string[], message: string, sourceOverride?: string, ): Promise { const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); const source = sourceOverride?.trim() || this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim(); const baseUrl = ( this.config.get('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1' ).replace(/\/$/, ''); if (!username || !password || !source) { this.logger.error('SMS provider credentials are not configured'); throw new ServiceUnavailableException('SMS provider is not configured yet'); } const url = `${baseUrl}/send/bulk`; let response: Response; try { response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ username, password, message, source, destinations, delay: 0, expire: null, }), signal: AbortSignal.timeout(30_000), }); } catch (err) { this.logger.error( `Gama SendBulk network error for ${destinations.length} recipients: ${ err instanceof Error ? err.message : err }`, ); throw new ServiceUnavailableException('SMS provider is unreachable'); } let payload: GamaBulkResponse; try { payload = (await response.json()) as GamaBulkResponse; } catch { this.logger.error( `Gama SendBulk returned non-JSON for ${destinations.length} recipients (HTTP ${response.status})`, ); throw new ServiceUnavailableException('SMS provider returned an invalid response'); } if (!response.ok || !payload.success) { const providerMessage = typeof payload.message === 'string' && payload.message.trim() ? payload.message.trim() : `HTTP ${response.status}`; this.logger.warn( `Gama SendBulk failed for ${destinations.length} recipients: ${providerMessage}`, ); throw mapGamaError(providerMessage); } const body = Array.isArray(payload.body) ? payload.body : []; const serverIds = body.map((id) => String(id)).filter(Boolean); if (serverIds.length === 0) { this.logger.warn( `Gama SendBulk succeeded without serverIds for ${destinations.length} recipients`, ); throw new ServiceUnavailableException('SMS provider returned an empty server id'); } this.logger.log( `SMS bulk sent to ${destinations.length} via Gama source=${source} (ids=${serverIds.length})`, ); return serverIds; } } /** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */ export function toGamaMsisdn(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) return null; const digits = trimmed.replace(/\D/g, ''); if (!digits) return null; let msisdn = digits; if (msisdn.startsWith('0') && msisdn.length === 11) { msisdn = `98${msisdn.slice(1)}`; } else if (msisdn.length === 10 && msisdn.startsWith('9')) { msisdn = `98${msisdn}`; } else if (msisdn.startsWith('98') && msisdn.length === 12) { // already MSISDN } else if (trimmed.startsWith('+') && digits.length >= 10 && digits.length <= 15) { msisdn = digits; } else { return null; } if (!/^98\d{10}$/.test(msisdn)) { return null; } return msisdn; } /** * Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`). * Rejects landlines, foreign numbers, and invalid formats. */ export function toIranianMobileMsisdn(raw: string): string | null { const msisdn = toGamaMsisdn(raw); if (!msisdn || !/^989\d{9}$/.test(msisdn)) { return null; } return msisdn; } export function maskMsisdn(msisdn: string): string { if (msisdn.length < 6) return '***'; return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`; } function mapGamaError(message: string): Error { const lower = message.toLowerCase(); if ( lower.includes('authentication failed') || lower.includes('access denied') || lower.includes('inactive user') ) { return new ServiceUnavailableException('SMS provider authentication failed'); } if ( lower.includes('credit is not enough') || lower.includes('expired account') ) { return new ServiceUnavailableException('SMS provider credit or account issue'); } if ( lower.includes('cellphone') || lower.includes('valid cellphone') || lower.includes('invalid orig') || lower.includes('arguments') ) { return new BadRequestException('Invalid SMS destination or sender'); } if (lower.includes('message is empty')) { return new BadRequestException('Message is empty'); } if (lower.includes('maximum amount') || lower.includes('batch')) { return new BadRequestException('Too many SMS destinations in one batch'); } return new ServiceUnavailableException('SMS provider rejected the request'); }