import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; /** پنل ترِز / رایگان — همان الگوی montime */ const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir'; const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx'; const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx'; const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx'; export type SmsSendResult = { ok: boolean; transport: 'trez' | 'soap' | 'link'; reason?: string; }; @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); constructor(private readonly config: ConfigService) {} isConfigured(): boolean { const username = this.config.get('SMS_USERNAME'); const password = this.getPassword(); const sender = this.config.get('SMS_SENDER'); return Boolean(username && password && sender); } normalizePhoneForSms(mobile: string): string { const digits = String(mobile ?? '').replace(/\D/g, ''); if (digits.startsWith('98') && digits.length === 12) { return `0${digits.slice(2)}`; } if (digits.startsWith('0') && digits.length === 11) { return digits; } if (digits.length === 10 && digits.startsWith('9')) { return `0${digits}`; } return digits; } async sendOtpSms(mobile: string, code: string): Promise { const template = this.config.get('SMS_OTP_TEMPLATE')?.trim() || `کد ورود منتکت: {code}`; const text = template.replace('{code}', code); const result = await this.sendSmsDetailed(mobile, text); return result.ok; } async sendSmsDetailed(phone: string, text: string): Promise { const username = this.config.get('SMS_USERNAME'); const password = this.getPassword(); const sender = this.config.get('SMS_SENDER'); if (!username || !password || !sender) { return { ok: false, transport: 'trez', reason: 'SMS not configured' }; } const to = this.normalizePhoneForSms(phone); const provider = this.resolveProvider(); if (provider === 'trez') { return this.sendViaTrez(username, password, sender, to, text); } const transport = this.resolve0098Transport(); if (transport === 'link') { return this.sendVia0098Link(username, password, sender, to, text); } const soapResult = await this.sendVia0098Soap( username, password, sender, to, text, ); if (soapResult.ok) return soapResult; this.logger.warn( `0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; link fallback`, ); return this.sendVia0098Link(username, password, sender, to, text); } private resolveProvider(): 'trez' | '0098' { const raw = (this.config.get('SMS_PROVIDER') ?? 'trez').toLowerCase(); return raw === '0098' ? '0098' : 'trez'; } private resolve0098Transport(): 'soap' | 'link' { const raw = (this.config.get('SMS_TRANSPORT') ?? 'soap').toLowerCase(); return raw === 'link' ? 'link' : 'soap'; } private async sendViaTrez( username: string, password: string, sender: string, to: string, text: string, ): Promise { const baseUrl = ( this.config.get('SMS_BASE_URL') ?? SMS_TREZ_DEFAULT_BASE_URL ).replace(/\/$/, ''); const path = this.config.get('SMS_SEND_PATH') ?? SMS_TREZ_DEFAULT_SEND_PATH; const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; const body = new URLSearchParams({ PhoneNumber: sender, Username: username, Password: password, Mobile: to, Message: text, }); try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: AbortSignal.timeout(this.readTimeoutMs()), }); const responseBody = (await res.text()).trim(); if (!res.ok) { return { ok: false, transport: 'trez', reason: `HTTP ${res.status}` }; } const parsed = this.parseTrezResponse(responseBody); if (!parsed.ok) { this.logger.warn( `Trez rejected to=${this.maskPhone(to)} reason=${parsed.reason}`, ); } return { ...parsed, transport: 'trez' }; } catch (e) { const message = e instanceof Error ? e.message : String(e); this.logger.error(`Trez failed to=${this.maskPhone(to)}: ${message}`); return { ok: false, transport: 'trez', reason: 'Trez request failed' }; } } private parseTrezResponse(body: string): { ok: boolean; reason: string } { if (!body) return { ok: true, reason: 'accepted (empty response)' }; const trimmed = body.trim(); const lower = trimmed.toLowerCase(); if ( lower.includes('error') || lower.includes('fail') || lower.includes('invalid') || lower.includes('خطا') ) { return { ok: false, reason: trimmed.slice(0, 120) }; } if (/^-?\d+$/.test(trimmed)) { const n = Number(trimmed); if (n === 1 || n > 1) return { ok: true, reason: 'accepted' }; if (n === 0) return { ok: false, reason: 'provider code 0' }; if (n < 0) return { ok: false, reason: `provider code ${trimmed}` }; } if (lower === 'true' || lower === 'ok' || lower === 'success') { return { ok: true, reason: 'accepted' }; } if (lower === 'false') return { ok: false, reason: 'provider returned false' }; return { ok: true, reason: 'accepted' }; } private async sendVia0098Soap( username: string, password: string, sender: string, to: string, text: string, ): Promise { const soapBody = ` ${this.escapeXml(username)} ${this.escapeXml(password)} ${this.escapeXml(text)} ${this.escapeXml(to)} ${this.escapeXml(sender)} `; try { const res = await fetch(SMS_0098_SOAP_URL, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: 'http://tempuri.org/SendSMS', }, body: soapBody, signal: AbortSignal.timeout(this.readTimeoutMs()), }); const body = (await res.text()).trim(); if (!res.ok) return { ok: false, transport: 'soap', reason: `HTTP ${res.status}` }; const code = body.match(/]*>([^<]*)<\/SendSMSResult>/i)?.[1]?.trim() ?? null; const n = code != null ? Number(code) : NaN; if (Number.isFinite(n) && n >= 0) return { ok: true, transport: 'soap' }; return { ok: false, transport: 'soap', reason: code ? `provider code ${code}` : 'unknown SOAP response', }; } catch { return { ok: false, transport: 'soap', reason: 'SOAP request failed' }; } } private async sendVia0098Link( username: string, password: string, sender: string, to: string, text: string, ): Promise { const domain = this.config.get('SMS_DOMAIN') ?? '0098'; const params = new URLSearchParams({ FROM: sender, TO: to, TEXT: text, USERNAME: username, PASSWORD: password, DOMAIN: domain, }); try { const res = await fetch(`${SMS_0098_LINK_URL}?${params}`, { signal: AbortSignal.timeout(this.readTimeoutMs()), }); const body = (await res.text()).trim(); if (!res.ok) return { ok: false, transport: 'link', reason: `HTTP ${res.status}` }; const lower = body.toLowerCase(); if (lower.includes('error') || lower.includes('fail')) { return { ok: false, transport: 'link', reason: body.slice(0, 120) }; } return { ok: true, transport: 'link' }; } catch { return { ok: false, transport: 'link', reason: 'link request failed' }; } } private escapeXml(value: string): string { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } private readTimeoutMs(): number { const raw = this.config.get('SMS_TIMEOUT_MS'); const n = raw ? Number.parseInt(raw, 10) : 20_000; return Number.isFinite(n) && n > 0 ? n : 20_000; } private maskPhone(phone: string): string { if (phone.length <= 6) return '***'; return `${phone.slice(0, 4)}****${phone.slice(-2)}`; } private getPassword(): string | undefined { return ( this.config.get('SMS_PASSWORD') ?? this.config.get('SMS_API_KEY') ); } }