Switch OTP SMS to 0098 SOAP API because link endpoint returns HTTP 500

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-24 18:27:49 +03:30
co-authored by Cursor
parent 58dde62f04
commit 1da070969e
4 changed files with 217 additions and 23 deletions
+3
View File
@@ -26,6 +26,9 @@ UPLOAD_DIR=uploads
CORS_ORIGINS=https://montime.ir,https://adm.montime.ir,http://localhost:5173,http://localhost:5174 CORS_ORIGINS=https://montime.ir,https://adm.montime.ir,http://localhost:5173,http://localhost:5174
# 0098sms.com — used when AUTH_RETURN_OTP is not true # 0098sms.com — used when AUTH_RETURN_OTP is not true
# Transport: soap (default, recommended) or link (legacy)
# SMS_TRANSPORT=soap
# SMS_SOAP_URL=https://webservice.0098sms.com/service.asmx
# From panel: account username, password (or web service password), approved sender line (FROM) # From panel: account username, password (or web service password), approved sender line (FROM)
SMS_USERNAME= SMS_USERNAME=
SMS_PASSWORD= SMS_PASSWORD=
+53
View File
@@ -0,0 +1,53 @@
import 'dotenv/config';
const username = process.env.SMS_USERNAME;
const password = process.env.SMS_PASSWORD;
const sender = process.env.SMS_SENDER;
if (!username || !password || !sender) {
console.error('Missing SMS env vars');
process.exit(1);
}
const text = 'Montime code: 123456';
const targets = process.argv.slice(2).length
? process.argv.slice(2)
: ['09397439581', '09128081392'];
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
for (const mobile of targets) {
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SendSMS xmlns="http://tempuri.org/">
<username>${escapeXml(username)}</username>
<password>${escapeXml(password)}</password>
<text>${escapeXml(text)}</text>
<mobileno>${escapeXml(mobile)}</mobileno>
<pnlno>${escapeXml(sender)}</pnlno>
</SendSMS>
</soap:Body>
</soap:Envelope>`;
const res = await fetch('https://webservice.0098sms.com/service.asmx', {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: 'http://tempuri.org/SendSMS',
},
body: soapBody,
signal: AbortSignal.timeout(20000),
});
const body = await res.text();
const match = body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/);
console.log(mobile, 'HTTP', res.status, 'RESULT', match?.[1] ?? body.slice(0, 200));
}
+4 -4
View File
@@ -98,13 +98,13 @@ export class AuthService {
'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.', 'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.',
); );
} }
const sent = await this.sms.sendOtpSms(mobile, code); const result = await this.sms.sendOtpSmsDetailed(mobile, code);
if (!sent) { if (!result.ok) {
this.logger.warn( this.logger.warn(
`OTP SMS failed for ${this.maskMobile(mobile)}`, `OTP SMS failed for ${this.maskMobile(mobile)} transport=${result.transport} reason=${result.reason}`,
); );
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
'Could not send SMS. Please try again later.', `Could not send SMS. Please try again later. (${result.reason})`,
); );
} }
} }
+157 -19
View File
@@ -1,9 +1,11 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
/** HTTP endpoint documented by 0098sms.com for sending via query string. */ /** Legacy link API — often returns HTTP 500; kept as fallback only. */
const SMS_0098_SEND_URL = const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
'http://www.0098sms.com/sendsmslink.aspx';
/** Current 0098 SOAP endpoint (works reliably). */
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
const SMS_0098_ERROR_CODES: Record<string, string> = { const SMS_0098_ERROR_CODES: Record<string, string> = {
'1': 'invalid phone number', '1': 'invalid phone number',
@@ -21,6 +23,13 @@ const SMS_0098_ERROR_CODES: Record<string, string> = {
'13': 'invalid characters in message text', '13': 'invalid characters in message text',
}; };
type SendResult = {
ok: boolean;
code?: string;
reason: string;
transport: 'soap' | 'link';
};
@Injectable() @Injectable()
export class SmsService { export class SmsService {
private readonly logger = new Logger(SmsService.name); private readonly logger = new Logger(SmsService.name);
@@ -36,7 +45,7 @@ export class SmsService {
} }
/** /**
* Formats mobile for 0098 `To` (Iranian mobiles as 09xxxxxxxxx). * Formats mobile for 0098 (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, '');
@@ -53,28 +62,147 @@ export class SmsService {
} }
async sendOtpSms(mobile: string, code: string): Promise<boolean> { async sendOtpSms(mobile: string, code: string): Promise<boolean> {
const result = await this.sendOtpSmsDetailed(mobile, code);
return result.ok;
}
async sendOtpSmsDetailed(mobile: string, code: string): Promise<SendResult> {
const template = const template =
this.config.get<string>('SMS_OTP_TEMPLATE') ?? this.config.get<string>('SMS_OTP_TEMPLATE') ??
'کد ورود منتایم: {code}'; 'کد ورود منتایم: {code}';
const text = template.replace('{code}', code); const text = template.replace('{code}', code);
return this.sendSms(mobile, text); return this.sendSmsDetailed(mobile, text);
} }
async sendSms(phone: string, text: string): Promise<boolean> { async sendSms(phone: string, text: string): Promise<boolean> {
const result = await this.sendSmsDetailed(phone, text);
return result.ok;
}
async sendSmsDetailed(phone: string, text: string): Promise<SendResult> {
const username = this.config.get<string>('SMS_USERNAME'); const username = this.config.get<string>('SMS_USERNAME');
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 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 { ok: false, reason: 'SMS not configured', transport: 'soap' };
} }
const to = this.normalizePhoneForSms(phone); const to = this.normalizePhoneForSms(phone);
const transport =
(this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase() ===
'link'
? 'link'
: 'soap';
if (transport === 'link') {
const linkResult = await this.sendViaLink(
username,
password,
sender,
to,
text,
);
if (linkResult.ok) {
return linkResult;
}
this.logger.warn(
`SMS link transport failed to=${this.maskPhone(to)} reason=${linkResult.reason}; trying SOAP`,
);
}
const soapResult = await this.sendViaSoap(
username,
password,
sender,
to,
text,
);
if (soapResult.ok || transport === 'soap') {
return soapResult;
}
return soapResult;
}
private async sendViaSoap(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SendResult> {
const soapUrl =
this.config.get<string>('SMS_SOAP_URL') ?? SMS_0098_SOAP_URL;
const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 20000);
const body = `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SendSMS xmlns="http://tempuri.org/">
<username>${this.escapeXml(username)}</username>
<password>${this.escapeXml(password)}</password>
<text>${this.escapeXml(text)}</text>
<mobileno>${this.escapeXml(to)}</mobileno>
<pnlno>${this.escapeXml(sender)}</pnlno>
</SendSMS>
</soap:Body>
</soap:Envelope>`;
try {
const res = await fetch(soapUrl, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: 'http://tempuri.org/SendSMS',
},
body,
signal: AbortSignal.timeout(timeoutMs),
});
const responseBody = (await res.text()).trim();
if (!res.ok) {
this.logger.warn(
`SMS SOAP HTTP ${res.status} to=${this.maskPhone(to)} body=${responseBody.slice(0, 300)}`,
);
return {
ok: false,
reason: `SOAP HTTP ${res.status}`,
transport: 'soap',
};
}
const match = responseBody.match(
/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i,
);
const code = match?.[1]?.trim() ?? responseBody;
const parsed = this.parse0098Response(code);
if (!parsed.ok) {
this.logger.warn(
`SMS SOAP rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason}`,
);
}
return { ...parsed, transport: 'soap' };
} catch (e) {
this.logger.error(
`SMS SOAP request failed to=${this.maskPhone(to)}`,
e instanceof Error ? e.stack : e,
);
return { ok: false, reason: 'SOAP request failed', transport: 'soap' };
}
}
private async sendViaLink(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SendResult> {
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
const sendUrl = this.config.get<string>('SMS_SEND_URL') ?? SMS_0098_LINK_URL;
const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 20000);
// 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: to, To: to,
@@ -94,26 +222,28 @@ export class SmsService {
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 link HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
); );
return false; return {
ok: false,
reason: `link HTTP ${res.status}`,
transport: 'link',
};
} }
const parsed = this.parse0098Response(body); const parsed = this.parse0098Response(body);
if (!parsed.ok) { if (!parsed.ok) {
this.logger.warn( this.logger.warn(
`SMS provider rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`, `SMS link rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`,
); );
return false;
} }
return { ...parsed, transport: 'link' };
return true;
} catch (e) { } catch (e) {
this.logger.error( this.logger.error(
`SMS request failed to=${this.maskPhone(to)}`, `SMS link request failed to=${this.maskPhone(to)}`,
e instanceof Error ? e.stack : e, e instanceof Error ? e.stack : e,
); );
return false; return { ok: false, reason: 'link request failed', transport: 'link' };
} }
} }
@@ -128,7 +258,6 @@ export class SmsService {
const trimmed = body.trim(); const trimmed = body.trim();
// 0098 often returns a numeric code; 0 = success.
if (/^\d+$/.test(trimmed)) { if (/^\d+$/.test(trimmed)) {
if (trimmed === '0') { if (trimmed === '0') {
return { ok: true, code: '0', reason: 'successful' }; return { ok: true, code: '0', reason: 'successful' };
@@ -153,6 +282,15 @@ export class SmsService {
return { ok: true, reason: 'accepted' }; return { ok: true, reason: 'accepted' };
} }
private escapeXml(value: string): string {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
private readPositiveInt(key: string, fallback: number): number { private readPositiveInt(key: string, fallback: number): number {
const raw = this.config.get<string>(key); const raw = this.config.get<string>(key);
const n = raw ? Number.parseInt(raw, 10) : NaN; const n = raw ? Number.parseInt(raw, 10) : NaN;