Restore 0098 SOAP SMS with link fallback and clearer errors

This commit is contained in:
2026-05-25 13:22:13 +03:30
parent 1da070969e
commit 27bd903e9d
4 changed files with 115 additions and 155 deletions
+6 -14
View File
@@ -26,20 +26,12 @@ 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) # From panel: account username, password (or web service password), approved sender line (pnlno)
# 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)
SMS_USERNAME= SMS_USERNAME=
SMS_PASSWORD= SMS_PASSWORD=
# Alternative name supported for password (same as some older projects) # SMS_API_KEY= # alias for SMS_PASSWORD
# SMS_API_KEY=
SMS_SENDER= SMS_SENDER=
# Optional; default is 0098 # soap (default, recommended) | link (legacy GET API, often HTTP 500)
# SMS_DOMAIN=0098 SMS_TRANSPORT=soap
# Optional OTP text; {code} is replaced. ASCII-only if provider rejects Persian. # SMS_DOMAIN=0098 # only for link transport
# SMS_OTP_TEMPLATE=Montime code: {code} # SMS_TIMEOUT_MS=20000
# 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
+14 -8
View File
@@ -1,18 +1,17 @@
import 'dotenv/config'; import 'dotenv/config';
const username = process.env.SMS_USERNAME; const username = process.env.SMS_USERNAME;
const password = process.env.SMS_PASSWORD; const password = process.env.SMS_PASSWORD ?? process.env.SMS_API_KEY;
const sender = process.env.SMS_SENDER; const sender = process.env.SMS_SENDER;
if (!username || !password || !sender) { if (!username || !password || !sender) {
console.error('Missing SMS env vars'); console.error('Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER in .env');
process.exit(1); process.exit(1);
} }
const text = 'Montime code: 123456';
const targets = process.argv.slice(2).length const targets = process.argv.slice(2).length
? process.argv.slice(2) ? process.argv.slice(2)
: ['09397439581', '09128081392']; : ['09397439581'];
function escapeXml(value) { function escapeXml(value) {
return String(value) return String(value)
@@ -23,9 +22,11 @@ function escapeXml(value) {
.replace(/'/g, '''); .replace(/'/g, ''');
} }
const text = 'کد ورود منتایم: 123456';
for (const mobile of targets) { for (const mobile of targets) {
const soapBody = `<?xml version="1.0" encoding="utf-8"?> 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:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body> <soap:Body>
<SendSMS xmlns="http://tempuri.org/"> <SendSMS xmlns="http://tempuri.org/">
<username>${escapeXml(username)}</username> <username>${escapeXml(username)}</username>
@@ -44,10 +45,15 @@ for (const mobile of targets) {
SOAPAction: 'http://tempuri.org/SendSMS', SOAPAction: 'http://tempuri.org/SendSMS',
}, },
body: soapBody, body: soapBody,
signal: AbortSignal.timeout(20000), signal: AbortSignal.timeout(20_000),
}); });
const body = await res.text(); const body = await res.text();
const match = body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/); const match = body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i);
console.log(mobile, 'HTTP', res.status, 'RESULT', match?.[1] ?? body.slice(0, 200)); const code = match?.[1]?.trim() ?? '?';
console.log(`mobile=${mobile} http=${res.status} RESULT=${code}`);
if (code !== '0') {
console.log(body.slice(0, 400));
}
} }
+6 -2
View File
@@ -98,13 +98,17 @@ 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 result = await this.sms.sendOtpSmsDetailed(mobile, code); const result = await this.sms.sendSmsDetailed(
mobile,
`کد ورود منتایم: ${code}`,
);
if (!result.ok) { if (!result.ok) {
const detail = result.reason ? ` (${result.reason})` : '';
this.logger.warn( this.logger.warn(
`OTP SMS failed for ${this.maskMobile(mobile)} transport=${result.transport} reason=${result.reason}`, `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. (${result.reason})`, `Could not send SMS. Please try again later.${detail}`,
); );
} }
} }
+89 -131
View File
@@ -1,10 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
/** Legacy link API — often returns HTTP 500; kept as fallback only. */ /** Legacy GET API — often returns HTTP 500; kept as optional fallback. */
const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx'; const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
/** Current 0098 SOAP endpoint (works reliably). */ /** Official 0098 SOAP API (works when link endpoint is down). */
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx'; 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> = {
@@ -23,11 +23,10 @@ const SMS_0098_ERROR_CODES: Record<string, string> = {
'13': 'invalid characters in message text', '13': 'invalid characters in message text',
}; };
type SendResult = { export type SmsSendResult = {
ok: boolean; ok: boolean;
code?: string;
reason: string;
transport: 'soap' | 'link'; transport: 'soap' | 'link';
reason?: string;
}; };
@Injectable() @Injectable()
@@ -36,7 +35,6 @@ export class SmsService {
constructor(private readonly config: ConfigService) {} constructor(private readonly config: ConfigService) {}
/** True when username, password, and sender line are all set. */
isConfigured(): boolean { isConfigured(): boolean {
const username = this.config.get<string>('SMS_USERNAME'); const username = this.config.get<string>('SMS_USERNAME');
const password = this.getPassword(); const password = this.getPassword();
@@ -44,9 +42,6 @@ export class SmsService {
return Boolean(username && password && sender); return Boolean(username && password && sender);
} }
/**
* 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, '');
if (digits.startsWith('98') && digits.length === 12) { if (digits.startsWith('98') && digits.length === 12) {
@@ -62,67 +57,46 @@ 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); const text = `کد ورود منتایم: ${code}`;
const result = await this.sendSmsDetailed(mobile, text);
return result.ok; return result.ok;
} }
async sendOtpSmsDetailed(mobile: string, code: string): Promise<SendResult> {
const template =
this.config.get<string>('SMS_OTP_TEMPLATE') ??
'کد ورود منتایم: {code}';
const text = template.replace('{code}', code);
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); const result = await this.sendSmsDetailed(phone, text);
return result.ok; return result.ok;
} }
async sendSmsDetailed(phone: string, text: string): Promise<SendResult> { async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
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');
if (!username || !password || !sender) { if (!username || !password || !sender) {
return { ok: false, reason: 'SMS not configured', transport: 'soap' }; return { ok: false, transport: 'soap', reason: 'SMS not configured' };
} }
const to = this.normalizePhoneForSms(phone); const to = this.normalizePhoneForSms(phone);
const transport = const transport = this.resolveTransport();
(this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase() ===
'link'
? 'link'
: 'soap';
if (transport === 'link') { if (transport === 'link') {
const linkResult = await this.sendViaLink( return this.sendViaLink(username, password, sender, to, text);
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( const soapResult = await this.sendViaSoap(username, password, sender, to, text);
username, if (soapResult.ok) {
password,
sender,
to,
text,
);
if (soapResult.ok || transport === 'soap') {
return soapResult; return soapResult;
} }
return soapResult; this.logger.warn(
`SOAP SMS failed to=${this.maskPhone(to)} reason=${soapResult.reason}; trying link fallback`,
);
return this.sendViaLink(username, password, sender, to, text);
}
private resolveTransport(): 'soap' | 'link' {
const raw = (this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase();
return raw === 'link' ? 'link' : 'soap';
} }
private async sendViaSoap( private async sendViaSoap(
@@ -131,12 +105,8 @@ export class SmsService {
sender: string, sender: string,
to: string, to: string,
text: string, text: string,
): Promise<SendResult> { ): Promise<SmsSendResult> {
const soapUrl = const soapBody = `<?xml version="1.0" encoding="utf-8"?>
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: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> <soap:Body>
<SendSMS xmlns="http://tempuri.org/"> <SendSMS xmlns="http://tempuri.org/">
@@ -149,46 +119,47 @@ export class SmsService {
</soap:Body> </soap:Body>
</soap:Envelope>`; </soap:Envelope>`;
const timeoutMs = this.readTimeoutMs();
try { try {
const res = await fetch(soapUrl, { const res = await fetch(SMS_0098_SOAP_URL, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/xml; charset=utf-8', 'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: 'http://tempuri.org/SendSMS', SOAPAction: 'http://tempuri.org/SendSMS',
}, },
body, body: soapBody,
signal: AbortSignal.timeout(timeoutMs), signal: AbortSignal.timeout(timeoutMs),
}); });
const responseBody = (await res.text()).trim(); const body = (await res.text()).trim();
if (!res.ok) { if (!res.ok) {
this.logger.warn(
`SMS SOAP HTTP ${res.status} to=${this.maskPhone(to)} body=${responseBody.slice(0, 300)}`,
);
return { return {
ok: false, ok: false,
reason: `SOAP HTTP ${res.status}`,
transport: 'soap', transport: 'soap',
reason: `HTTP ${res.status}`,
}; };
} }
const match = responseBody.match( const code = this.parseSoapResultCode(body);
/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i, if (code === '0') {
); return { ok: true, transport: 'soap' };
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) { const reason =
this.logger.error( SMS_0098_ERROR_CODES[code ?? ''] ??
`SMS SOAP request failed to=${this.maskPhone(to)}`, (code ? `provider code ${code}` : 'unknown SOAP response');
e instanceof Error ? e.stack : e, this.logger.warn(
`SOAP rejected to=${this.maskPhone(to)} code=${code ?? '?'} body=${body.slice(0, 200)}`,
); );
return { ok: false, reason: 'SOAP request failed', transport: 'soap' }; return { ok: false, transport: 'soap', reason };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(
`SOAP request failed to=${this.maskPhone(to)}: ${message}`,
e instanceof Error ? e.stack : undefined,
);
return { ok: false, transport: 'soap', reason: 'SOAP request failed' };
} }
} }
@@ -198,88 +169,75 @@ export class SmsService {
sender: string, sender: string,
to: string, to: string,
text: string, text: string,
): Promise<SendResult> { ): Promise<SmsSendResult> {
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_LINK_URL;
const timeoutMs = this.readPositiveInt('SMS_TIMEOUT_MS', 20000);
const params = new URLSearchParams({ const params = new URLSearchParams({
FROM: sender, FROM: sender,
To: to, TO: to,
Text: text, TEXT: text,
USERNAME: username, USERNAME: username,
PASSWORD: password, PASSWORD: password,
DOMAIN: domain, DOMAIN: domain,
}); });
const url = `${sendUrl}?${params.toString()}`; const url = `${SMS_0098_LINK_URL}?${params.toString()}`;
const timeoutMs = this.readTimeoutMs();
try { try {
const res = await fetch(url, { const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
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(
`SMS link HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
);
return { return {
ok: false, ok: false,
reason: `link HTTP ${res.status}`,
transport: 'link', transport: 'link',
reason: `HTTP ${res.status}`,
}; };
} }
if (!this.isLikelySuccessLinkBody(body)) {
const parsed = this.parse0098Response(body); return {
if (!parsed.ok) { ok: false,
this.logger.warn( transport: 'link',
`SMS link rejected to=${this.maskPhone(to)} code=${parsed.code ?? 'unknown'} reason=${parsed.reason} body=${body.slice(0, 300)}`, reason: body.slice(0, 120) || 'link API rejected',
); };
} }
return { ...parsed, transport: 'link' }; return { ok: true, transport: 'link' };
} catch (e) { } catch (e) {
this.logger.error( const message = e instanceof Error ? e.message : String(e);
`SMS link request failed to=${this.maskPhone(to)}`, this.logger.error(`Link SMS failed to=${this.maskPhone(to)}: ${message}`);
e instanceof Error ? e.stack : e, return { ok: false, transport: 'link', reason: 'link request failed' };
);
return { ok: false, reason: 'link request failed', transport: 'link' };
} }
} }
private parse0098Response(body: string): { private parseSoapResultCode(xml: string): string | null {
ok: boolean; const match =
code?: string; xml.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i) ??
reason: string; xml.match(/<SendSMSResult>([^<]*)<\/SendSMSResult>/i);
} { if (match?.[1] != null) {
return match[1].trim();
}
if (xml.includes('>0<')) {
return '0';
}
return null;
}
private isLikelySuccessLinkBody(body: string): boolean {
if (!body) { if (!body) {
return { ok: false, reason: 'empty response' }; return true;
} }
const lower = body.toLowerCase();
const trimmed = body.trim();
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 { ok: false, reason: trimmed.slice(0, 120) }; return false;
} }
const code = body.trim();
return { ok: true, reason: 'accepted' }; if (/^\d+$/.test(code) && code !== '0') {
return false;
}
return true;
} }
private escapeXml(value: string): string { private escapeXml(value: string): string {
@@ -291,10 +249,10 @@ export class SmsService {
.replace(/'/g, '&apos;'); .replace(/'/g, '&apos;');
} }
private readPositiveInt(key: string, fallback: number): number { private readTimeoutMs(): number {
const raw = this.config.get<string>(key); const raw = this.config.get<string>('SMS_TIMEOUT_MS');
const n = raw ? Number.parseInt(raw, 10) : NaN; const n = raw ? Number.parseInt(raw, 10) : 20_000;
return Number.isFinite(n) && n > 0 ? n : fallback; return Number.isFinite(n) && n > 0 ? n : 20_000;
} }
private maskPhone(phone: string): string { private maskPhone(phone: string): string {