Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت. Co-authored-by: Cursor <cursoragent@cursor.com>
276 lines
8.9 KiB
TypeScript
276 lines
8.9 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
/** پنل ترِز / رایگان — همان الگوی montime */
|
|
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
|
|
const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx';
|
|
|
|
const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
|
|
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
|
|
|
|
export type SmsSendResult = {
|
|
ok: boolean;
|
|
transport: 'trez' | 'soap' | 'link';
|
|
reason?: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class SmsService {
|
|
private readonly logger = new Logger(SmsService.name);
|
|
|
|
constructor(private readonly config: ConfigService) {}
|
|
|
|
isConfigured(): boolean {
|
|
const username = this.config.get<string>('SMS_USERNAME');
|
|
const password = this.getPassword();
|
|
const sender = this.config.get<string>('SMS_SENDER');
|
|
return Boolean(username && password && sender);
|
|
}
|
|
|
|
normalizePhoneForSms(mobile: string): string {
|
|
const digits = String(mobile ?? '').replace(/\D/g, '');
|
|
if (digits.startsWith('98') && digits.length === 12) {
|
|
return `0${digits.slice(2)}`;
|
|
}
|
|
if (digits.startsWith('0') && digits.length === 11) {
|
|
return digits;
|
|
}
|
|
if (digits.length === 10 && digits.startsWith('9')) {
|
|
return `0${digits}`;
|
|
}
|
|
return digits;
|
|
}
|
|
|
|
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
|
const template =
|
|
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
|
|
`کد ورود منتکت: {code}`;
|
|
const text = template.replace('{code}', code);
|
|
const result = await this.sendSmsDetailed(mobile, text);
|
|
return result.ok;
|
|
}
|
|
|
|
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
|
|
const username = this.config.get<string>('SMS_USERNAME');
|
|
const password = this.getPassword();
|
|
const sender = this.config.get<string>('SMS_SENDER');
|
|
|
|
if (!username || !password || !sender) {
|
|
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
|
|
}
|
|
|
|
const to = this.normalizePhoneForSms(phone);
|
|
const provider = this.resolveProvider();
|
|
|
|
if (provider === 'trez') {
|
|
return this.sendViaTrez(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(
|
|
`0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; link fallback`,
|
|
);
|
|
return this.sendVia0098Link(username, password, sender, to, text);
|
|
}
|
|
|
|
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 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 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(this.readTimeoutMs()),
|
|
});
|
|
const responseBody = (await res.text()).trim();
|
|
if (!res.ok) {
|
|
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}`,
|
|
);
|
|
}
|
|
return { ...parsed, transport: 'trez' };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
this.logger.error(`Trez failed to=${this.maskPhone(to)}: ${message}`);
|
|
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' };
|
|
return { ok: true, reason: 'accepted' };
|
|
}
|
|
|
|
private async sendVia0098Soap(
|
|
username: string,
|
|
password: string,
|
|
sender: string,
|
|
to: string,
|
|
text: string,
|
|
): Promise<SmsSendResult> {
|
|
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>${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(SMS_0098_SOAP_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'text/xml; charset=utf-8',
|
|
SOAPAction: 'http://tempuri.org/SendSMS',
|
|
},
|
|
body: soapBody,
|
|
signal: AbortSignal.timeout(this.readTimeoutMs()),
|
|
});
|
|
const body = (await res.text()).trim();
|
|
if (!res.ok) return { ok: false, transport: 'soap', reason: `HTTP ${res.status}` };
|
|
const code =
|
|
body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i)?.[1]?.trim() ??
|
|
null;
|
|
const n = code != null ? Number(code) : NaN;
|
|
if (Number.isFinite(n) && n >= 0) return { ok: true, transport: 'soap' };
|
|
return {
|
|
ok: false,
|
|
transport: 'soap',
|
|
reason: code ? `provider code ${code}` : 'unknown SOAP response',
|
|
};
|
|
} catch {
|
|
return { ok: false, transport: 'soap', reason: 'SOAP request failed' };
|
|
}
|
|
}
|
|
|
|
private async sendVia0098Link(
|
|
username: string,
|
|
password: string,
|
|
sender: string,
|
|
to: string,
|
|
text: string,
|
|
): Promise<SmsSendResult> {
|
|
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
|
|
const params = new URLSearchParams({
|
|
FROM: sender,
|
|
TO: to,
|
|
TEXT: text,
|
|
USERNAME: username,
|
|
PASSWORD: password,
|
|
DOMAIN: domain,
|
|
});
|
|
try {
|
|
const res = await fetch(`${SMS_0098_LINK_URL}?${params}`, {
|
|
signal: AbortSignal.timeout(this.readTimeoutMs()),
|
|
});
|
|
const body = (await res.text()).trim();
|
|
if (!res.ok) return { ok: false, transport: 'link', reason: `HTTP ${res.status}` };
|
|
const lower = body.toLowerCase();
|
|
if (lower.includes('error') || lower.includes('fail')) {
|
|
return { ok: false, transport: 'link', reason: body.slice(0, 120) };
|
|
}
|
|
return { ok: true, transport: 'link' };
|
|
} catch {
|
|
return { ok: false, transport: 'link', reason: 'link request failed' };
|
|
}
|
|
}
|
|
|
|
private escapeXml(value: string): string {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
private readTimeoutMs(): number {
|
|
const raw = this.config.get<string>('SMS_TIMEOUT_MS');
|
|
const n = raw ? Number.parseInt(raw, 10) : 20_000;
|
|
return Number.isFinite(n) && n > 0 ? n : 20_000;
|
|
}
|
|
|
|
private maskPhone(phone: string): string {
|
|
if (phone.length <= 6) return '***';
|
|
return `${phone.slice(0, 4)}****${phone.slice(-2)}`;
|
|
}
|
|
|
|
private getPassword(): string | undefined {
|
|
return (
|
|
this.config.get<string>('SMS_PASSWORD') ??
|
|
this.config.get<string>('SMS_API_KEY')
|
|
);
|
|
}
|
|
}
|