From 58dde62f04569da2aeb9bdd47815d07662790b54 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 24 May 2026 17:57:05 +0330 Subject: [PATCH] Fix 0098 SMS response parsing, logging, and OTP delivery reliability Co-authored-by: Cursor --- .env.example | 6 +++ src/modules/auth/sms.service.ts | 86 +++++++++++++++++++++++++++------ 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 053b632..0f1c608 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,9 @@ 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 diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts index 87b116e..824bb5b 100644 --- a/src/modules/auth/sms.service.ts +++ b/src/modules/auth/sms.service.ts @@ -5,6 +5,22 @@ import { ConfigService } from '@nestjs/config'; const SMS_0098_SEND_URL = 'http://www.0098sms.com/sendsmslink.aspx'; +const SMS_0098_ERROR_CODES: Record = { + '1': 'invalid phone number', + '2': 'no phone number', + '3': 'no sender', + '4': 'no text', + '5': 'no username', + '6': 'no password', + '7': 'no domain', + '8': 'invalid license', + '9': 'insufficient SMS credit', + '10': 'no link for this number', + '11': 'no license for link access', + '12': 'wrong username or password', + '13': 'invalid characters in message text', +}; + @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); @@ -20,7 +36,7 @@ export class SmsService { } /** - * Formats mobile for 0098 `TO` (Iranian mobiles as 09xxxxxxxxx). + * Formats mobile for 0098 `To` (Iranian mobiles as 09xxxxxxxxx). */ normalizePhoneForSms(mobile: string): string { const digits = String(mobile ?? '').replace(/\D/g, ''); @@ -37,7 +53,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); return this.sendSms(mobile, text); } @@ -46,39 +65,48 @@ export class SmsService { 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; } + const to = this.normalizePhoneForSms(phone); + + // 0098 link API uses mixed-case param names (see 0098sms/WebAPI samples). const params = new URLSearchParams({ FROM: sender, - TO: this.normalizePhoneForSms(phone), - TEXT: text, + To: to, + Text: text, USERNAME: username, PASSWORD: password, DOMAIN: domain, }); - const url = `${SMS_0098_SEND_URL}?${params.toString()}`; - - const to = this.normalizePhoneForSms(phone); + const url = `${sendUrl}?${params.toString()}`; try { - const res = await fetch(url); + const res = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + }); const body = (await res.text()).trim(); + if (!res.ok) { this.logger.warn( `SMS HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`, ); return false; } - if (!this.isLikelySuccessBody(body)) { + + const parsed = this.parse0098Response(body); + if (!parsed.ok) { this.logger.warn( - `SMS provider rejected to=${this.maskPhone(to)} body=${body.slice(0, 300)}`, + `SMS provider rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`, ); return false; } + return true; } catch (e) { this.logger.error( @@ -89,20 +117,46 @@ export class SmsService { } } - /** 0098 often returns plain text; treat obvious error tokens as failure. */ - private isLikelySuccessBody(body: string): boolean { + private parse0098Response(body: string): { + ok: boolean; + code?: string; + reason: string; + } { if (!body) { - return true; + return { ok: false, reason: 'empty response' }; } - const lower = body.toLowerCase(); + + 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' }; + } + 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(); if ( lower.includes('error') || lower.includes('fail') || lower.includes('invalid') ) { - return false; + return { ok: false, reason: trimmed.slice(0, 120) }; } - return true; + + return { ok: true, reason: 'accepted' }; + } + + 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 maskPhone(phone: string): string {