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=
# 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
+70 -16
View File
@@ -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<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()
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<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);
}
@@ -46,39 +65,48 @@ export class SmsService {
const password = this.getPassword();
const sender = this.config.get<string>('SMS_SENDER');
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) {
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<string>(key);
const n = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : fallback;
}
private maskPhone(phone: string): string {