@objectstack/service-sms 14.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ import { ISmsTransport, NormalizedSmsMessage, SmsTransportSendResult, ISmsService, SendSmsInput, SendSmsResult } from '@objectstack/spec/contracts';
2
+ import { Plugin, PluginContext } from '@objectstack/core';
3
+
4
+ /**
5
+ * Normalize + validate a recipient phone number. Accepts E.164 and common
6
+ * human formats (spaces / dashes / dots / parens are stripped). Returns
7
+ * `undefined` when the result doesn't look like a phone number.
8
+ *
9
+ * Same shape rule as plugin-auth's `normalizePhoneNumber` — 6-15 digits
10
+ * with an optional leading `+` (kept local: the two packages must not
11
+ * depend on each other).
12
+ */
13
+ declare function normalizeSmsRecipient(raw: string): string | undefined;
14
+ /**
15
+ * Mask a phone number for log lines: keep the prefix (country-code-ish) and
16
+ * the last two digits, hide the middle. `+8613812345678` → `+8613******78`.
17
+ * SMS logging policy: masked recipient + status ONLY — never the body
18
+ * (OTP codes travel in it; see the ISmsService contract header).
19
+ */
20
+ declare function maskPhoneNumber(phone: string): string;
21
+ /**
22
+ * Development transport — never actually sends. Logs the masked recipient
23
+ * (and, OUTSIDE production only, the message body so local OTP flows are
24
+ * testable) and returns a synthetic message id.
25
+ *
26
+ * The production-body suppression is a hard rule from #2780: OTP codes must
27
+ * never land in logs. In dev the body IS the delivery — same pattern as the
28
+ * auth magic-link / invitation URLs, which are printed in dev only.
29
+ */
30
+ declare class LogSmsTransport implements ISmsTransport {
31
+ private readonly logger?;
32
+ private counter;
33
+ constructor(logger?: {
34
+ info: (msg: string, meta?: any) => void;
35
+ } | undefined);
36
+ send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult>;
37
+ }
38
+ interface SmsServiceOptions {
39
+ transport: ISmsTransport;
40
+ /**
41
+ * Whether `transport` is a real provider (Aliyun / Twilio / injected).
42
+ * `false` = development log fallback; surfaced via `isConfigured()` so
43
+ * consumers can gate SMS-dependent features in production.
44
+ */
45
+ configured: boolean;
46
+ /** Retry attempts on transport throw. Default 0 (no retry). */
47
+ retries?: number;
48
+ /** Logger for diagnostic output. NEVER receives message bodies. */
49
+ logger?: {
50
+ info: (msg: string, meta?: any) => void;
51
+ warn: (msg: string, meta?: any) => void;
52
+ };
53
+ }
54
+ /**
55
+ * Concrete ISmsService implementation.
56
+ *
57
+ * Flow: validate + normalize input → transport.send() (with optional
58
+ * retry) → SendSmsResult. Deliberately NO persistence and NO body logging —
59
+ * see the contract header in `@objectstack/spec/contracts/sms-service.ts`.
60
+ */
61
+ declare class SmsService implements ISmsService {
62
+ options: SmsServiceOptions;
63
+ constructor(options: SmsServiceOptions);
64
+ /**
65
+ * Hot-swap the underlying transport (settings namespace changed). The
66
+ * `configured` flag travels with the transport.
67
+ */
68
+ setTransport(transport: ISmsTransport, configured: boolean): void;
69
+ isConfigured(): boolean;
70
+ send(input: SendSmsInput): Promise<SendSmsResult>;
71
+ }
72
+
73
+ interface AliyunSmsTransportOptions {
74
+ accessKeyId: string;
75
+ accessKeySecret: string;
76
+ /** 短信签名 SignName — the registered sender signature, e.g. `阿里云短信测试`. */
77
+ signName: string;
78
+ /**
79
+ * Default 模板 TemplateCode used when the input carries no `templateId`
80
+ * (Aliyun only delivers pre-registered templates — free-form bodies are
81
+ * refused by the API). A catch-all template with a single `${content}`
82
+ * variable makes generic notification sends possible.
83
+ */
84
+ defaultTemplateCode?: string;
85
+ /** API endpoint host. Default `dysmsapi.aliyuncs.com`. */
86
+ endpoint?: string;
87
+ /** Injectable fetch for tests. */
88
+ fetchImpl?: typeof fetch;
89
+ }
90
+ /**
91
+ * Aliyun SMS (dysmsapi `SendSms`) transport, signed with the current
92
+ * ACS3-HMAC-SHA256 scheme — plain `fetch` + `node:crypto`, no vendor SDK.
93
+ *
94
+ * Aliyun is template-only: the transport sends `templateId` (falling back to
95
+ * the configured default TemplateCode) with `templateParams` (falling back to
96
+ * `{ content: body }` for the catch-all-template pattern). The rendered
97
+ * `body` itself is never transmitted outside `TemplateParam`.
98
+ */
99
+ declare class AliyunSmsTransport implements ISmsTransport {
100
+ private readonly options;
101
+ private readonly endpoint;
102
+ private readonly fetchImpl;
103
+ constructor(options: AliyunSmsTransportOptions);
104
+ send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult>;
105
+ }
106
+
107
+ interface TwilioSmsTransportOptions {
108
+ accountSid: string;
109
+ authToken: string;
110
+ /** Sender number (E.164). One of `from` / `messagingServiceSid` is required. */
111
+ from?: string;
112
+ /** Twilio Messaging Service SID (alternative to a fixed `from` number). */
113
+ messagingServiceSid?: string;
114
+ /** API base URL override (tests). Default `https://api.twilio.com`. */
115
+ baseUrl?: string;
116
+ /** Injectable fetch for tests. */
117
+ fetchImpl?: typeof fetch;
118
+ }
119
+ /**
120
+ * Twilio Programmable Messaging transport — a single `POST
121
+ * /2010-04-01/Accounts/{sid}/Messages.json` with HTTP Basic auth. Plain
122
+ * `fetch`, no vendor SDK. Free-form: delivers `body` verbatim.
123
+ */
124
+ declare class TwilioSmsTransport implements ISmsTransport {
125
+ private readonly options;
126
+ private readonly baseUrl;
127
+ private readonly fetchImpl;
128
+ constructor(options: TwilioSmsTransportOptions);
129
+ send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult>;
130
+ }
131
+
132
+ type SmsProviderTag = 'log' | 'aliyun' | 'twilio';
133
+ interface MakeSmsTransportOptions {
134
+ provider: SmsProviderTag;
135
+ /** Provider-specific credentials/options (see the transport option types). */
136
+ options?: Record<string, unknown>;
137
+ logger?: {
138
+ info: (msg: string, meta?: any) => void;
139
+ };
140
+ }
141
+ /**
142
+ * Build an ISmsTransport from a provider tag + opts. Used by
143
+ * SmsServicePlugin to materialise the transport selected by config /
144
+ * the `sms` settings namespace.
145
+ *
146
+ * Throws when a non-`log` provider is missing required credentials.
147
+ */
148
+ declare function makeSmsTransport(opts: MakeSmsTransportOptions): ISmsTransport;
149
+
150
+ /**
151
+ * Plugin configuration. Mirrors EmailServicePluginOptions: a directly
152
+ * injected transport wins, then `provider` + credentials, then the
153
+ * development `LogSmsTransport` fallback (no real send).
154
+ */
155
+ interface SmsServicePluginOptions {
156
+ /** Pluggable delivery transport. Overrides `provider`/credentials. */
157
+ transport?: ISmsTransport;
158
+ /** Provider tag — `'log' | 'aliyun' | 'twilio'`. Default `'log'`. */
159
+ provider?: SmsProviderTag;
160
+ /** Provider-specific credentials/options (see transport option types). */
161
+ providerOptions?: Record<string, unknown>;
162
+ /** Retry attempts on transport throw. Default 0. */
163
+ retries?: number;
164
+ }
165
+ /**
166
+ * SmsServicePlugin — registers the `sms` service (#2780).
167
+ *
168
+ * Lifecycle:
169
+ * - `init`: build transport (injected → provider+credentials →
170
+ * LogSmsTransport fallback); register the SmsService so dependents
171
+ * (auth OTP, the messaging `sms` channel) can resolve it.
172
+ * - `start` (kernel:ready): bind the `sms` settings namespace so the
173
+ * admin UI can live-swap the provider without a restart, and register
174
+ * the `sms/test` action. Env-locked keys (OS_SMS_*) still win at the
175
+ * settings-resolver level.
176
+ *
177
+ * Deliberately NO persistence objects: SMS bodies carry OTP codes — see the
178
+ * ISmsService contract header.
179
+ */
180
+ declare class SmsServicePlugin implements Plugin {
181
+ name: string;
182
+ version: string;
183
+ type: "standard";
184
+ private readonly options;
185
+ private service?;
186
+ constructor(options?: SmsServicePluginOptions);
187
+ private resolveInitialTransport;
188
+ init(ctx: PluginContext): Promise<void>;
189
+ start(ctx: PluginContext): Promise<void>;
190
+ /**
191
+ * Translate the `sms` settings snapshot into a transport and hot-swap it
192
+ * on the running SmsService. Incomplete credentials keep the previous
193
+ * transport (with a warning) so a half-saved form can't break delivery.
194
+ */
195
+ private applySmsSettings;
196
+ }
197
+
198
+ export { AliyunSmsTransport, type AliyunSmsTransportOptions, LogSmsTransport, type MakeSmsTransportOptions, type SmsProviderTag, SmsService, type SmsServiceOptions, SmsServicePlugin, type SmsServicePluginOptions, TwilioSmsTransport, type TwilioSmsTransportOptions, makeSmsTransport, maskPhoneNumber, normalizeSmsRecipient };
package/dist/index.js ADDED
@@ -0,0 +1,443 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AliyunSmsTransport: () => AliyunSmsTransport,
24
+ LogSmsTransport: () => LogSmsTransport,
25
+ SmsService: () => SmsService,
26
+ SmsServicePlugin: () => SmsServicePlugin,
27
+ TwilioSmsTransport: () => TwilioSmsTransport,
28
+ makeSmsTransport: () => makeSmsTransport,
29
+ maskPhoneNumber: () => maskPhoneNumber,
30
+ normalizeSmsRecipient: () => normalizeSmsRecipient
31
+ });
32
+ module.exports = __toCommonJS(index_exports);
33
+
34
+ // src/sms-service.ts
35
+ function normalizeSmsRecipient(raw) {
36
+ const stripped = String(raw ?? "").replace(/[\s\-().]/g, "");
37
+ return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : void 0;
38
+ }
39
+ function maskPhoneNumber(phone) {
40
+ const p = String(phone ?? "");
41
+ if (p.length <= 6) return `${p.slice(0, 2)}****`;
42
+ return `${p.slice(0, 5)}${"*".repeat(Math.max(2, p.length - 7))}${p.slice(-2)}`;
43
+ }
44
+ var LogSmsTransport = class {
45
+ constructor(logger) {
46
+ this.logger = logger;
47
+ this.counter = 0;
48
+ }
49
+ async send(message) {
50
+ const messageId = `dev-sms-${Date.now()}-${++this.counter}`;
51
+ const dev = globalThis?.process?.env?.NODE_ENV !== "production";
52
+ this.logger?.info(
53
+ `[LogSmsTransport] would send SMS to ${maskPhoneNumber(message.to)}` + (dev ? ` \u2014 body: ${message.body}` : " (body suppressed outside dev)"),
54
+ { messageId }
55
+ );
56
+ return { messageId, response: "logged" };
57
+ }
58
+ };
59
+ var SmsService = class {
60
+ constructor(options) {
61
+ this.options = options;
62
+ if (!options.transport) throw new Error("SmsService: transport is required");
63
+ }
64
+ /**
65
+ * Hot-swap the underlying transport (settings namespace changed). The
66
+ * `configured` flag travels with the transport.
67
+ */
68
+ setTransport(transport, configured) {
69
+ this.options.transport = transport;
70
+ this.options.configured = configured;
71
+ }
72
+ isConfigured() {
73
+ return this.options.configured;
74
+ }
75
+ async send(input) {
76
+ const to = normalizeSmsRecipient(input?.to ?? "");
77
+ if (!to) {
78
+ throw new Error(`VALIDATION_FAILED: '${maskPhoneNumber(String(input?.to ?? ""))}' is not a valid phone number`);
79
+ }
80
+ const body = String(input?.body ?? "").trim();
81
+ if (!body && !input?.templateId && !input?.templateParams) {
82
+ throw new Error("VALIDATION_FAILED: body (or templateId/templateParams) is required");
83
+ }
84
+ const normalized = {
85
+ to,
86
+ body,
87
+ ...input.templateId ? { templateId: input.templateId } : {},
88
+ ...input.templateParams ? { templateParams: input.templateParams } : {}
89
+ };
90
+ const id = `sms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
91
+ const maxAttempts = (this.options.retries ?? 0) + 1;
92
+ let lastError;
93
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
94
+ try {
95
+ const result = await this.options.transport.send(normalized);
96
+ this.options.logger?.info(
97
+ `[SmsService] sent to ${maskPhoneNumber(to)} (messageId=${result.messageId})`
98
+ );
99
+ return { id, status: "sent", messageId: result.messageId };
100
+ } catch (err) {
101
+ lastError = err;
102
+ if (attempt < maxAttempts) {
103
+ await new Promise((r) => setTimeout(r, Math.min(2e3, 100 * 2 ** (attempt - 1))));
104
+ }
105
+ }
106
+ }
107
+ const errMessage = String(lastError?.message ?? lastError ?? "send failed").slice(0, 500);
108
+ this.options.logger?.warn(
109
+ `[SmsService] send to ${maskPhoneNumber(to)} failed: ${errMessage}`
110
+ );
111
+ return { id, status: "failed", error: errMessage };
112
+ }
113
+ };
114
+
115
+ // src/transports/aliyun.ts
116
+ var import_node_crypto = require("crypto");
117
+ var API_VERSION = "2017-05-25";
118
+ var ALGORITHM = "ACS3-HMAC-SHA256";
119
+ var sha256Hex = (s) => (0, import_node_crypto.createHash)("sha256").update(s, "utf8").digest("hex");
120
+ var hmac256Hex = (key, s) => (0, import_node_crypto.createHmac)("sha256", key).update(s, "utf8").digest("hex");
121
+ var encode = (s) => encodeURIComponent(s).replace(/\+/g, "%20").replace(/\*/g, "%2A").replace(/%7E/g, "~");
122
+ var AliyunSmsTransport = class {
123
+ constructor(options) {
124
+ this.options = options;
125
+ if (!options.accessKeyId || !options.accessKeySecret) {
126
+ throw new Error("AliyunSmsTransport: accessKeyId and accessKeySecret are required");
127
+ }
128
+ if (!options.signName) {
129
+ throw new Error("AliyunSmsTransport: signName is required");
130
+ }
131
+ this.endpoint = options.endpoint ?? "dysmsapi.aliyuncs.com";
132
+ this.fetchImpl = options.fetchImpl ?? fetch;
133
+ }
134
+ async send(message) {
135
+ const templateCode = message.templateId ?? this.options.defaultTemplateCode;
136
+ if (!templateCode) {
137
+ throw new Error(
138
+ "AliyunSmsTransport: Aliyun requires a template \u2014 pass templateId or configure a default template code"
139
+ );
140
+ }
141
+ const templateParam = JSON.stringify(message.templateParams ?? { content: message.body });
142
+ const query = {
143
+ PhoneNumbers: message.to,
144
+ SignName: this.options.signName,
145
+ TemplateCode: templateCode,
146
+ TemplateParam: templateParam
147
+ };
148
+ const canonicalQuery = Object.keys(query).sort().map((k) => `${encode(k)}=${encode(query[k])}`).join("&");
149
+ const bodyHash = sha256Hex("");
150
+ const headers = {
151
+ host: this.endpoint,
152
+ "x-acs-action": "SendSms",
153
+ "x-acs-content-sha256": bodyHash,
154
+ "x-acs-date": (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
155
+ "x-acs-signature-nonce": (0, import_node_crypto.randomUUID)(),
156
+ "x-acs-version": API_VERSION
157
+ };
158
+ const signedHeaderNames = Object.keys(headers).sort();
159
+ const canonicalHeaders = signedHeaderNames.map((k) => `${k}:${headers[k].trim()}
160
+ `).join("");
161
+ const signedHeaders = signedHeaderNames.join(";");
162
+ const canonicalRequest = ["POST", "/", canonicalQuery, canonicalHeaders, signedHeaders, bodyHash].join("\n");
163
+ const stringToSign = `${ALGORITHM}
164
+ ${sha256Hex(canonicalRequest)}`;
165
+ const signature = hmac256Hex(this.options.accessKeySecret, stringToSign);
166
+ const response = await this.fetchImpl(`https://${this.endpoint}/?${canonicalQuery}`, {
167
+ method: "POST",
168
+ headers: {
169
+ ...headers,
170
+ Authorization: `${ALGORITHM} Credential=${this.options.accessKeyId},SignedHeaders=${signedHeaders},Signature=${signature}`
171
+ }
172
+ });
173
+ let payload = {};
174
+ try {
175
+ payload = await response.json();
176
+ } catch {
177
+ }
178
+ if (!response.ok || payload?.Code !== "OK") {
179
+ const code = payload?.Code ?? `HTTP_${response.status}`;
180
+ const detail = payload?.Message ?? response.statusText ?? "request failed";
181
+ throw new Error(`Aliyun SendSms failed (${code}): ${detail}`);
182
+ }
183
+ return { messageId: String(payload.BizId ?? payload.RequestId ?? ""), response: payload.RequestId };
184
+ }
185
+ };
186
+
187
+ // src/transports/twilio.ts
188
+ var TwilioSmsTransport = class {
189
+ constructor(options) {
190
+ this.options = options;
191
+ if (!options.accountSid || !options.authToken) {
192
+ throw new Error("TwilioSmsTransport: accountSid and authToken are required");
193
+ }
194
+ if (!options.from && !options.messagingServiceSid) {
195
+ throw new Error("TwilioSmsTransport: one of from / messagingServiceSid is required");
196
+ }
197
+ this.baseUrl = (options.baseUrl ?? "https://api.twilio.com").replace(/\/$/, "");
198
+ this.fetchImpl = options.fetchImpl ?? fetch;
199
+ }
200
+ async send(message) {
201
+ const form = new URLSearchParams({
202
+ To: message.to,
203
+ Body: message.body,
204
+ ...this.options.messagingServiceSid ? { MessagingServiceSid: this.options.messagingServiceSid } : { From: this.options.from }
205
+ });
206
+ const auth = Buffer.from(`${this.options.accountSid}:${this.options.authToken}`).toString("base64");
207
+ const response = await this.fetchImpl(
208
+ `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.options.accountSid)}/Messages.json`,
209
+ {
210
+ method: "POST",
211
+ headers: {
212
+ Authorization: `Basic ${auth}`,
213
+ "Content-Type": "application/x-www-form-urlencoded"
214
+ },
215
+ body: form.toString()
216
+ }
217
+ );
218
+ let payload = {};
219
+ try {
220
+ payload = await response.json();
221
+ } catch {
222
+ }
223
+ if (!response.ok) {
224
+ const code = payload?.code ?? `HTTP_${response.status}`;
225
+ const detail = payload?.message ?? response.statusText ?? "request failed";
226
+ throw new Error(`Twilio send failed (${code}): ${detail}`);
227
+ }
228
+ return { messageId: String(payload.sid ?? ""), response: payload.status };
229
+ }
230
+ };
231
+
232
+ // src/transports/index.ts
233
+ function makeSmsTransport(opts) {
234
+ const { provider, options = {}, logger } = opts;
235
+ switch (provider) {
236
+ case "log":
237
+ return new LogSmsTransport(logger);
238
+ case "aliyun":
239
+ return new AliyunSmsTransport(options);
240
+ case "twilio":
241
+ return new TwilioSmsTransport(options);
242
+ default:
243
+ throw new Error(`makeSmsTransport: unknown provider '${provider}'`);
244
+ }
245
+ }
246
+
247
+ // src/sms-plugin.ts
248
+ function providerFromSettings(values) {
249
+ const provider = String(values.provider ?? "log");
250
+ const str = (k) => {
251
+ const v = values[k];
252
+ return typeof v === "string" && v.trim().length > 0 ? v.trim() : void 0;
253
+ };
254
+ if (provider === "aliyun") {
255
+ const accessKeyId = str("aliyun_access_key_id");
256
+ const accessKeySecret = str("aliyun_access_key_secret");
257
+ const signName = str("aliyun_sign_name");
258
+ if (!accessKeyId || !accessKeySecret || !signName) {
259
+ return { provider, options: {}, missing: "aliyun_access_key_id / aliyun_access_key_secret / aliyun_sign_name" };
260
+ }
261
+ return {
262
+ provider,
263
+ options: {
264
+ accessKeyId,
265
+ accessKeySecret,
266
+ signName,
267
+ ...str("aliyun_template_code") ? { defaultTemplateCode: str("aliyun_template_code") } : {}
268
+ }
269
+ };
270
+ }
271
+ if (provider === "twilio") {
272
+ const accountSid = str("twilio_account_sid");
273
+ const authToken = str("twilio_auth_token");
274
+ const from = str("twilio_from_number");
275
+ const messagingServiceSid = str("twilio_messaging_service_sid");
276
+ if (!accountSid || !authToken || !from && !messagingServiceSid) {
277
+ return { provider, options: {}, missing: "twilio_account_sid / twilio_auth_token / twilio_from_number (or messaging service SID)" };
278
+ }
279
+ return {
280
+ provider,
281
+ options: {
282
+ accountSid,
283
+ authToken,
284
+ ...from ? { from } : {},
285
+ ...messagingServiceSid ? { messagingServiceSid } : {}
286
+ }
287
+ };
288
+ }
289
+ return { provider: "log", options: {} };
290
+ }
291
+ var SmsServicePlugin = class {
292
+ constructor(options = {}) {
293
+ this.name = "com.objectstack.service.sms";
294
+ this.version = "1.0.0";
295
+ this.type = "standard";
296
+ this.options = options;
297
+ }
298
+ resolveInitialTransport(ctx) {
299
+ if (this.options.transport) return { transport: this.options.transport, configured: true };
300
+ const provider = this.options.provider ?? "log";
301
+ if (provider === "log") return { transport: new LogSmsTransport(ctx.logger), configured: false };
302
+ try {
303
+ return {
304
+ transport: makeSmsTransport({ provider, options: this.options.providerOptions, logger: ctx.logger }),
305
+ configured: true
306
+ };
307
+ } catch (err) {
308
+ ctx.logger.warn(
309
+ `SmsServicePlugin: provider='${provider}' selected but transport build failed (${err?.message ?? err}) \u2014 falling back to LogSmsTransport.`
310
+ );
311
+ return { transport: new LogSmsTransport(ctx.logger), configured: false };
312
+ }
313
+ }
314
+ async init(ctx) {
315
+ const { transport, configured } = this.resolveInitialTransport(ctx);
316
+ if (!configured) {
317
+ ctx.logger.info("SmsServicePlugin: no provider configured \u2014 using LogSmsTransport (SMS will NOT be sent)");
318
+ } else {
319
+ ctx.logger.info(`SmsServicePlugin: using '${this.options.provider ?? "custom"}' provider`);
320
+ }
321
+ this.service = new SmsService({
322
+ transport,
323
+ configured,
324
+ retries: this.options.retries,
325
+ logger: ctx.logger
326
+ });
327
+ ctx.registerService("sms", this.service);
328
+ ctx.logger.info("SmsServicePlugin: sms service registered");
329
+ }
330
+ async start(ctx) {
331
+ ctx.hook("kernel:ready", async () => {
332
+ if (!this.service) return;
333
+ if (this.options.transport) return;
334
+ try {
335
+ const settings = ctx.getService("settings");
336
+ if (!settings || typeof settings.getNamespace !== "function") return;
337
+ const applySettings = async () => {
338
+ try {
339
+ const payload = await settings.getNamespace("sms");
340
+ const values = {};
341
+ for (const [k, v] of Object.entries(payload.values)) {
342
+ values[k] = v?.value;
343
+ }
344
+ this.applySmsSettings(values, ctx);
345
+ } catch (err) {
346
+ ctx.logger.warn("SmsServicePlugin: failed to apply sms settings: " + (err?.message ?? err));
347
+ }
348
+ };
349
+ await applySettings();
350
+ if (typeof settings.subscribe === "function") {
351
+ settings.subscribe("sms", () => {
352
+ void applySettings();
353
+ });
354
+ ctx.logger.info("SmsServicePlugin: bound to settings:changed for namespace=sms");
355
+ }
356
+ if (typeof settings.registerAction === "function") {
357
+ const svc = this.service;
358
+ settings.registerAction("sms", "test", async ({ values, payload, ctx: actionCtx }) => {
359
+ const overrides = payload && typeof payload === "object" && payload.values && typeof payload.values === "object" ? payload.values : payload ?? {};
360
+ const merged = { ...values ?? {}, ...overrides };
361
+ const rawTo = actionCtx?.body?.to ?? payload?.to;
362
+ const to = rawTo ? normalizeSmsRecipient(rawTo) : void 0;
363
+ if (!to) {
364
+ return { ok: false, severity: "error", message: 'Provide a valid "to" phone number (E.164 recommended).' };
365
+ }
366
+ const resolved = providerFromSettings(merged);
367
+ if (resolved.missing) {
368
+ return { ok: false, severity: "error", message: `${resolved.provider}: missing ${resolved.missing}.` };
369
+ }
370
+ let target = svc;
371
+ if (resolved.provider !== "log") {
372
+ try {
373
+ const transport = makeSmsTransport({ provider: resolved.provider, options: resolved.options, logger: ctx.logger });
374
+ target = new SmsService({ transport, configured: true, logger: ctx.logger });
375
+ } catch (err) {
376
+ return { ok: false, severity: "error", message: `Failed to build ${resolved.provider} transport: ${err?.message ?? String(err)}` };
377
+ }
378
+ }
379
+ try {
380
+ const result = await target.send({
381
+ to,
382
+ body: "ObjectStack SMS test message.",
383
+ templateParams: { content: "ObjectStack SMS test message." }
384
+ });
385
+ if (result.status === "failed") {
386
+ return { ok: false, severity: "error", message: result.error ?? "Send failed." };
387
+ }
388
+ return {
389
+ ok: true,
390
+ severity: "info",
391
+ message: `Sent test SMS to ${maskPhoneNumber(to)} via ${resolved.provider} (id=${result.messageId ?? result.id}).`
392
+ };
393
+ } catch (err) {
394
+ return { ok: false, severity: "error", message: err?.message ?? String(err) };
395
+ }
396
+ });
397
+ }
398
+ } catch {
399
+ }
400
+ });
401
+ }
402
+ /**
403
+ * Translate the `sms` settings snapshot into a transport and hot-swap it
404
+ * on the running SmsService. Incomplete credentials keep the previous
405
+ * transport (with a warning) so a half-saved form can't break delivery.
406
+ */
407
+ applySmsSettings(values, ctx) {
408
+ if (!this.service) return;
409
+ const resolved = providerFromSettings(values);
410
+ if (resolved.missing) {
411
+ ctx.logger.warn(
412
+ `SmsServicePlugin: provider='${resolved.provider}' selected but ${resolved.missing} is empty \u2014 transport NOT rebuilt.`
413
+ );
414
+ return;
415
+ }
416
+ if (resolved.provider === "log") {
417
+ if (values.provider === "log") {
418
+ this.service.setTransport(new LogSmsTransport(ctx.logger), false);
419
+ ctx.logger.info("SmsServicePlugin: sms settings applied (provider=log; SMS will NOT be sent).");
420
+ }
421
+ return;
422
+ }
423
+ try {
424
+ const transport = makeSmsTransport({ provider: resolved.provider, options: resolved.options, logger: ctx.logger });
425
+ this.service.setTransport(transport, true);
426
+ ctx.logger.info(`SmsServicePlugin: transport rebuilt from settings (provider=${resolved.provider}).`);
427
+ } catch (err) {
428
+ ctx.logger.warn("SmsServicePlugin: failed to rebuild transport: " + (err?.message ?? err));
429
+ }
430
+ }
431
+ };
432
+ // Annotate the CommonJS export names for ESM import in node:
433
+ 0 && (module.exports = {
434
+ AliyunSmsTransport,
435
+ LogSmsTransport,
436
+ SmsService,
437
+ SmsServicePlugin,
438
+ TwilioSmsTransport,
439
+ makeSmsTransport,
440
+ maskPhoneNumber,
441
+ normalizeSmsRecipient
442
+ });
443
+ //# sourceMappingURL=index.js.map