Files
tehran_tala/apps/api/src/modules/sms/drivers/relay.driver.ts
T

111 lines
3.9 KiB
TypeScript
Raw Normal View History

import { networkInterfaces } from 'node:os';
import { envOr, envOrNull } from '../../../common/env';
import { SmsDriver, postWithTimeout } from './sms-driver';
/**
* The owner's SMS gateway (`/api/createsms`) — a relay that serves the SMS of
* several panels and apps and forwards to the actual provider. The default
* driver since D26.
*
* Shape, from the owner's PHP client:
* * POST JSON, 15s timeout, auth header `secret_key`
* * body: { phone, host, text, platform }
* * `host` identifies the sending server: a configured value, else the
* server's own IP (`gethostbyname(gethostname())`)
* * `platform: "kave"` routes through Kavenegar; null lets the gateway pick.
* The owner wants every message through Kavenegar.
*
* The secret is in source on the owner's decision (D26): the gateway has a
* second verification factor held elsewhere. `SMS_RELAY_SECRET` overrides it.
*
* The PHP client forced IPv4 (CURLOPT_IPRESOLVE); the URL is an IP literal, so
* there is nothing to resolve and fetch needs no equivalent.
*/
const DEFAULT_RELAY_URL = 'http://185.231.112.101/api/createsms';
const DEFAULT_RELAY_SECRET = 'f21debfb-83ac-4624-a2f4-f08375043181';
const DEFAULT_RELAY_PLATFORM = 'kave';
/** `Http::timeout(15)` in the owner's client. */
const REQUEST_TIMEOUT_MS = 15_000;
/** The first external IPv4 of this machine — Node's `gethostbyname(gethostname())`. */
export function serverIpv4(): string | null {
for (const addresses of Object.values(networkInterfaces())) {
for (const a of addresses ?? []) {
if (a.family === 'IPv4' && !a.internal) return a.address;
}
}
return null;
}
export function relayConfig() {
const platform = envOr('SMS_RELAY_PLATFORM', DEFAULT_RELAY_PLATFORM);
return {
url: envOr('SMS_RELAY_URL', DEFAULT_RELAY_URL),
secret: envOr('SMS_RELAY_SECRET', DEFAULT_RELAY_SECRET),
host: envOrNull('SMS_RELAY_HOST') ?? serverIpv4(),
// "default" sends null, so the gateway chooses the line itself.
};
}
/**
* The gateway's reply format is not documented, so a 2xx is trusted unless the
* body plainly says otherwise. Returns the reason, or null when the reply
* reads as accepted.
*
* A non-empty body that is not JSON counts as a failure: the gateway is a JSON
* API, and its own PHP errors arrive as bare text with HTTP 200 — seen live on
* 2026-10-05 as "cURL library is not loaded", with nothing delivered.
*/
export function relayRejection(body: string): string | null {
const text = body.trim();
if (text === '') return null;
let data: unknown;
try {
data = JSON.parse(text);
} catch {
return text.slice(0, 200);
}
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
const d = data as Record<string, unknown>;
const failedStatus =
d.status === false ||
(typeof d.status === 'string' && /^(error|fail|failed|failure)$/i.test(d.status));
const failed = failedStatus || d.success === false || d.ok === false || Boolean(d.error);
if (!failed) return null;
if (typeof d.error === 'string' && d.error) return d.error;
if (typeof d.message === 'string' && d.message) return d.message;
return 'rejected-by-gateway';
}
export const relayDriver: SmsDriver = {
name: 'relay',
missingConfig() {
// The URL and secret always have defaults; only the host can be missing,
// on a machine with no external IPv4.
return relayConfig().host ? [] : ['SMS_RELAY_HOST'];
},
async deliver(mobile, text) {
const c = relayConfig();
const reply = await postWithTimeout(
c.url,
{ 'Content-Type': 'application/json', secret_key: c.secret },
JSON.stringify({ phone: mobile, host: c.host, text }),
REQUEST_TIMEOUT_MS,
);
if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` };
const rejection = relayRejection(reply.body);
if (rejection) return { ok: false, response: reply.body, error: rejection };
return { ok: true, response: reply.body };
},
};