Fix 0098 SMS response parsing, logging, and OTP delivery reliability
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user