change web service sms
This commit is contained in:
+11
-5
@@ -25,16 +25,22 @@ UPLOAD_DIR=uploads
|
||||
# Include both dev ports if calling API directly (without Vite /api proxy)
|
||||
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
|
||||
# From panel: account username, password (or web service password), approved sender line (pnlno)
|
||||
# SMS — used when AUTH_RETURN_OTP is not true
|
||||
# Provider: trez (default, smspanel.Trez.ir) | 0098 (legacy)
|
||||
SMS_PROVIDER=trez
|
||||
SMS_USERNAME=
|
||||
SMS_PASSWORD=
|
||||
# SMS_API_KEY= # alias for SMS_PASSWORD
|
||||
# Trez/Raygan sender line (PhoneNumber), e.g. 90002847
|
||||
SMS_SENDER=
|
||||
# soap (default, recommended) | link (legacy GET API, often HTTP 500)
|
||||
SMS_TRANSPORT=soap
|
||||
# SMS_DOMAIN=0098 # only for link transport
|
||||
# Trez panel base URL and send path (defaults shown)
|
||||
# SMS_BASE_URL=http://smspanel.Trez.ir
|
||||
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||
# SMS_OTP_TEMPLATE=کد ورود منتایم: {code}
|
||||
# SMS_TIMEOUT_MS=20000
|
||||
# --- legacy 0098 only (SMS_PROVIDER=0098) ---
|
||||
# SMS_TRANSPORT=soap
|
||||
# SMS_DOMAIN=0098
|
||||
|
||||
# Minimum minutes before a booking can be scheduled (default 60)
|
||||
BOOKING_MIN_LEAD_MINUTES=60
|
||||
|
||||
+63
-16
@@ -3,6 +3,7 @@ import 'dotenv/config';
|
||||
const username = process.env.SMS_USERNAME;
|
||||
const password = process.env.SMS_PASSWORD ?? process.env.SMS_API_KEY;
|
||||
const sender = process.env.SMS_SENDER;
|
||||
const provider = (process.env.SMS_PROVIDER ?? 'trez').toLowerCase();
|
||||
|
||||
if (!username || !password || !sender) {
|
||||
console.error('Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER in .env');
|
||||
@@ -11,20 +12,60 @@ if (!username || !password || !sender) {
|
||||
|
||||
const targets = process.argv.slice(2).length
|
||||
? process.argv.slice(2)
|
||||
: ['09397439581'];
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
: ['09128081392'];
|
||||
|
||||
const text = 'کد ورود منتایم: 123456';
|
||||
const timeoutMs = Number.parseInt(process.env.SMS_TIMEOUT_MS ?? '20000', 10);
|
||||
|
||||
function parseTrezResponse(body) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return { ok: true, reason: 'empty' };
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('fail') || lower.includes('خطا')) {
|
||||
return { ok: false, reason: trimmed };
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
const n = Number(trimmed);
|
||||
if (n === 1 || n > 1) return { ok: true, reason: trimmed };
|
||||
return { ok: false, reason: `code ${trimmed}` };
|
||||
}
|
||||
return { ok: true, reason: trimmed };
|
||||
}
|
||||
|
||||
async function sendViaTrez(mobile) {
|
||||
const baseUrl = (process.env.SMS_BASE_URL ?? 'http://smspanel.Trez.ir').replace(/\/$/, '');
|
||||
const path = process.env.SMS_SEND_PATH ?? '/SendMessageWithCode.ashx';
|
||||
const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
PhoneNumber: sender,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Mobile: mobile,
|
||||
Message: text,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const body = await res.text();
|
||||
const parsed = parseTrezResponse(body);
|
||||
console.log(
|
||||
`mobile=${mobile} provider=trez http=${res.status} body=${JSON.stringify(body.trim())} ${parsed.ok ? 'OK' : 'FAILED'} (${parsed.reason})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function sendVia0098(mobile) {
|
||||
const escapeXml = (value) =>
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
for (const mobile of targets) {
|
||||
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
@@ -45,7 +86,7 @@ for (const mobile of targets) {
|
||||
SOAPAction: 'http://tempuri.org/SendSMS',
|
||||
},
|
||||
body: soapBody,
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const body = await res.text();
|
||||
@@ -53,10 +94,16 @@ for (const mobile of targets) {
|
||||
const code = match?.[1]?.trim() ?? '?';
|
||||
const n = Number(code);
|
||||
const ok = Number.isFinite(n) ? n >= 0 : false;
|
||||
const status = ok ? 'OK (accepted by 0098)' : 'FAILED';
|
||||
console.log(
|
||||
`mobile=${mobile} provider=0098 http=${res.status} RESULT=${code} ${ok ? 'OK' : 'FAILED'}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`mobile=${mobile} http=${res.status} RESULT=${code} ${status}`);
|
||||
if (!ok) {
|
||||
console.log(body.slice(0, 400));
|
||||
console.log(`SMS_PROVIDER=${provider}`);
|
||||
for (const mobile of targets) {
|
||||
if (provider === '0098') {
|
||||
await sendVia0098(mobile);
|
||||
} else {
|
||||
await sendViaTrez(mobile);
|
||||
}
|
||||
}
|
||||
|
||||
+144
-18
@@ -1,20 +1,23 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/** Legacy GET API — often returns HTTP 500; kept as optional fallback. */
|
||||
/** Legacy 0098 GET API — kept when SMS_PROVIDER=0098. */
|
||||
const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
|
||||
|
||||
/** Official 0098 SOAP API (works when link endpoint is down). */
|
||||
/** Legacy 0098 SOAP API — kept when SMS_PROVIDER=0098. */
|
||||
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
|
||||
|
||||
/** SOAP SendSMSResult: 0 or positive = accepted (often SMS part count); negative = error. */
|
||||
const SMS_0098_SOAP_ERROR_CODES: Record<string, string> = {
|
||||
'-22': 'invalid mobile number format',
|
||||
};
|
||||
|
||||
/** Trez / Raygan panel (default). */
|
||||
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
|
||||
const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx';
|
||||
|
||||
export type SmsSendResult = {
|
||||
ok: boolean;
|
||||
transport: 'soap' | 'link';
|
||||
transport: 'trez' | 'soap' | 'link';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
@@ -46,7 +49,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);
|
||||
const result = await this.sendSmsDetailed(mobile, text);
|
||||
return result.ok;
|
||||
}
|
||||
@@ -62,33 +68,153 @@ export class SmsService {
|
||||
const sender = this.config.get<string>('SMS_SENDER');
|
||||
|
||||
if (!username || !password || !sender) {
|
||||
return { ok: false, transport: 'soap', reason: 'SMS not configured' };
|
||||
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
|
||||
}
|
||||
|
||||
const to = this.normalizePhoneForSms(phone);
|
||||
const transport = this.resolveTransport();
|
||||
const provider = this.resolveProvider();
|
||||
|
||||
if (transport === 'link') {
|
||||
return this.sendViaLink(username, password, sender, to, text);
|
||||
if (provider === 'trez') {
|
||||
return this.sendViaTrez(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
const soapResult = await this.sendViaSoap(username, password, sender, to, text);
|
||||
const transport = this.resolve0098Transport();
|
||||
if (transport === 'link') {
|
||||
return this.sendVia0098Link(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
const soapResult = await this.sendVia0098Soap(
|
||||
username,
|
||||
password,
|
||||
sender,
|
||||
to,
|
||||
text,
|
||||
);
|
||||
if (soapResult.ok) {
|
||||
return soapResult;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`SOAP SMS failed to=${this.maskPhone(to)} reason=${soapResult.reason}; trying link fallback`,
|
||||
`0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; trying link fallback`,
|
||||
);
|
||||
return this.sendViaLink(username, password, sender, to, text);
|
||||
return this.sendVia0098Link(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
private resolveTransport(): 'soap' | 'link' {
|
||||
private resolveProvider(): 'trez' | '0098' {
|
||||
const raw = (this.config.get<string>('SMS_PROVIDER') ?? 'trez').toLowerCase();
|
||||
return raw === '0098' ? '0098' : 'trez';
|
||||
}
|
||||
|
||||
private resolve0098Transport(): 'soap' | 'link' {
|
||||
const raw = (this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase();
|
||||
return raw === 'link' ? 'link' : 'soap';
|
||||
}
|
||||
|
||||
private async sendViaSoap(
|
||||
private async sendViaTrez(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<SmsSendResult> {
|
||||
const baseUrl = (
|
||||
this.config.get<string>('SMS_BASE_URL') ?? SMS_TREZ_DEFAULT_BASE_URL
|
||||
).replace(/\/$/, '');
|
||||
const path =
|
||||
this.config.get<string>('SMS_SEND_PATH') ?? SMS_TREZ_DEFAULT_SEND_PATH;
|
||||
const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const timeoutMs = this.readTimeoutMs();
|
||||
|
||||
const body = new URLSearchParams({
|
||||
PhoneNumber: sender,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Mobile: to,
|
||||
Message: text,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const responseBody = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
this.logger.warn(
|
||||
`Trez HTTP ${res.status} to=${this.maskPhone(to)} body=${responseBody.slice(0, 200)}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
transport: 'trez',
|
||||
reason: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = this.parseTrezResponse(responseBody);
|
||||
if (!parsed.ok) {
|
||||
this.logger.warn(
|
||||
`Trez rejected to=${this.maskPhone(to)} reason=${parsed.reason} body=${responseBody.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return { ...parsed, transport: 'trez' };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(
|
||||
`Trez request failed to=${this.maskPhone(to)}: ${message}`,
|
||||
e instanceof Error ? e.stack : undefined,
|
||||
);
|
||||
return { ok: false, transport: 'trez', reason: 'Trez request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private parseTrezResponse(body: string): { ok: boolean; reason: string } {
|
||||
if (!body) {
|
||||
return { ok: true, reason: 'accepted (empty response)' };
|
||||
}
|
||||
|
||||
const trimmed = body.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('error') ||
|
||||
lower.includes('fail') ||
|
||||
lower.includes('invalid') ||
|
||||
lower.includes('خطا')
|
||||
) {
|
||||
return { ok: false, reason: trimmed.slice(0, 120) };
|
||||
}
|
||||
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
const n = Number(trimmed);
|
||||
if (n === 1 || n > 1) {
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
if (n === 0) {
|
||||
return { ok: false, reason: 'provider code 0' };
|
||||
}
|
||||
if (n < 0) {
|
||||
return { ok: false, reason: `provider code ${trimmed}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (lower === 'true' || lower === 'ok' || lower === 'success') {
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
|
||||
if (lower === 'false') {
|
||||
return { ok: false, reason: 'provider returned false' };
|
||||
}
|
||||
|
||||
// Non-numeric IDs (message ref) are treated as success.
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
|
||||
private async sendVia0098Soap(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
@@ -139,20 +265,20 @@ export class SmsService {
|
||||
SMS_0098_SOAP_ERROR_CODES[code ?? ''] ??
|
||||
(code ? `provider code ${code}` : 'unknown SOAP response');
|
||||
this.logger.warn(
|
||||
`SOAP rejected to=${this.maskPhone(to)} code=${code ?? '?'} body=${body.slice(0, 200)}`,
|
||||
`0098 SOAP rejected to=${this.maskPhone(to)} code=${code ?? '?'} body=${body.slice(0, 200)}`,
|
||||
);
|
||||
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}`,
|
||||
`0098 SOAP request failed to=${this.maskPhone(to)}: ${message}`,
|
||||
e instanceof Error ? e.stack : undefined,
|
||||
);
|
||||
return { ok: false, transport: 'soap', reason: 'SOAP request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendViaLink(
|
||||
private async sendVia0098Link(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
@@ -192,7 +318,7 @@ export class SmsService {
|
||||
return { ok: true, transport: 'link' };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`Link SMS failed to=${this.maskPhone(to)}: ${message}`);
|
||||
this.logger.error(`0098 link SMS failed to=${this.maskPhone(to)}: ${message}`);
|
||||
return { ok: false, transport: 'link', reason: 'link request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user