diff --git a/.env.example b/.env.example index 6ae7959..49853f0 100644 --- a/.env.example +++ b/.env.example @@ -26,20 +26,12 @@ UPLOAD_DIR=uploads 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 -# Transport: soap (default, recommended) or link (legacy) -# SMS_TRANSPORT=soap -# SMS_SOAP_URL=https://webservice.0098sms.com/service.asmx -# From panel: account username, password (or web service password), approved sender line (FROM) +# From panel: account username, password (or web service password), approved sender line (pnlno) SMS_USERNAME= SMS_PASSWORD= -# Alternative name supported for password (same as some older projects) -# SMS_API_KEY= +# SMS_API_KEY= # alias for SMS_PASSWORD SMS_SENDER= -# Optional; default is 0098 -# SMS_DOMAIN=0098 -# Optional OTP text; {code} is replaced. ASCII-only if provider rejects Persian. -# SMS_OTP_TEMPLATE=Montime code: {code} -# Optional request timeout in ms (default 15000) -# SMS_TIMEOUT_MS=15000 -# Optional override for send URL -# SMS_SEND_URL=http://www.0098sms.com/sendsmslink.aspx +# soap (default, recommended) | link (legacy GET API, often HTTP 500) +SMS_TRANSPORT=soap +# SMS_DOMAIN=0098 # only for link transport +# SMS_TIMEOUT_MS=20000 diff --git a/scripts/test-sms.mjs b/scripts/test-sms.mjs index d6b0644..a4ebfe9 100644 --- a/scripts/test-sms.mjs +++ b/scripts/test-sms.mjs @@ -1,18 +1,17 @@ import 'dotenv/config'; const username = process.env.SMS_USERNAME; -const password = process.env.SMS_PASSWORD; +const password = process.env.SMS_PASSWORD ?? process.env.SMS_API_KEY; const sender = process.env.SMS_SENDER; if (!username || !password || !sender) { - console.error('Missing SMS env vars'); + console.error('Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER in .env'); process.exit(1); } -const text = 'Montime code: 123456'; const targets = process.argv.slice(2).length ? process.argv.slice(2) - : ['09397439581', '09128081392']; + : ['09397439581']; function escapeXml(value) { return String(value) @@ -23,9 +22,11 @@ function escapeXml(value) { .replace(/'/g, '''); } +const text = 'کد ورود منتایم: 123456'; + for (const mobile of targets) { const soapBody = ` - + ${escapeXml(username)} @@ -44,10 +45,15 @@ for (const mobile of targets) { SOAPAction: 'http://tempuri.org/SendSMS', }, body: soapBody, - signal: AbortSignal.timeout(20000), + signal: AbortSignal.timeout(20_000), }); const body = await res.text(); - const match = body.match(/]*>([^<]*)<\/SendSMSResult>/); - console.log(mobile, 'HTTP', res.status, 'RESULT', match?.[1] ?? body.slice(0, 200)); + const match = body.match(/]*>([^<]*)<\/SendSMSResult>/i); + const code = match?.[1]?.trim() ?? '?'; + + console.log(`mobile=${mobile} http=${res.status} RESULT=${code}`); + if (code !== '0') { + console.log(body.slice(0, 400)); + } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index f7b1f54..760295c 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -98,13 +98,17 @@ export class AuthService { 'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.', ); } - const result = await this.sms.sendOtpSmsDetailed(mobile, code); + const result = await this.sms.sendSmsDetailed( + mobile, + `کد ورود منتایم: ${code}`, + ); if (!result.ok) { + const detail = result.reason ? ` (${result.reason})` : ''; this.logger.warn( `OTP SMS failed for ${this.maskMobile(mobile)} transport=${result.transport} reason=${result.reason}`, ); throw new ServiceUnavailableException( - `Could not send SMS. Please try again later. (${result.reason})`, + `Could not send SMS. Please try again later.${detail}`, ); } } diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts index b4d3dba..7114279 100644 --- a/src/modules/auth/sms.service.ts +++ b/src/modules/auth/sms.service.ts @@ -1,10 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -/** Legacy link API — often returns HTTP 500; kept as fallback only. */ +/** Legacy GET API — often returns HTTP 500; kept as optional fallback. */ const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx'; -/** Current 0098 SOAP endpoint (works reliably). */ +/** Official 0098 SOAP API (works when link endpoint is down). */ const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx'; const SMS_0098_ERROR_CODES: Record = { @@ -23,11 +23,10 @@ const SMS_0098_ERROR_CODES: Record = { '13': 'invalid characters in message text', }; -type SendResult = { +export type SmsSendResult = { ok: boolean; - code?: string; - reason: string; transport: 'soap' | 'link'; + reason?: string; }; @Injectable() @@ -36,7 +35,6 @@ export class SmsService { constructor(private readonly config: ConfigService) {} - /** True when username, password, and sender line are all set. */ isConfigured(): boolean { const username = this.config.get('SMS_USERNAME'); const password = this.getPassword(); @@ -44,9 +42,6 @@ export class SmsService { return Boolean(username && password && sender); } - /** - * Formats mobile for 0098 (Iranian mobiles as 09xxxxxxxxx). - */ normalizePhoneForSms(mobile: string): string { const digits = String(mobile ?? '').replace(/\D/g, ''); if (digits.startsWith('98') && digits.length === 12) { @@ -62,67 +57,46 @@ export class SmsService { } async sendOtpSms(mobile: string, code: string): Promise { - const result = await this.sendOtpSmsDetailed(mobile, code); + const text = `کد ورود منتایم: ${code}`; + const result = await this.sendSmsDetailed(mobile, text); return result.ok; } - async sendOtpSmsDetailed(mobile: string, code: string): Promise { - const template = - this.config.get('SMS_OTP_TEMPLATE') ?? - 'کد ورود منتایم: {code}'; - const text = template.replace('{code}', code); - return this.sendSmsDetailed(mobile, text); - } - async sendSms(phone: string, text: string): Promise { const result = await this.sendSmsDetailed(phone, text); return result.ok; } - async sendSmsDetailed(phone: string, text: string): Promise { + 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, reason: 'SMS not configured', transport: 'soap' }; + return { ok: false, transport: 'soap', reason: 'SMS not configured' }; } const to = this.normalizePhoneForSms(phone); - const transport = - (this.config.get('SMS_TRANSPORT') ?? 'soap').toLowerCase() === - 'link' - ? 'link' - : 'soap'; + const transport = this.resolveTransport(); if (transport === 'link') { - const linkResult = await this.sendViaLink( - username, - password, - sender, - to, - text, - ); - if (linkResult.ok) { - return linkResult; - } - this.logger.warn( - `SMS link transport failed to=${this.maskPhone(to)} reason=${linkResult.reason}; trying SOAP`, - ); + return this.sendViaLink(username, password, sender, to, text); } - const soapResult = await this.sendViaSoap( - username, - password, - sender, - to, - text, - ); - if (soapResult.ok || transport === 'soap') { + const soapResult = await this.sendViaSoap(username, password, sender, to, text); + if (soapResult.ok) { return soapResult; } - return soapResult; + this.logger.warn( + `SOAP SMS failed to=${this.maskPhone(to)} reason=${soapResult.reason}; trying link fallback`, + ); + return this.sendViaLink(username, password, sender, to, text); + } + + private resolveTransport(): 'soap' | 'link' { + const raw = (this.config.get('SMS_TRANSPORT') ?? 'soap').toLowerCase(); + return raw === 'link' ? 'link' : 'soap'; } private async sendViaSoap( @@ -131,12 +105,8 @@ export class SmsService { sender: string, to: string, text: string, - ): Promise { - const soapUrl = - this.config.get('SMS_SOAP_URL') ?? SMS_0098_SOAP_URL; - const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 20000); - - const body = ` + ): Promise { + const soapBody = ` @@ -149,46 +119,47 @@ export class SmsService { `; + const timeoutMs = this.readTimeoutMs(); + try { - const res = await fetch(soapUrl, { + const res = await fetch(SMS_0098_SOAP_URL, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: 'http://tempuri.org/SendSMS', }, - body, + body: soapBody, signal: AbortSignal.timeout(timeoutMs), }); - const responseBody = (await res.text()).trim(); + const body = (await res.text()).trim(); if (!res.ok) { - this.logger.warn( - `SMS SOAP HTTP ${res.status} to=${this.maskPhone(to)} body=${responseBody.slice(0, 300)}`, - ); return { ok: false, - reason: `SOAP HTTP ${res.status}`, transport: 'soap', + reason: `HTTP ${res.status}`, }; } - const match = responseBody.match( - /]*>([^<]*)<\/SendSMSResult>/i, - ); - const code = match?.[1]?.trim() ?? responseBody; - const parsed = this.parse0098Response(code); - if (!parsed.ok) { - this.logger.warn( - `SMS SOAP rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason}`, - ); + const code = this.parseSoapResultCode(body); + if (code === '0') { + return { ok: true, transport: 'soap' }; } - return { ...parsed, transport: 'soap' }; - } catch (e) { - this.logger.error( - `SMS SOAP request failed to=${this.maskPhone(to)}`, - e instanceof Error ? e.stack : e, + + const reason = + SMS_0098_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)}`, ); - return { ok: false, reason: 'SOAP request failed', transport: 'soap' }; + 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}`, + e instanceof Error ? e.stack : undefined, + ); + return { ok: false, transport: 'soap', reason: 'SOAP request failed' }; } } @@ -198,88 +169,75 @@ export class SmsService { sender: string, to: string, text: string, - ): Promise { + ): Promise { const domain = this.config.get('SMS_DOMAIN') ?? '0098'; - const sendUrl = this.config.get('SMS_SEND_URL') ?? SMS_0098_LINK_URL; - const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 20000); - const params = new URLSearchParams({ FROM: sender, - To: to, - Text: text, + TO: to, + TEXT: text, USERNAME: username, PASSWORD: password, DOMAIN: domain, }); - const url = `${sendUrl}?${params.toString()}`; + const url = `${SMS_0098_LINK_URL}?${params.toString()}`; + const timeoutMs = this.readTimeoutMs(); try { - const res = await fetch(url, { - signal: AbortSignal.timeout(timeoutMs), - }); + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); const body = (await res.text()).trim(); - if (!res.ok) { - this.logger.warn( - `SMS link HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`, - ); return { ok: false, - reason: `link HTTP ${res.status}`, transport: 'link', + reason: `HTTP ${res.status}`, }; } - - const parsed = this.parse0098Response(body); - if (!parsed.ok) { - this.logger.warn( - `SMS link rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`, - ); + if (!this.isLikelySuccessLinkBody(body)) { + return { + ok: false, + transport: 'link', + reason: body.slice(0, 120) || 'link API rejected', + }; } - return { ...parsed, transport: 'link' }; + return { ok: true, transport: 'link' }; } catch (e) { - this.logger.error( - `SMS link request failed to=${this.maskPhone(to)}`, - e instanceof Error ? e.stack : e, - ); - return { ok: false, reason: 'link request failed', transport: 'link' }; + const message = e instanceof Error ? e.message : String(e); + this.logger.error(`Link SMS failed to=${this.maskPhone(to)}: ${message}`); + return { ok: false, transport: 'link', reason: 'link request failed' }; } } - private parse0098Response(body: string): { - ok: boolean; - code?: string; - reason: string; - } { + private parseSoapResultCode(xml: string): string | null { + const match = + xml.match(/]*>([^<]*)<\/SendSMSResult>/i) ?? + xml.match(/([^<]*)<\/SendSMSResult>/i); + if (match?.[1] != null) { + return match[1].trim(); + } + if (xml.includes('>0<')) { + return '0'; + } + return null; + } + + private isLikelySuccessLinkBody(body: string): boolean { if (!body) { - return { ok: false, reason: 'empty response' }; + return true; } - - const trimmed = body.trim(); - - if (/^\d+$/.test(trimmed)) { - if (trimmed === '0') { - return { ok: true, code: '0', reason: 'successful' }; - } - const reason = SMS_0098_ERROR_CODES[trimmed] ?? `provider code ${trimmed}`; - return { ok: false, code: trimmed, reason }; - } - - if (trimmed.startsWith('0')) { - return { ok: true, code: '0', reason: 'successful' }; - } - - const lower = trimmed.toLowerCase(); + const lower = body.toLowerCase(); if ( lower.includes('error') || lower.includes('fail') || lower.includes('invalid') ) { - return { ok: false, reason: trimmed.slice(0, 120) }; + return false; } - - return { ok: true, reason: 'accepted' }; + const code = body.trim(); + if (/^\d+$/.test(code) && code !== '0') { + return false; + } + return true; } private escapeXml(value: string): string { @@ -291,10 +249,10 @@ export class SmsService { .replace(/'/g, '''); } - private readPositiveInt(key: string, fallback: number): number { - const raw = this.config.get(key); - const n = raw ? Number.parseInt(raw, 10) : NaN; - return Number.isFinite(n) && n > 0 ? n : fallback; + 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 {