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=
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user