Add partner SMS gateway via Gama SendQuick and document it.
Expose POST /public/sms/send with API key + domain allowlist for external backends like Balout, wire Meshkee OTP/message sends to Gama, and publish Partner SMS docs on /docs/website. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
267a218c26
commit
f4295e780c
+168
-11
@@ -1,6 +1,22 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type SmsSendResult = {
|
||||
serverId: string;
|
||||
};
|
||||
|
||||
type GamaQuickResponse = {
|
||||
success?: boolean;
|
||||
message?: string | null;
|
||||
errors?: unknown;
|
||||
body?: number | string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
@@ -11,27 +27,168 @@ export class SmsService {
|
||||
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
||||
}
|
||||
|
||||
async sendVerificationCode(cellNumber: string, code: string): Promise<void> {
|
||||
async sendVerificationCode(cellNumber: string, code: string): Promise<SmsSendResult> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn(
|
||||
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
|
||||
);
|
||||
return;
|
||||
return { serverId: 'disabled' };
|
||||
}
|
||||
|
||||
// TODO: integrate real SMS provider when API credentials are available
|
||||
this.logger.log(`Sending SMS verification code to ${cellNumber}`);
|
||||
throw new Error('SMS provider is not configured yet');
|
||||
const message = `کد تایید شما: ${code}`;
|
||||
return this.sendMessage(cellNumber, message);
|
||||
}
|
||||
|
||||
async sendMessage(cellNumber: string, message: string): Promise<void> {
|
||||
async sendMessage(cellNumber: string, message: string): Promise<SmsSendResult> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
|
||||
return;
|
||||
return { serverId: 'disabled' };
|
||||
}
|
||||
|
||||
// TODO: integrate real SMS provider when API credentials are available
|
||||
this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`);
|
||||
throw new Error('SMS provider is not configured yet');
|
||||
const text = message.trim();
|
||||
if (!text) {
|
||||
throw new BadRequestException('Message is empty');
|
||||
}
|
||||
if (text.length > 700) {
|
||||
throw new BadRequestException('Message is too long (max 700 characters)');
|
||||
}
|
||||
|
||||
const destination = toGamaMsisdn(cellNumber);
|
||||
if (!destination) {
|
||||
throw new BadRequestException('Invalid cellphone number');
|
||||
}
|
||||
|
||||
return this.sendQuick(destination, text);
|
||||
}
|
||||
|
||||
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
|
||||
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
||||
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
||||
const source = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
|
||||
const baseUrl = (
|
||||
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
|
||||
).replace(/\/$/, '');
|
||||
|
||||
if (!username || !password || !source) {
|
||||
this.logger.error('SMS provider credentials are not configured');
|
||||
throw new ServiceUnavailableException('SMS provider is not configured yet');
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/send/quick`;
|
||||
const masked = maskMsisdn(destination);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
message,
|
||||
source,
|
||||
destination,
|
||||
delay: 0,
|
||||
expire: null,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Gama SendQuick network error for ${masked}: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('SMS provider is unreachable');
|
||||
}
|
||||
|
||||
let payload: GamaQuickResponse;
|
||||
try {
|
||||
payload = (await response.json()) as GamaQuickResponse;
|
||||
} catch {
|
||||
this.logger.error(`Gama SendQuick returned non-JSON for ${masked} (HTTP ${response.status})`);
|
||||
throw new ServiceUnavailableException('SMS provider returned an invalid response');
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.success) {
|
||||
const providerMessage =
|
||||
typeof payload.message === 'string' && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: `HTTP ${response.status}`;
|
||||
this.logger.warn(`Gama SendQuick failed for ${masked}: ${providerMessage}`);
|
||||
throw mapGamaError(providerMessage);
|
||||
}
|
||||
|
||||
const serverId = String(payload.body ?? '');
|
||||
if (!serverId) {
|
||||
this.logger.warn(`Gama SendQuick succeeded without serverId for ${masked}`);
|
||||
throw new ServiceUnavailableException('SMS provider returned an empty server id');
|
||||
}
|
||||
|
||||
this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`);
|
||||
return { serverId };
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */
|
||||
export function toGamaMsisdn(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (!digits) return null;
|
||||
|
||||
let msisdn = digits;
|
||||
if (msisdn.startsWith('0') && msisdn.length === 11) {
|
||||
msisdn = `98${msisdn.slice(1)}`;
|
||||
} else if (msisdn.length === 10 && msisdn.startsWith('9')) {
|
||||
msisdn = `98${msisdn}`;
|
||||
} else if (msisdn.startsWith('98') && msisdn.length === 12) {
|
||||
// already MSISDN
|
||||
} else if (trimmed.startsWith('+') && digits.length >= 10 && digits.length <= 15) {
|
||||
msisdn = digits;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!/^98\d{10}$/.test(msisdn)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return msisdn;
|
||||
}
|
||||
|
||||
export function maskMsisdn(msisdn: string): string {
|
||||
if (msisdn.length < 6) return '***';
|
||||
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
||||
}
|
||||
|
||||
function mapGamaError(message: string): Error {
|
||||
const lower = message.toLowerCase();
|
||||
if (
|
||||
lower.includes('authentication failed') ||
|
||||
lower.includes('access denied') ||
|
||||
lower.includes('inactive user')
|
||||
) {
|
||||
return new ServiceUnavailableException('SMS provider authentication failed');
|
||||
}
|
||||
if (
|
||||
lower.includes('credit is not enough') ||
|
||||
lower.includes('expired account')
|
||||
) {
|
||||
return new ServiceUnavailableException('SMS provider credit or account issue');
|
||||
}
|
||||
if (
|
||||
lower.includes('cellphone') ||
|
||||
lower.includes('valid cellphone') ||
|
||||
lower.includes('invalid orig') ||
|
||||
lower.includes('arguments')
|
||||
) {
|
||||
return new BadRequestException('Invalid SMS destination or sender');
|
||||
}
|
||||
if (lower.includes('message is empty')) {
|
||||
return new BadRequestException('Message is empty');
|
||||
}
|
||||
return new ServiceUnavailableException('SMS provider rejected the request');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user