diff --git a/.env.example b/.env.example index 0f1c608..6ae7959 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,9 @@ 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) SMS_USERNAME= SMS_PASSWORD= diff --git a/scripts/test-sms.mjs b/scripts/test-sms.mjs new file mode 100644 index 0000000..d6b0644 --- /dev/null +++ b/scripts/test-sms.mjs @@ -0,0 +1,53 @@ +import 'dotenv/config'; + +const username = process.env.SMS_USERNAME; +const password = process.env.SMS_PASSWORD; +const sender = process.env.SMS_SENDER; + +if (!username || !password || !sender) { + console.error('Missing SMS env vars'); + process.exit(1); +} + +const text = 'Montime code: 123456'; +const targets = process.argv.slice(2).length + ? process.argv.slice(2) + : ['09397439581', '09128081392']; + +function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +for (const mobile of targets) { + const soapBody = ` + + + + ${escapeXml(username)} + ${escapeXml(password)} + ${escapeXml(text)} + ${escapeXml(mobile)} + ${escapeXml(sender)} + + +`; + + const res = await fetch('https://webservice.0098sms.com/service.asmx', { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + SOAPAction: 'http://tempuri.org/SendSMS', + }, + body: soapBody, + signal: AbortSignal.timeout(20000), + }); + + const body = await res.text(); + const match = body.match(/]*>([^<]*)<\/SendSMSResult>/); + console.log(mobile, 'HTTP', res.status, 'RESULT', match?.[1] ?? body.slice(0, 200)); +} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 22b2e3d..f7b1f54 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -98,13 +98,13 @@ export class AuthService { 'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.', ); } - const sent = await this.sms.sendOtpSms(mobile, code); - if (!sent) { + const result = await this.sms.sendOtpSmsDetailed(mobile, code); + if (!result.ok) { this.logger.warn( - `OTP SMS failed for ${this.maskMobile(mobile)}`, + `OTP SMS failed for ${this.maskMobile(mobile)} transport=${result.transport} reason=${result.reason}`, ); throw new ServiceUnavailableException( - 'Could not send SMS. Please try again later.', + `Could not send SMS. Please try again later. (${result.reason})`, ); } } diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts index 824bb5b..b4d3dba 100644 --- a/src/modules/auth/sms.service.ts +++ b/src/modules/auth/sms.service.ts @@ -1,9 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -/** HTTP endpoint documented by 0098sms.com for sending via query string. */ -const SMS_0098_SEND_URL = - 'http://www.0098sms.com/sendsmslink.aspx'; +/** Legacy link API — often returns HTTP 500; kept as fallback only. */ +const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx'; + +/** Current 0098 SOAP endpoint (works reliably). */ +const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx'; const SMS_0098_ERROR_CODES: Record = { '1': 'invalid phone number', @@ -21,6 +23,13 @@ const SMS_0098_ERROR_CODES: Record = { '13': 'invalid characters in message text', }; +type SendResult = { + ok: boolean; + code?: string; + reason: string; + transport: 'soap' | 'link'; +}; + @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); @@ -36,7 +45,7 @@ export class SmsService { } /** - * Formats mobile for 0098 `To` (Iranian mobiles as 09xxxxxxxxx). + * Formats mobile for 0098 (Iranian mobiles as 09xxxxxxxxx). */ normalizePhoneForSms(mobile: string): string { const digits = String(mobile ?? '').replace(/\D/g, ''); @@ -53,28 +62,147 @@ export class SmsService { } async sendOtpSms(mobile: string, code: string): Promise { + const result = await this.sendOtpSmsDetailed(mobile, code); + 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.sendSms(mobile, text); + 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 { const username = this.config.get('SMS_USERNAME'); const password = this.getPassword(); const sender = this.config.get('SMS_SENDER'); - const domain = this.config.get('SMS_DOMAIN') ?? '0098'; - const sendUrl = this.config.get('SMS_SEND_URL') ?? SMS_0098_SEND_URL; - const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 15000); if (!username || !password || !sender) { - return false; + return { ok: false, reason: 'SMS not configured', transport: 'soap' }; } const to = this.normalizePhoneForSms(phone); + const transport = + (this.config.get('SMS_TRANSPORT') ?? 'soap').toLowerCase() === + 'link' + ? 'link' + : 'soap'; + + 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`, + ); + } + + const soapResult = await this.sendViaSoap( + username, + password, + sender, + to, + text, + ); + if (soapResult.ok || transport === 'soap') { + return soapResult; + } + + return soapResult; + } + + private async sendViaSoap( + username: string, + password: string, + 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 = ` + + + + ${this.escapeXml(username)} + ${this.escapeXml(password)} + ${this.escapeXml(text)} + ${this.escapeXml(to)} + ${this.escapeXml(sender)} + + +`; + + try { + const res = await fetch(soapUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + SOAPAction: 'http://tempuri.org/SendSMS', + }, + body, + signal: AbortSignal.timeout(timeoutMs), + }); + + const responseBody = (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', + }; + } + + 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}`, + ); + } + return { ...parsed, transport: 'soap' }; + } catch (e) { + this.logger.error( + `SMS SOAP request failed to=${this.maskPhone(to)}`, + e instanceof Error ? e.stack : e, + ); + return { ok: false, reason: 'SOAP request failed', transport: 'soap' }; + } + } + + private async sendViaLink( + username: string, + password: string, + sender: string, + to: string, + text: string, + ): 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); - // 0098 link API uses mixed-case param names (see 0098sms/WebAPI samples). const params = new URLSearchParams({ FROM: sender, To: to, @@ -94,26 +222,28 @@ export class SmsService { if (!res.ok) { this.logger.warn( - `SMS HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`, + `SMS link HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`, ); - return false; + return { + ok: false, + reason: `link HTTP ${res.status}`, + transport: 'link', + }; } const parsed = this.parse0098Response(body); if (!parsed.ok) { this.logger.warn( - `SMS provider rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`, + `SMS link rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`, ); - return false; } - - return true; + return { ...parsed, transport: 'link' }; } catch (e) { this.logger.error( - `SMS request failed to=${this.maskPhone(to)}`, + `SMS link request failed to=${this.maskPhone(to)}`, e instanceof Error ? e.stack : e, ); - return false; + return { ok: false, reason: 'link request failed', transport: 'link' }; } } @@ -128,7 +258,6 @@ export class SmsService { const trimmed = body.trim(); - // 0098 often returns a numeric code; 0 = success. if (/^\d+$/.test(trimmed)) { if (trimmed === '0') { return { ok: true, code: '0', reason: 'successful' }; @@ -153,6 +282,15 @@ export class SmsService { return { ok: true, reason: 'accepted' }; } + private escapeXml(value: string): string { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + private readPositiveInt(key: string, fallback: number): number { const raw = this.config.get(key); const n = raw ? Number.parseInt(raw, 10) : NaN;