Fix 0098 SMS response parsing, logging, and OTP delivery reliability

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-24 17:57:05 +03:30
co-authored by Cursor
parent 1522ff62f5
commit 58dde62f04
2 changed files with 76 additions and 16 deletions
+6
View File
@@ -34,3 +34,9 @@ SMS_PASSWORD=
SMS_SENDER= SMS_SENDER=
# Optional; default is 0098 # Optional; default is 0098
# SMS_DOMAIN=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
+70 -16
View File
@@ -5,6 +5,22 @@ import { ConfigService } from '@nestjs/config';
const SMS_0098_SEND_URL = const SMS_0098_SEND_URL =
'http://www.0098sms.com/sendsmslink.aspx'; 'http://www.0098sms.com/sendsmslink.aspx';
const SMS_0098_ERROR_CODES: Record<string, string> = {
'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() @Injectable()
export class SmsService { export class SmsService {
private readonly logger = new Logger(SmsService.name); 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 { normalizePhoneForSms(mobile: string): string {
const digits = String(mobile ?? '').replace(/\D/g, ''); const digits = String(mobile ?? '').replace(/\D/g, '');
@@ -37,7 +53,10 @@ export class SmsService {
} }
async sendOtpSms(mobile: string, code: string): Promise<boolean> { async sendOtpSms(mobile: string, code: string): Promise<boolean> {
const text = `کد ورود منتایم: ${code}`; const template =
this.config.get<string>('SMS_OTP_TEMPLATE') ??
'کد ورود منتایم: {code}';
const text = template.replace('{code}', code);
return this.sendSms(mobile, text); return this.sendSms(mobile, text);
} }
@@ -46,39 +65,48 @@ export class SmsService {
const password = this.getPassword(); const password = this.getPassword();
const sender = this.config.get<string>('SMS_SENDER'); const sender = this.config.get<string>('SMS_SENDER');
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098'; const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
const sendUrl = this.config.get<string>('SMS_SEND_URL') ?? SMS_0098_SEND_URL;
const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 15000);
if (!username || !password || !sender) { if (!username || !password || !sender) {
return false; return false;
} }
const to = this.normalizePhoneForSms(phone);
// 0098 link API uses mixed-case param names (see 0098sms/WebAPI samples).
const params = new URLSearchParams({ const params = new URLSearchParams({
FROM: sender, FROM: sender,
TO: this.normalizePhoneForSms(phone), To: to,
TEXT: text, Text: text,
USERNAME: username, USERNAME: username,
PASSWORD: password, PASSWORD: password,
DOMAIN: domain, DOMAIN: domain,
}); });
const url = `${SMS_0098_SEND_URL}?${params.toString()}`; const url = `${sendUrl}?${params.toString()}`;
const to = this.normalizePhoneForSms(phone);
try { try {
const res = await fetch(url); const res = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
const body = (await res.text()).trim(); const body = (await res.text()).trim();
if (!res.ok) { if (!res.ok) {
this.logger.warn( this.logger.warn(
`SMS HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`, `SMS HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
); );
return false; return false;
} }
if (!this.isLikelySuccessBody(body)) {
const parsed = this.parse0098Response(body);
if (!parsed.ok) {
this.logger.warn( 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 false;
} }
return true; return true;
} catch (e) { } catch (e) {
this.logger.error( this.logger.error(
@@ -89,20 +117,46 @@ export class SmsService {
} }
} }
/** 0098 often returns plain text; treat obvious error tokens as failure. */ private parse0098Response(body: string): {
private isLikelySuccessBody(body: string): boolean { ok: boolean;
code?: string;
reason: string;
} {
if (!body) { 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 ( if (
lower.includes('error') || lower.includes('error') ||
lower.includes('fail') || lower.includes('fail') ||
lower.includes('invalid') 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<string>(key);
const n = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : fallback;
} }
private maskPhone(phone: string): string { private maskPhone(phone: string): string {