@zeroxsolutions/sms 0.0.2 → 0.1.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.
- package/CHANGELOG.md +12 -0
- package/README.md +82 -21
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/esms/delivery-report.d.ts +13 -0
- package/dist/lib/esms/delivery-report.d.ts.map +1 -0
- package/dist/lib/esms/delivery-report.js +50 -0
- package/dist/lib/esms/index.d.ts +4 -0
- package/dist/lib/esms/index.d.ts.map +1 -0
- package/dist/lib/esms/index.js +3 -0
- package/dist/lib/esms/sender.d.ts +30 -0
- package/dist/lib/esms/sender.d.ts.map +1 -0
- package/dist/lib/esms/sender.js +122 -0
- package/dist/lib/sender/errors.d.ts +11 -1
- package/dist/lib/sender/errors.d.ts.map +1 -1
- package/dist/lib/sender/errors.js +14 -1
- package/dist/lib/sender/port.d.ts +29 -1
- package/dist/lib/sender/port.d.ts.map +1 -1
- package/package.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 0.1.0 (2026-09-25)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- **sms:** let an esms caller set the endpoint and the timeout ([d8cda41](https://github.com/zeroxsolutions/zeroxsolutions/commit/d8cda41))
|
|
6
|
+
- **sms:** send through esms and read its delivery reports ([d88829b](https://github.com/zeroxsolutions/zeroxsolutions/commit/d88829b))
|
|
7
|
+
|
|
8
|
+
### ❤️ Thank You
|
|
9
|
+
|
|
10
|
+
- Claude Opus 5.5
|
|
11
|
+
- Lương Văn Tú
|
|
12
|
+
|
|
1
13
|
## 0.0.2 (2026-09-22)
|
|
2
14
|
|
|
3
15
|
### 🚀 Features
|
package/README.md
CHANGED
|
@@ -18,8 +18,8 @@ pnpm add @zeroxsolutions/sms
|
|
|
18
18
|
|
|
19
19
|
## The port
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
composed message:
|
|
21
|
+
A consumer implements `ISmsSender` for a provider this package has no adapter for, and calls `send`
|
|
22
|
+
with a composed message:
|
|
23
23
|
|
|
24
24
|
```ts
|
|
25
25
|
import type { ISmsSender, SendSmsResult, SmsMessage } from '@zeroxsolutions/sms';
|
|
@@ -44,33 +44,94 @@ const { messageId } = await new ProviderSender().send({
|
|
|
44
44
|
takes a name and an address pair. `to` is in E.164 and is not validated here: which numbers a product
|
|
45
45
|
accepts, and how it normalises one, is the product's own decision.
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
`reference` is optional: the caller's own id for the message. An adapter whose provider refuses a
|
|
48
|
+
repeated id sends it, so a send retried after a lost answer cannot deliver and charge twice.
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
provider's SDK, and the table mapping that provider's codes onto the refusals below can only be
|
|
51
|
-
written against that provider's documentation. When an account exists, the adapter arrives as its own
|
|
52
|
-
subpath, `@zeroxsolutions/sms/<provider>`, with that SDK as an optional peer dependency, so a
|
|
53
|
-
consumer that never imports it does not install it.
|
|
50
|
+
## eSMS
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
`@zeroxsolutions/sms/esms` sends through [eSMS](https://esms.vn) as customer-care SMS under a
|
|
53
|
+
registered brandname, inside Vietnam. It posts to eSMS's REST API over the runtime's own `fetch`, so it
|
|
54
|
+
installs nothing and runs on workerd as on Node.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { EsmsSmsSender } from '@zeroxsolutions/sms/esms';
|
|
58
|
+
|
|
59
|
+
const sender = new EsmsSmsSender({
|
|
60
|
+
apiKey: env.ESMS_API_KEY,
|
|
61
|
+
secretKey: env.ESMS_SECRET_KEY,
|
|
62
|
+
sandbox: false,
|
|
63
|
+
callbackUrl: 'https://app.example.com/esms-callback/<a secret of yours>',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const { messageId } = await sender.send({
|
|
67
|
+
from: 'TRIPVN',
|
|
68
|
+
to: '+84900000000',
|
|
69
|
+
text: 'Ma xac thuc cua ban la 123456.',
|
|
70
|
+
reference: 'msg_01j9',
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`send` resolving means eSMS accepted the message, not that the handset received it. A number that is
|
|
75
|
+
not `+84` followed by 9 or 10 digits is refused before any request is made.
|
|
76
|
+
|
|
77
|
+
| eSMS `CodeResult` | Refusal |
|
|
78
|
+
| ------------------------------------------- | --------------------------------------------------------------- |
|
|
79
|
+
| `108` | `SmsRecipientInvalid` |
|
|
80
|
+
| `99` | `SmsRecipientUnreachable` |
|
|
81
|
+
| `104`, `177` | `SmsSenderNotRegistered` |
|
|
82
|
+
| `146`, `201` | `SmsMessageRejected` |
|
|
83
|
+
| `103` | `SmsBalanceExhausted` |
|
|
84
|
+
| not 2xx, not JSON, or no answer in time | `SmsProviderUnavailable` |
|
|
85
|
+
| `124` with a `reference` | none: answered as sent, with the `reference` as its `messageId` |
|
|
86
|
+
| any other | `SmsSendFailed` |
|
|
87
|
+
|
|
88
|
+
The account has to meet four conditions no code can check:
|
|
89
|
+
|
|
90
|
+
1. The brandname is registered with the carriers.
|
|
91
|
+
2. The text matches the customer-care template registered for it, or eSMS answers `146`.
|
|
92
|
+
3. The account does not require an IP whitelist (`140`), or `endpoint` names a relay with a fixed
|
|
93
|
+
egress address: a Worker has none.
|
|
94
|
+
4. `sandbox: true` is set wherever a message must not reach a handset or be charged.
|
|
95
|
+
|
|
96
|
+
`SmsProviderUnavailable` may follow a send eSMS already accepted: a timeout (`timeoutMs`, 10 s by
|
|
97
|
+
default), or an answer lost in transit. A caller retrying the same message reuses its `reference`; a
|
|
98
|
+
"send a new code" action uses a new one. Match a delivery report to its send by `reference` when one
|
|
99
|
+
was sent. Whether eSMS records a `RequestId` on a refused attempt, `103` among them, is open: if it
|
|
100
|
+
does, a retry after topping up the account answers `124` and is reported as sent. Check this in the
|
|
101
|
+
sandbox before relying on `124`.
|
|
102
|
+
|
|
103
|
+
### Delivery reports
|
|
104
|
+
|
|
105
|
+
With `callbackUrl` set, eSMS calls it with `GET` and the message's final status in the query string,
|
|
106
|
+
retrying only on a timeout. `parseEsmsDeliveryReport` reads that query into an `SmsDeliveryReport`:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { ESMS_CALLBACK_SOURCE_ADDRESSES, parseEsmsDeliveryReport } from '@zeroxsolutions/sms/esms';
|
|
110
|
+
|
|
111
|
+
const report = parseEsmsDeliveryReport(new URL(request.url).searchParams);
|
|
112
|
+
// { messageId, reference?, outcome: 'delivered' | 'failed' | 'pending', cost?: { amount, currency: 'VND' } }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
eSMS signs nothing. The route that receives the callback checks the secret in its own path and the
|
|
116
|
+
caller's address against `ESMS_CALLBACK_SOURCE_ADDRESSES`, redacts the query in its access log (it
|
|
117
|
+
carries the number), and answers quickly. A query it cannot read throws `SmsDeliveryReportInvalid`.
|
|
58
118
|
|
|
59
119
|
## Refusals
|
|
60
120
|
|
|
61
121
|
A refusal is one plain `Error` subclass per meaning a caller acts on differently - no wire status, no
|
|
62
122
|
code, no envelope. A caller maps them by class.
|
|
63
123
|
|
|
64
|
-
| Class
|
|
65
|
-
|
|
|
66
|
-
| `SmsRecipientInvalid`
|
|
67
|
-
| `SmsRecipientUnreachable`
|
|
68
|
-
| `SmsSenderNotRegistered`
|
|
69
|
-
| `SmsMessageRejected`
|
|
70
|
-
| `SmsRateLimited`
|
|
71
|
-
| `SmsBalanceExhausted`
|
|
72
|
-
| `SmsProviderUnavailable`
|
|
73
|
-
| `SmsSendFailed`
|
|
124
|
+
| Class | What happened |
|
|
125
|
+
| -------------------------- | --------------------------------------------------------- |
|
|
126
|
+
| `SmsRecipientInvalid` | the number is not routable |
|
|
127
|
+
| `SmsRecipientUnreachable` | the carrier would not deliver to the subscriber |
|
|
128
|
+
| `SmsSenderNotRegistered` | the sender is not a brandname this account may send under |
|
|
129
|
+
| `SmsMessageRejected` | the text breaks a provider or carrier rule |
|
|
130
|
+
| `SmsRateLimited` | the provider is throttling this account |
|
|
131
|
+
| `SmsBalanceExhausted` | the account has no credit left |
|
|
132
|
+
| `SmsProviderUnavailable` | the provider failed on its own side |
|
|
133
|
+
| `SmsSendFailed` | a refusal none of the above names |
|
|
134
|
+
| `SmsDeliveryReportInvalid` | a provider's delivery report could not be read |
|
|
74
135
|
|
|
75
136
|
Every message is a constant. The provider's own text names the number it refused, so it stays in
|
|
76
137
|
`cause`: log the class, never `cause` and never `String(error)`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** The SMS seam: one composed message, the port that sends it, and the refusals a provider raises through it. */
|
|
2
|
-
export { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
|
|
3
|
-
export type { ISmsSender, SendSmsResult, SmsMessage } from './lib/sender/port.js';
|
|
2
|
+
export { SmsBalanceExhausted, SmsDeliveryReportInvalid, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
|
|
3
|
+
export type { ISmsSender, SendSmsResult, SmsDeliveryReport, SmsMessage } from './lib/sender/port.js';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,iHAAiH;AACjH,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,uBAAuB,EACvB,aAAa,EACb,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,iHAAiH;AACjH,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,kBAAkB,EAClB,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,uBAAuB,EACvB,aAAa,EACb,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The SMS seam: one composed message, the port that sends it, and the refusals a provider raises through it. */
|
|
2
|
-
export { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
|
|
2
|
+
export { SmsBalanceExhausted, SmsDeliveryReportInvalid, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SmsDeliveryReport } from '../sender/port.js';
|
|
2
|
+
/**
|
|
3
|
+
* The addresses eSMS documents its callbacks as coming from (developers.esms.vn, "Callback Url").
|
|
4
|
+
* eSMS signs nothing, so a product's callback route checks the caller's address against these and a
|
|
5
|
+
* secret in its own path.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ESMS_CALLBACK_SOURCE_ADDRESSES: readonly string[];
|
|
8
|
+
/**
|
|
9
|
+
* Reads the query eSMS calls `CallbackUrl` with. Throws `SmsDeliveryReportInvalid` for a query with no
|
|
10
|
+
* `SMSID`, or with a `SendStatus`, `SendSuccess`, `SendFailed` or `TotalPrice` that is not a number.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseEsmsDeliveryReport(query: URLSearchParams): SmsDeliveryReport;
|
|
13
|
+
//# sourceMappingURL=delivery-report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delivery-report.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/delivery-report.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE3D;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,EAAE,SAAS,MAAM,EAI1D,CAAC;AAiBH;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,eAAe,GAAG,iBAAiB,CAuBjF"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SmsDeliveryReportInvalid } from '../sender/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* The addresses eSMS documents its callbacks as coming from (developers.esms.vn, "Callback Url").
|
|
4
|
+
* eSMS signs nothing, so a product's callback route checks the caller's address against these and a
|
|
5
|
+
* secret in its own path.
|
|
6
|
+
*/
|
|
7
|
+
export const ESMS_CALLBACK_SOURCE_ADDRESSES = Object.freeze([
|
|
8
|
+
'103.29.26.39',
|
|
9
|
+
'103.29.26.80',
|
|
10
|
+
'103.29.27.39',
|
|
11
|
+
]);
|
|
12
|
+
/** eSMS's `SendStatus` for a message whose sending has finished; `SendSuccess` and `SendFailed` then say how. */
|
|
13
|
+
const SENDING_FINISHED = 5;
|
|
14
|
+
/** The currency eSMS prices in. It has no minor unit, so a price is already in minor units. */
|
|
15
|
+
const PRICE_CURRENCY = 'VND';
|
|
16
|
+
/** The number `name` carries; `undefined` where it is absent or empty. Anything else refuses the report. */
|
|
17
|
+
function numberIn(query, name) {
|
|
18
|
+
const raw = query.get(name);
|
|
19
|
+
if (raw === null || raw === '')
|
|
20
|
+
return undefined;
|
|
21
|
+
const value = Number(raw);
|
|
22
|
+
if (!Number.isFinite(value))
|
|
23
|
+
throw new SmsDeliveryReportInvalid();
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reads the query eSMS calls `CallbackUrl` with. Throws `SmsDeliveryReportInvalid` for a query with no
|
|
28
|
+
* `SMSID`, or with a `SendStatus`, `SendSuccess`, `SendFailed` or `TotalPrice` that is not a number.
|
|
29
|
+
*/
|
|
30
|
+
export function parseEsmsDeliveryReport(query) {
|
|
31
|
+
const messageId = query.get('SMSID');
|
|
32
|
+
if (!messageId)
|
|
33
|
+
throw new SmsDeliveryReportInvalid();
|
|
34
|
+
const status = numberIn(query, 'SendStatus');
|
|
35
|
+
const succeeded = numberIn(query, 'SendSuccess') ?? 0;
|
|
36
|
+
const failed = numberIn(query, 'SendFailed') ?? 0;
|
|
37
|
+
const price = numberIn(query, 'TotalPrice');
|
|
38
|
+
const reference = query.get('RequestId');
|
|
39
|
+
const outcome = status === SENDING_FINISHED && succeeded >= 1
|
|
40
|
+
? 'delivered'
|
|
41
|
+
: status === SENDING_FINISHED && failed >= 1
|
|
42
|
+
? 'failed'
|
|
43
|
+
: 'pending';
|
|
44
|
+
return {
|
|
45
|
+
messageId,
|
|
46
|
+
...(reference ? { reference } : {}),
|
|
47
|
+
outcome,
|
|
48
|
+
...(price === undefined ? {} : { cost: { amount: Math.round(price), currency: PRICE_CURRENCY } }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The eSMS half: the sender backed by eSMS's REST API, and the reader of its delivery callback. */
|
|
2
|
+
export { ESMS_CALLBACK_SOURCE_ADDRESSES, parseEsmsDeliveryReport } from './delivery-report.js';
|
|
3
|
+
export { EsmsSmsSender, type EsmsSmsSenderOptions } from './sender.js';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/index.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,OAAO,EAAE,8BAA8B,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ISmsSender, SendSmsResult, SmsMessage } from '../sender/port.js';
|
|
2
|
+
/** How an `EsmsSmsSender` reaches the account. */
|
|
3
|
+
export interface EsmsSmsSenderOptions {
|
|
4
|
+
readonly apiKey: string;
|
|
5
|
+
readonly secretKey: string;
|
|
6
|
+
/** `Sandbox=1`: eSMS accepts and answers, delivers nothing, and charges nothing. */
|
|
7
|
+
readonly sandbox?: boolean;
|
|
8
|
+
/** Sent as `CallbackUrl` on every message; eSMS calls it with the message's final status. */
|
|
9
|
+
readonly callbackUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Where the send call goes, eSMS's own URL by default. A relay with a fixed egress address goes here
|
|
12
|
+
* when the account only accepts whitelisted addresses (code `140`), since a Worker has none.
|
|
13
|
+
*/
|
|
14
|
+
readonly endpoint?: string;
|
|
15
|
+
/** How long a send waits for eSMS before refusing with `SmsProviderUnavailable`, 10 000 by default. */
|
|
16
|
+
readonly timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Backs the SMS port with eSMS's REST API over the runtime's own `fetch`. Delivers inside Vietnam
|
|
20
|
+
* only; `send` resolving means eSMS accepted the message, and its delivery report says whether the
|
|
21
|
+
* handset received it.
|
|
22
|
+
*/
|
|
23
|
+
export declare class EsmsSmsSender implements ISmsSender {
|
|
24
|
+
private readonly options;
|
|
25
|
+
constructor(options: EsmsSmsSenderOptions);
|
|
26
|
+
send(message: SmsMessage): Promise<SendSmsResult>;
|
|
27
|
+
/** Sends `body` and reads eSMS's answer; anything short of a JSON object is the provider failing. */
|
|
28
|
+
private post;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=sender.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/sender.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAgD/E,kDAAkD;AAClD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,oFAAoF;IACpF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,6FAA6F;IAC7F,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,uGAAuG;IACvG,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAoBD;;;;GAIG;AACH,qBAAa,aAAc,YAAW,UAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,oBAAoB;IAEpD,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC;IAoCvD,qGAAqG;YACvF,IAAI;CA2BnB"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from '../sender/errors.js';
|
|
2
|
+
/** eSMS's call that sends one message (developers.esms.vn, "Tin CSKH"); the default `endpoint`. */
|
|
3
|
+
const SEND_URL = 'https://rest.esms.vn/MainService.svc/json/SendMultipleMessage_V4_post_json/';
|
|
4
|
+
/** `SmsType` 2, customer care: the type a brandname sends a one-time code as. */
|
|
5
|
+
const CUSTOMER_CARE = '2';
|
|
6
|
+
/** The `CodeResult` eSMS answers a request it accepted with. Accepted, not delivered. */
|
|
7
|
+
const ACCEPTED = '100';
|
|
8
|
+
/** The `CodeResult` eSMS answers a `RequestId` it saw in the last 24 hours with. */
|
|
9
|
+
const REPEATED_REQUEST = '124';
|
|
10
|
+
/** The longest `RequestId` eSMS accepts. */
|
|
11
|
+
const MAX_REFERENCE_LENGTH = 50;
|
|
12
|
+
/** Vietnam's national prefix plus a 9- or 10-digit subscriber number, the shape eSMS delivers to. */
|
|
13
|
+
const VIETNAM_PHONE = /^\+84\d{9,10}$/;
|
|
14
|
+
/**
|
|
15
|
+
* How long a send waits for eSMS before giving up, unless `timeoutMs` says otherwise. A Worker waits
|
|
16
|
+
* on a stalled subrequest as long as its client stays connected, so a send with no bound would hang
|
|
17
|
+
* the caller rather than fail fast.
|
|
18
|
+
*/
|
|
19
|
+
const ESMS_SEND_TIMEOUT_MS = 10_000;
|
|
20
|
+
/**
|
|
21
|
+
* Every `CodeResult` eSMS documents for this call that a caller acts on differently, and the class it
|
|
22
|
+
* raises (developers.esms.vn, "Bang ma loi"). Any other code, `101` wrong keys and `140` IP not
|
|
23
|
+
* whitelisted among them, raises `SmsSendFailed`.
|
|
24
|
+
*/
|
|
25
|
+
const REFUSALS = {
|
|
26
|
+
'99': SmsRecipientUnreachable,
|
|
27
|
+
'103': SmsBalanceExhausted,
|
|
28
|
+
'104': SmsSenderNotRegistered,
|
|
29
|
+
'108': SmsRecipientInvalid,
|
|
30
|
+
'146': SmsMessageRejected,
|
|
31
|
+
'177': SmsSenderNotRegistered,
|
|
32
|
+
'201': SmsMessageRejected,
|
|
33
|
+
};
|
|
34
|
+
/** The class a `CodeResult` raises - `SmsSendFailed` wherever it is not one of the table's own keys. */
|
|
35
|
+
function refusalClassOf(code) {
|
|
36
|
+
return code !== undefined && Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : SmsSendFailed;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether every character of `text` is ASCII. A code-point test rather than a `[^\x00-\x7f]` class,
|
|
40
|
+
* which `no-control-regex` refuses.
|
|
41
|
+
*/
|
|
42
|
+
function isAscii(text) {
|
|
43
|
+
return [...text].every((character) => (character.codePointAt(0) ?? 0) <= 0x7f);
|
|
44
|
+
}
|
|
45
|
+
/** `+84901234567` as eSMS spells it, `0901234567`; `undefined` for a number that is not this shape. */
|
|
46
|
+
function nationalNumber(e164) {
|
|
47
|
+
return VIETNAM_PHONE.test(e164) ? `0${e164.slice(3)}` : undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Backs the SMS port with eSMS's REST API over the runtime's own `fetch`. Delivers inside Vietnam
|
|
51
|
+
* only; `send` resolving means eSMS accepted the message, and its delivery report says whether the
|
|
52
|
+
* handset received it.
|
|
53
|
+
*/
|
|
54
|
+
export class EsmsSmsSender {
|
|
55
|
+
options;
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.options = options;
|
|
58
|
+
}
|
|
59
|
+
async send(message) {
|
|
60
|
+
const phone = nationalNumber(message.to);
|
|
61
|
+
if (phone === undefined)
|
|
62
|
+
throw new SmsRecipientInvalid();
|
|
63
|
+
if (message.reference !== undefined &&
|
|
64
|
+
(message.reference.length === 0 || message.reference.length > MAX_REFERENCE_LENGTH)) {
|
|
65
|
+
throw new SmsSendFailed();
|
|
66
|
+
}
|
|
67
|
+
const answer = await this.post({
|
|
68
|
+
ApiKey: this.options.apiKey,
|
|
69
|
+
SecretKey: this.options.secretKey,
|
|
70
|
+
Brandname: message.from,
|
|
71
|
+
SmsType: CUSTOMER_CARE,
|
|
72
|
+
Phone: phone,
|
|
73
|
+
Content: message.text,
|
|
74
|
+
IsUnicode: isAscii(message.text) ? '0' : '1',
|
|
75
|
+
...(this.options.sandbox ? { Sandbox: '1' } : {}),
|
|
76
|
+
...(message.reference === undefined ? {} : { RequestId: message.reference }),
|
|
77
|
+
...(this.options.callbackUrl === undefined ? {} : { CallbackUrl: this.options.callbackUrl }),
|
|
78
|
+
});
|
|
79
|
+
const codeResult = answer.CodeResult === undefined ? undefined : String(answer.CodeResult);
|
|
80
|
+
if (codeResult === ACCEPTED) {
|
|
81
|
+
const messageId = answer.SMSID ?? message.reference;
|
|
82
|
+
if (messageId !== undefined)
|
|
83
|
+
return { messageId };
|
|
84
|
+
throw new SmsSendFailed({ cause: answer });
|
|
85
|
+
}
|
|
86
|
+
if (codeResult === REPEATED_REQUEST && message.reference !== undefined) {
|
|
87
|
+
return { messageId: message.reference };
|
|
88
|
+
}
|
|
89
|
+
const Refusal = refusalClassOf(codeResult);
|
|
90
|
+
throw new Refusal({ cause: answer });
|
|
91
|
+
}
|
|
92
|
+
/** Sends `body` and reads eSMS's answer; anything short of a JSON object is the provider failing. */
|
|
93
|
+
async post(body) {
|
|
94
|
+
let response;
|
|
95
|
+
try {
|
|
96
|
+
response = await fetch(this.options.endpoint ?? SEND_URL, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? ESMS_SEND_TIMEOUT_MS),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
throw new SmsProviderUnavailable({ cause });
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
await response.body?.cancel().catch(() => undefined);
|
|
108
|
+
throw new SmsProviderUnavailable({ cause: new Error(`eSMS answered ${response.status}`) });
|
|
109
|
+
}
|
|
110
|
+
let answer;
|
|
111
|
+
try {
|
|
112
|
+
answer = await response.json();
|
|
113
|
+
}
|
|
114
|
+
catch (cause) {
|
|
115
|
+
throw new SmsProviderUnavailable({ cause });
|
|
116
|
+
}
|
|
117
|
+
if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) {
|
|
118
|
+
throw new SmsProviderUnavailable({ cause: answer });
|
|
119
|
+
}
|
|
120
|
+
return answer;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What a provider's refusal means, one class per meaning a caller acts on differently. An adapter
|
|
3
3
|
* translates its own provider's codes onto these, so nothing above the port names a provider.
|
|
4
|
+
* `SmsDeliveryReportInvalid` is the one refusal not raised by a send: it names a delivery report that
|
|
5
|
+
* cannot be read.
|
|
4
6
|
*
|
|
5
7
|
* Every message is a constant. The provider's own text - which often names the number - stays in
|
|
6
8
|
* `cause`, so a caller logs the class and never `cause` or `String(error)`.
|
|
@@ -32,7 +34,11 @@ export declare class SmsRateLimited extends Error {
|
|
|
32
34
|
export declare class SmsBalanceExhausted extends Error {
|
|
33
35
|
constructor(options?: ErrorOptions);
|
|
34
36
|
}
|
|
35
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The provider failed on its own side; the message itself may be fine. This refusal can follow a send
|
|
39
|
+
* the provider already accepted - a timeout or a lost answer - so a retry must reuse the same
|
|
40
|
+
* `reference`.
|
|
41
|
+
*/
|
|
36
42
|
export declare class SmsProviderUnavailable extends Error {
|
|
37
43
|
constructor(options?: ErrorOptions);
|
|
38
44
|
}
|
|
@@ -40,4 +46,8 @@ export declare class SmsProviderUnavailable extends Error {
|
|
|
40
46
|
export declare class SmsSendFailed extends Error {
|
|
41
47
|
constructor(options?: ErrorOptions);
|
|
42
48
|
}
|
|
49
|
+
/** A delivery report that is missing its message id or carries a number it cannot read. */
|
|
50
|
+
export declare class SmsDeliveryReportInvalid extends Error {
|
|
51
|
+
constructor(options?: ErrorOptions);
|
|
52
|
+
}
|
|
43
53
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/errors.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,gHAAgH;AAChH,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,oGAAoG;AACpG,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,uGAAuG;AACvG,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,sIAAsI;AACtI,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,mGAAmG;AACnG,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,yFAAyF;AACzF,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,sGAAsG;AACtG,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,2FAA2F;AAC3F,qBAAa,wBAAyB,SAAQ,KAAK;gBACrC,OAAO,CAAC,EAAE,YAAY;CAInC"}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What a provider's refusal means, one class per meaning a caller acts on differently. An adapter
|
|
3
3
|
* translates its own provider's codes onto these, so nothing above the port names a provider.
|
|
4
|
+
* `SmsDeliveryReportInvalid` is the one refusal not raised by a send: it names a delivery report that
|
|
5
|
+
* cannot be read.
|
|
4
6
|
*
|
|
5
7
|
* Every message is a constant. The provider's own text - which often names the number - stays in
|
|
6
8
|
* `cause`, so a caller logs the class and never `cause` or `String(error)`.
|
|
@@ -50,7 +52,11 @@ export class SmsBalanceExhausted extends Error {
|
|
|
50
52
|
this.name = 'SmsBalanceExhausted';
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The provider failed on its own side; the message itself may be fine. This refusal can follow a send
|
|
57
|
+
* the provider already accepted - a timeout or a lost answer - so a retry must reuse the same
|
|
58
|
+
* `reference`.
|
|
59
|
+
*/
|
|
54
60
|
export class SmsProviderUnavailable extends Error {
|
|
55
61
|
constructor(options) {
|
|
56
62
|
super('The sms provider failed internally', options);
|
|
@@ -64,3 +70,10 @@ export class SmsSendFailed extends Error {
|
|
|
64
70
|
this.name = 'SmsSendFailed';
|
|
65
71
|
}
|
|
66
72
|
}
|
|
73
|
+
/** A delivery report that is missing its message id or carries a number it cannot read. */
|
|
74
|
+
export class SmsDeliveryReportInvalid extends Error {
|
|
75
|
+
constructor(options) {
|
|
76
|
+
super('The delivery report could not be read', options);
|
|
77
|
+
this.name = 'SmsDeliveryReportInvalid';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -5,16 +5,44 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `to` is in E.164 and is not branded: which numbers a product accepts, and how it normalises one,
|
|
7
7
|
* is the product's decision and not something this package can hold for it.
|
|
8
|
+
*
|
|
9
|
+
* `reference` is the caller's own id for this message. An adapter whose provider refuses a repeated
|
|
10
|
+
* id sends it, so a retried send cannot deliver and charge twice; a provider without that check
|
|
11
|
+
* ignores it.
|
|
8
12
|
*/
|
|
9
13
|
export interface SmsMessage {
|
|
10
14
|
readonly from: string;
|
|
11
15
|
readonly to: string;
|
|
12
16
|
readonly text: string;
|
|
17
|
+
readonly reference?: string;
|
|
13
18
|
}
|
|
14
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* What the provider hands back. Normally the id its own logs and delivery reports name this message
|
|
21
|
+
* by; the caller's own `reference` instead, whenever the provider answered no id of its own.
|
|
22
|
+
*/
|
|
15
23
|
export interface SendSmsResult {
|
|
16
24
|
readonly messageId: string;
|
|
17
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* A message's final status as a provider reports it, in one shape whichever provider sent it. It
|
|
28
|
+
* carries no phone number: the caller already holds the number through `messageId` or `reference`.
|
|
29
|
+
*/
|
|
30
|
+
export interface SmsDeliveryReport {
|
|
31
|
+
/** The provider's id for the message. */
|
|
32
|
+
readonly messageId: string;
|
|
33
|
+
/**
|
|
34
|
+
* The `reference` the message was sent with, when it had one. A caller that sent one matches
|
|
35
|
+
* reports by it, since `send` can answer with the reference itself instead of the provider's id,
|
|
36
|
+
* whenever the provider answered no id of its own.
|
|
37
|
+
*/
|
|
38
|
+
readonly reference?: string;
|
|
39
|
+
readonly outcome: 'delivered' | 'failed' | 'pending';
|
|
40
|
+
/** What the provider charged, in integer minor units of `currency`. */
|
|
41
|
+
readonly cost?: {
|
|
42
|
+
readonly amount: number;
|
|
43
|
+
readonly currency: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
18
46
|
/** Sends one composed message. The only seam a product substitutes to send an SMS. */
|
|
19
47
|
export interface ISmsSender {
|
|
20
48
|
send(message: SmsMessage): Promise<SendSmsResult>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/port.ts"],"names":[],"mappings":"AAAA,oGAAoG;AAEpG
|
|
1
|
+
{"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/port.ts"],"names":[],"mappings":"AAAA,oGAAoG;AAEpG;;;;;;;;;;GAUG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,yCAAyC;IACzC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;IACrD,uEAAuE;IACvE,QAAQ,CAAC,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACxE;AAED,sFAAsF;AACtF,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;CACnD"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroxsolutions/sms",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
|
-
"description": "The SMS seam: a composed message, the port that sends it,
|
|
7
|
+
"description": "The SMS seam: a composed message, the port that sends it, its typed refusals, and the eSMS adapter behind that port. Knows nothing of templates, locales or delivery records - those stay in the product.",
|
|
8
8
|
"main": "./dist/index.js",
|
|
9
9
|
"module": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
@@ -20,9 +20,17 @@
|
|
|
20
20
|
"types": "./dist/index.d.ts",
|
|
21
21
|
"import": "./dist/index.js",
|
|
22
22
|
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./esms": {
|
|
25
|
+
"types": "./dist/lib/esms/index.d.ts",
|
|
26
|
+
"import": "./dist/lib/esms/index.js",
|
|
27
|
+
"default": "./dist/lib/esms/index.js"
|
|
23
28
|
}
|
|
24
29
|
},
|
|
25
30
|
"dependencies": {
|
|
26
31
|
"tslib": "^2.3.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"msw": "2.15.0"
|
|
27
35
|
}
|
|
28
36
|
}
|