diff --git a/.env.example b/.env.example index c16b76c..d300b7c 100644 --- a/.env.example +++ b/.env.example @@ -25,16 +25,22 @@ UPLOAD_DIR=uploads # Include both dev ports if calling API directly (without Vite /api proxy) CORS_ORIGINS=https://montime.ir,https://adm.montime.ir,http://localhost:5173,http://localhost:5174 -# 0098sms.com — used when AUTH_RETURN_OTP is not true -# From panel: account username, password (or web service password), approved sender line (pnlno) +# SMS — used when AUTH_RETURN_OTP is not true +# Provider: trez (default, smspanel.Trez.ir) | 0098 (legacy) +SMS_PROVIDER=trez SMS_USERNAME= SMS_PASSWORD= # SMS_API_KEY= # alias for SMS_PASSWORD +# Trez/Raygan sender line (PhoneNumber), e.g. 90002847 SMS_SENDER= -# soap (default, recommended) | link (legacy GET API, often HTTP 500) -SMS_TRANSPORT=soap -# SMS_DOMAIN=0098 # only for link transport +# Trez panel base URL and send path (defaults shown) +# SMS_BASE_URL=http://smspanel.Trez.ir +# SMS_SEND_PATH=/SendMessageWithCode.ashx +# SMS_OTP_TEMPLATE=کد ورود منتایم: {code} # SMS_TIMEOUT_MS=20000 +# --- legacy 0098 only (SMS_PROVIDER=0098) --- +# SMS_TRANSPORT=soap +# SMS_DOMAIN=0098 # Minimum minutes before a booking can be scheduled (default 60) BOOKING_MIN_LEAD_MINUTES=60 diff --git a/scripts/test-sms.mjs b/scripts/test-sms.mjs index f2946d4..5daf80f 100644 --- a/scripts/test-sms.mjs +++ b/scripts/test-sms.mjs @@ -3,6 +3,7 @@ import 'dotenv/config'; const username = process.env.SMS_USERNAME; const password = process.env.SMS_PASSWORD ?? process.env.SMS_API_KEY; const sender = process.env.SMS_SENDER; +const provider = (process.env.SMS_PROVIDER ?? 'trez').toLowerCase(); if (!username || !password || !sender) { console.error('Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER in .env'); @@ -11,20 +12,60 @@ if (!username || !password || !sender) { const targets = process.argv.slice(2).length ? process.argv.slice(2) - : ['09397439581']; - -function escapeXml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} + : ['09128081392']; const text = 'کد ورود منتایم: 123456'; +const timeoutMs = Number.parseInt(process.env.SMS_TIMEOUT_MS ?? '20000', 10); + +function parseTrezResponse(body) { + const trimmed = body.trim(); + if (!trimmed) return { ok: true, reason: 'empty' }; + const lower = trimmed.toLowerCase(); + if (lower.includes('error') || lower.includes('fail') || lower.includes('خطا')) { + return { ok: false, reason: trimmed }; + } + if (/^-?\d+$/.test(trimmed)) { + const n = Number(trimmed); + if (n === 1 || n > 1) return { ok: true, reason: trimmed }; + return { ok: false, reason: `code ${trimmed}` }; + } + return { ok: true, reason: trimmed }; +} + +async function sendViaTrez(mobile) { + const baseUrl = (process.env.SMS_BASE_URL ?? 'http://smspanel.Trez.ir').replace(/\/$/, ''); + const path = process.env.SMS_SEND_PATH ?? '/SendMessageWithCode.ashx'; + const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + PhoneNumber: sender, + Username: username, + Password: password, + Mobile: mobile, + Message: text, + }).toString(), + signal: AbortSignal.timeout(timeoutMs), + }); + + const body = await res.text(); + const parsed = parseTrezResponse(body); + console.log( + `mobile=${mobile} provider=trez http=${res.status} body=${JSON.stringify(body.trim())} ${parsed.ok ? 'OK' : 'FAILED'} (${parsed.reason})`, + ); +} + +async function sendVia0098(mobile) { + const escapeXml = (value) => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); -for (const mobile of targets) { const soapBody = ` @@ -45,7 +86,7 @@ for (const mobile of targets) { SOAPAction: 'http://tempuri.org/SendSMS', }, body: soapBody, - signal: AbortSignal.timeout(20_000), + signal: AbortSignal.timeout(timeoutMs), }); const body = await res.text(); @@ -53,10 +94,16 @@ for (const mobile of targets) { const code = match?.[1]?.trim() ?? '?'; const n = Number(code); const ok = Number.isFinite(n) ? n >= 0 : false; - const status = ok ? 'OK (accepted by 0098)' : 'FAILED'; + console.log( + `mobile=${mobile} provider=0098 http=${res.status} RESULT=${code} ${ok ? 'OK' : 'FAILED'}`, + ); +} - console.log(`mobile=${mobile} http=${res.status} RESULT=${code} ${status}`); - if (!ok) { - console.log(body.slice(0, 400)); +console.log(`SMS_PROVIDER=${provider}`); +for (const mobile of targets) { + if (provider === '0098') { + await sendVia0098(mobile); + } else { + await sendViaTrez(mobile); } } diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts index fbfab56..821a11e 100644 --- a/src/modules/auth/sms.service.ts +++ b/src/modules/auth/sms.service.ts @@ -1,20 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -/** Legacy GET API — often returns HTTP 500; kept as optional fallback. */ +/** Legacy 0098 GET API — kept when SMS_PROVIDER=0098. */ const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx'; -/** Official 0098 SOAP API (works when link endpoint is down). */ +/** Legacy 0098 SOAP API — kept when SMS_PROVIDER=0098. */ const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx'; -/** SOAP SendSMSResult: 0 or positive = accepted (often SMS part count); negative = error. */ const SMS_0098_SOAP_ERROR_CODES: Record = { '-22': 'invalid mobile number format', }; +/** Trez / Raygan panel (default). */ +const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir'; +const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx'; + export type SmsSendResult = { ok: boolean; - transport: 'soap' | 'link'; + transport: 'trez' | 'soap' | 'link'; reason?: string; }; @@ -46,7 +49,10 @@ export class SmsService { } async sendOtpSms(mobile: string, code: string): Promise { - const text = `کد ورود منتایم: ${code}`; + const template = + this.config.get('SMS_OTP_TEMPLATE') ?? + 'کد ورود منتایم: {code}'; + const text = template.replace('{code}', code); const result = await this.sendSmsDetailed(mobile, text); return result.ok; } @@ -62,33 +68,153 @@ export class SmsService { const sender = this.config.get('SMS_SENDER'); if (!username || !password || !sender) { - return { ok: false, transport: 'soap', reason: 'SMS not configured' }; + return { ok: false, transport: 'trez', reason: 'SMS not configured' }; } const to = this.normalizePhoneForSms(phone); - const transport = this.resolveTransport(); + const provider = this.resolveProvider(); - if (transport === 'link') { - return this.sendViaLink(username, password, sender, to, text); + if (provider === 'trez') { + return this.sendViaTrez(username, password, sender, to, text); } - const soapResult = await this.sendViaSoap(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( - `SOAP SMS failed to=${this.maskPhone(to)} reason=${soapResult.reason}; trying link fallback`, + `0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; trying link fallback`, ); - return this.sendViaLink(username, password, sender, to, text); + return this.sendVia0098Link(username, password, sender, to, text); } - private resolveTransport(): 'soap' | 'link' { + 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 sendViaSoap( + 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 timeoutMs = this.readTimeoutMs(); + + 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(timeoutMs), + }); + + const responseBody = (await res.text()).trim(); + if (!res.ok) { + this.logger.warn( + `Trez HTTP ${res.status} to=${this.maskPhone(to)} body=${responseBody.slice(0, 200)}`, + ); + 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} body=${responseBody.slice(0, 200)}`, + ); + } + return { ...parsed, transport: 'trez' }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + this.logger.error( + `Trez request failed to=${this.maskPhone(to)}: ${message}`, + e instanceof Error ? e.stack : undefined, + ); + 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' }; + } + + // Non-numeric IDs (message ref) are treated as success. + return { ok: true, reason: 'accepted' }; + } + + private async sendVia0098Soap( username: string, password: string, sender: string, @@ -139,20 +265,20 @@ export class SmsService { SMS_0098_SOAP_ERROR_CODES[code ?? ''] ?? (code ? `provider code ${code}` : 'unknown SOAP response'); this.logger.warn( - `SOAP rejected to=${this.maskPhone(to)} code=${code ?? '?'} body=${body.slice(0, 200)}`, + `0098 SOAP rejected to=${this.maskPhone(to)} code=${code ?? '?'} body=${body.slice(0, 200)}`, ); return { ok: false, transport: 'soap', reason }; } catch (e) { const message = e instanceof Error ? e.message : String(e); this.logger.error( - `SOAP request failed to=${this.maskPhone(to)}: ${message}`, + `0098 SOAP request failed to=${this.maskPhone(to)}: ${message}`, e instanceof Error ? e.stack : undefined, ); return { ok: false, transport: 'soap', reason: 'SOAP request failed' }; } } - private async sendViaLink( + private async sendVia0098Link( username: string, password: string, sender: string, @@ -192,7 +318,7 @@ export class SmsService { return { ok: true, transport: 'link' }; } catch (e) { const message = e instanceof Error ? e.message : String(e); - this.logger.error(`Link SMS failed to=${this.maskPhone(to)}: ${message}`); + this.logger.error(`0098 link SMS failed to=${this.maskPhone(to)}: ${message}`); return { ok: false, transport: 'link', reason: 'link request failed' }; } }