Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8315f1882a | |||
| 311ee7c677 | |||
| e3062066c1 | |||
| 7310634d7d | |||
| 549b3260c9 | |||
| 13e3ff8f1f | |||
| ca1a7473ed | |||
| 989eb01763 | |||
| ef13290230 | |||
| c06cc0648b |
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v7.0.0
|
- uses: actions/setup-node@v7.0.0
|
||||||
with:
|
with:
|
||||||
cache: npm
|
cache: npm
|
||||||
node-version: 24
|
node-version: 24.18.0
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: The tag must match the version being published
|
- name: The tag must match the version being published
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v7.0.0
|
- uses: actions/setup-node@v7.0.0
|
||||||
with:
|
with:
|
||||||
cache: npm
|
cache: npm
|
||||||
node-version: 24
|
node-version: 24.18.0
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Guidance for LLM agents working in this repository. What each file in it is for
|
|||||||
|
|
||||||
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 1.0.0. The branch
|
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 1.0.0. The branch
|
||||||
started from an orphan commit — no history from 0.4.0 is carried over. The 0.4.0 source is still
|
started from an orphan commit — no history from 0.4.0 is carried over. The 0.4.0 source is still
|
||||||
readable on the `master` branch of the same repository and is the reference for protocol behaviour,
|
readable on the `v0.4.0` branch of the same repository and is the reference for protocol behaviour,
|
||||||
not for structure or style.
|
not for structure or style.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
@@ -109,7 +109,7 @@ src/
|
|||||||
constants.ts consts + constsById, and the SMPP version constants
|
constants.ts consts + constsById, and the SMPP version constants
|
||||||
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
|
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
|
||||||
errors.ts errors + errorsById (ESME_*)
|
errors.ts errors + errorsById (ESME_*)
|
||||||
tlvs.ts TLV definitions, tlvsById
|
tlvs.ts TLV definitions, tlvsById, the input shape, and writing a TLV stream
|
||||||
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
|
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -135,16 +135,16 @@ docker compose run --rm node npm run build
|
|||||||
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
|
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
|
||||||
objects plus union types.
|
objects plus union types.
|
||||||
- The published floor is Node 18, but the dev container runs Node 24 (type stripping needs it). CI
|
- The published floor is Node 18, but the dev container runs Node 24 (type stripping needs it). CI
|
||||||
compiles the tests and runs them on 18/20/22/24, so the floor is verified rather than asserted.
|
compiles the tests and runs them on 18, every LTS above it, and current, so the floor is
|
||||||
|
verified rather than asserted.
|
||||||
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
|
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
|
||||||
TypeScript 7 once that constraint lifts.
|
TypeScript 7 once that constraint lifts.
|
||||||
|
|
||||||
## Defects found in 0.4.0
|
## Defects found in 0.4.0
|
||||||
|
|
||||||
Every row names what 0.4.0's own code did, so it is not rebuilt here.
|
Every row names what 0.4.0's own code did, so it is not rebuilt here.
|
||||||
[README.md](README.md#behaviour-that-changed-on-the-wire) names what changed for a consumer, and is
|
[MIGRATION.md](MIGRATION.md) names what changed for a consumer, and is the only place that does.
|
||||||
the only place that does. Confirmed by reading the 0.4.0 source; each row has a regression test
|
Confirmed by reading the 0.4.0 source; each row has a regression test naming the behaviour.
|
||||||
naming the behaviour.
|
|
||||||
|
|
||||||
| Defect | 0.4.0 behaviour |
|
| Defect | 0.4.0 behaviour |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -152,6 +152,7 @@ naming the behaviour.
|
|||||||
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
|
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
|
||||||
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
|
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
|
||||||
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
|
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
|
||||||
|
| GSM 03.38 declared as IA5 | `sendSms` resolves its encoding through `consts.ENCODING`, so a GSM body goes out under `data_coding` 0x01 — SMPP 3.4 5.2.19's IA5 (CCITT T.50), where `$` and `@` are STX and NUL |
|
||||||
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
|
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
|
||||||
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
|
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
|
||||||
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
|
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
|
||||||
@@ -210,9 +211,9 @@ decision under [The wire](#the-wire).
|
|||||||
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
|
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
|
||||||
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
|
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
|
||||||
footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a
|
footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a
|
||||||
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`
|
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`,
|
||||||
and `message-class.test.ts` is one `SendSmsDeps.send` that answers nothing, and a field added to
|
`message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing,
|
||||||
that type fails to compile in both copies at once.
|
and a field added to that type fails to compile in every copy at once.
|
||||||
- `message_id` values the library generates are UUID v7.
|
- `message_id` values the library generates are UUID v7.
|
||||||
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
|
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
|
||||||
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
|
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
|
||||||
@@ -234,9 +235,11 @@ Each file answers one question, and a fact belongs to the file whose question it
|
|||||||
|
|
||||||
- **README.md — what you can rely on.** Observable behaviour, for someone using the package. It
|
- **README.md — what you can rely on.** Observable behaviour, for someone using the package. It
|
||||||
carries a reason only where the reason changes how you would call the thing.
|
carries a reason only where the reason changes how you would call the thing.
|
||||||
|
- **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the
|
||||||
|
behaviour that changed on the wire.
|
||||||
- **AGENTS.md — what may not change, and why.** Goals, hard rules, architecture, conventions, and the
|
- **AGENTS.md — what may not change, and why.** Goals, hard rules, architecture, conventions, and the
|
||||||
decisions the goals do not already settle. It does not restate behaviour README states.
|
decisions the goals do not already settle. It does not restate behaviour README states.
|
||||||
- **todo.md** is a temporary working file that sets its own rules; nothing here governs it.
|
- **todo.md** is a working file that sets its own rules; nothing here governs it.
|
||||||
|
|
||||||
A sentence living in two of them is a defect: delete the copy in the file whose question it does not
|
A sentence living in two of them is a defect: delete the copy in the file whose question it does not
|
||||||
answer. The toolchain commands are the one deliberate exception — README's copy serves a contributor
|
answer. The toolchain commands are the one deliberate exception — README's copy serves a contributor
|
||||||
@@ -316,7 +319,33 @@ Grouped by what each one constrains.
|
|||||||
every typed consumer a narrow forever — goal 6, and the tag is the last cheap chance to spend it —
|
every typed consumer a narrow forever — goal 6, and the tag is the last cheap chance to spend it —
|
||||||
to guard a state the compiler refuses. Where the domain really is open the check is already there:
|
to guard a state the compiler refuses. Where the domain really is open the check is already there:
|
||||||
`sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller
|
`sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller
|
||||||
without types gets.
|
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
|
||||||
|
under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`.
|
||||||
|
|
||||||
|
- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.**
|
||||||
|
Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote
|
||||||
|
empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a
|
||||||
|
correlation table by the array, compiled and then misbehaved, and one message's empty entry
|
||||||
|
collides with another's. Telesign names an id for the first segment of a concatenated submit only,
|
||||||
|
so it is a documented operator's shape rather than a hypothesis. Nothing else moves:
|
||||||
|
`parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined`
|
||||||
|
reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]`
|
||||||
|
because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves
|
||||||
|
a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()`
|
||||||
|
reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and
|
||||||
|
that reading stays separate from this one rather than sharing a helper, since it must leave a
|
||||||
|
Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked.
|
||||||
|
What settles it here is the resolved text rather than the parameter, because `writeParams()`
|
||||||
|
substitutes the field's own default: a peer that omits `message_id` and one that writes it empty
|
||||||
|
build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''`
|
||||||
|
and documenting it, which leaves the published type promising what the value does not keep —
|
||||||
|
goal 2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which
|
||||||
|
breaks the positional correspondence with `pduObjs` that README promises and loses which segment a
|
||||||
|
PDU belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional
|
||||||
|
promise structural where today the compiler cannot check it; deferred to the next major, the first
|
||||||
|
place two documented fields may become one. Accepted: every consumer reading `smsIds` narrows,
|
||||||
|
including the majority whose SMSC names every id; indexing narrows too, except for the consumer
|
||||||
|
who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined` already.
|
||||||
|
|
||||||
### The wire
|
### The wire
|
||||||
|
|
||||||
@@ -586,6 +615,121 @@ Grouped by what each one constrains.
|
|||||||
unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters
|
unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters
|
||||||
one SMS holds now costs more segments than it did, and `smsIds` is that much longer.
|
one SMS holds now costs more segments than it did, and `smsIds` is that much longer.
|
||||||
|
|
||||||
|
- **An alphabet the caller named has to carry the message, and a time the format cannot express is
|
||||||
|
refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and
|
||||||
|
stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on
|
||||||
|
`あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'`
|
||||||
|
flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote
|
||||||
|
`NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the
|
||||||
|
caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can
|
||||||
|
carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks
|
||||||
|
the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it,
|
||||||
|
so the guard cannot drift from what the encoder writes for any one character, and a fourth
|
||||||
|
alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller
|
||||||
|
composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read
|
||||||
|
this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()`
|
||||||
|
reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit
|
||||||
|
binary body Latin-1 is kept for. The guard is
|
||||||
|
on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns
|
||||||
|
carries every character it was picked for, over the whole code point range. `smppTime.encode()`
|
||||||
|
returns a `Result`, where the three encoding helpers stayed total: that argument was that
|
||||||
|
`EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an
|
||||||
|
invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit
|
||||||
|
one", and `decode()` has been fallible for the same reason since it was written. Rejected:
|
||||||
|
transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice
|
||||||
|
— the same reason a flash Latin-1 message is refused rather than promoted, and an operator that
|
||||||
|
accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected:
|
||||||
|
guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this
|
||||||
|
library's own published helper composing the garbage the guard exists to stop. Rejected: a
|
||||||
|
`holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with
|
||||||
|
the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller
|
||||||
|
formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the
|
||||||
|
field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to
|
||||||
|
it, a negative one and `Infinity` with it — clamping `86400 * 365` reported success for a year and
|
||||||
|
put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists
|
||||||
|
to remove. The ceiling is this encoder's rather than the format's: 3.4's `YYMMDDhhmmss000R`
|
||||||
|
carries years and months, which `decode()` reads back, and no fixed number of seconds is either
|
||||||
|
one, so spelling a second count in days and below is where the guess would go — which is why the
|
||||||
|
too-long refusal names the `Date` that reaches every instant the absolute form holds, and the
|
||||||
|
negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which
|
||||||
|
leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an
|
||||||
|
extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is
|
||||||
|
the one input a per-character reading passes and the encoder then writes as the extended character
|
||||||
|
— the only composition in any of the three codecs, and not a character a message is written in.
|
||||||
|
|
||||||
|
- **A string body is written in the alphabet its own `data_coding` names, and one that alphabet
|
||||||
|
cannot carry is refused by the codec — `message_payload` on the same terms as `short_message`.**
|
||||||
|
Maintainer's call, 2026-09-09, from the architecture review of
|
||||||
|
[#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's
|
||||||
|
own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう`
|
||||||
|
returned `42 44 46` — `"BDF"` — reported as built, while a string `message_payload` was cut to its
|
||||||
|
low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above.
|
||||||
|
The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given
|
||||||
|
under any `data_coding`, which is what keeps goal 6's escape hatch open — the raw UDH, 8-bit binary
|
||||||
|
and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string
|
||||||
|
with no `data_coding` is untouched, detection carrying every character it was picked for. The
|
||||||
|
guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the
|
||||||
|
character, its code point and its index said once for both refusals — unexported where
|
||||||
|
`unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a
|
||||||
|
caller would get wrong, where asking the codec is, and publishing it would freeze this library's
|
||||||
|
error prose as API for an application whose own refusal should read like itself. Goal 6, from the
|
||||||
|
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
|
||||||
|
reached through
|
||||||
|
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
|
||||||
|
`encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the
|
||||||
|
alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it,
|
||||||
|
since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because
|
||||||
|
`splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment
|
||||||
|
could name is not the one in the message. The TLV is encoded rather than merely checked because
|
||||||
|
`data_coding` names the alphabet of the body wherever it is carried — that is how
|
||||||
|
`messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one
|
||||||
|
— so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would
|
||||||
|
close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the
|
||||||
|
order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will
|
||||||
|
be read under — and a `short_message` on a command whose table declares none is ignored here as
|
||||||
|
`writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the
|
||||||
|
same input rather than three. A `data_coding` on a command that declares no such field is honoured
|
||||||
|
the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in.
|
||||||
|
Every entry carrying the payload tag is resolved, by tag id rather
|
||||||
|
than by record key, since `tagIdOf()` lets a caller name it anything and a spelling that escaped
|
||||||
|
the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string
|
||||||
|
`message_payload` outright and demanding
|
||||||
|
octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued
|
||||||
|
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
|
||||||
|
own definition.
|
||||||
|
|
||||||
|
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
|
||||||
|
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
|
||||||
|
through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT
|
||||||
|
T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's
|
||||||
|
STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the
|
||||||
|
same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written
|
||||||
|
under, as `unencodable()` is to whether one can carry a message: the mirror of
|
||||||
|
`encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected
|
||||||
|
one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is
|
||||||
|
exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a
|
||||||
|
`data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the
|
||||||
|
*SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee;
|
||||||
|
it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile
|
||||||
|
and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and
|
||||||
|
so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01
|
||||||
|
to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no
|
||||||
|
researched peer means IA5 by it. The two tables agree over most of the printable range and part at
|
||||||
|
0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed,
|
||||||
|
carriage return and escape are common to both — which is where a peer that did mean IA5 is
|
||||||
|
misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two
|
||||||
|
names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different
|
||||||
|
values and so the only one a reader could carry from the option's vocabulary into SMPP's flat
|
||||||
|
table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name.
|
||||||
|
Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the
|
||||||
|
section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding`
|
||||||
|
option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as
|
||||||
|
Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing
|
||||||
|
researched has found. Accepted: a message already in flight is unmoved — both codings resolve to
|
||||||
|
the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the
|
||||||
|
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
|
||||||
|
change reads exactly as it did.
|
||||||
|
|
||||||
### The session's life
|
### The session's life
|
||||||
|
|
||||||
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
||||||
@@ -827,6 +971,7 @@ Grouped by what each one constrains.
|
|||||||
only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and
|
only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and
|
||||||
`end()` are the only callers of `stop()`. A third caller has to shut the gate itself.
|
`end()` are the only callers of `stop()`. A third caller has to shut the gate itself.
|
||||||
|
|
||||||
|
|
||||||
### Internals and tests
|
### Internals and tests
|
||||||
|
|
||||||
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
|
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Migrating from larvitsmpp 0.4.0
|
||||||
|
|
||||||
|
`@larvit/smpp` 1.0.0 succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The
|
||||||
|
shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises.
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
- **The package is `@larvit/smpp`** and ESM only. `require()` no longer works.
|
||||||
|
- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp`, `sendDlr`, `unbind` and
|
||||||
|
`session.close` are promises resolving to a result with an optional `err`. Nothing rejects. Await
|
||||||
|
`close()`, or the socket outlives the call.
|
||||||
|
- **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and
|
||||||
|
a `session` event. It no longer calls back once per connection.
|
||||||
|
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
|
||||||
|
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
|
||||||
|
Assigning to it throws a `TypeError`, since modules are strict mode.
|
||||||
|
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
|
||||||
|
`pduObjs`, `undefined` where the SMSC took the segment without naming an id.
|
||||||
|
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
|
||||||
|
returns `false` or `{ userData }`.
|
||||||
|
- **Renamed options:** `enqLinkTiming` → `enquireLinkInterval`, server `timeout` → `idleTimeout`.
|
||||||
|
- **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`,
|
||||||
|
`encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`. The
|
||||||
|
codec is synchronous and returns `{ err, pduObj }` / `{ err, buffer }`.
|
||||||
|
- **`pduObj.isResp()` is the standalone `isResp(pduObj)`.** `pduObj.cmdStatus` is `undefined` for
|
||||||
|
a status code the library does not know, with the raw number in `pduObj.cmdStatusId`.
|
||||||
|
- **`defs.filters` is gone.** It was declared on every command and TLV and never invoked. SMPP time
|
||||||
|
formatting, the one part worth keeping, is `smppTime`.
|
||||||
|
- **`DATAGRAM`, `FORWARD` and `STORE_FORWARD` moved from `consts.ESM_CLASS` to
|
||||||
|
`consts.MESSAGING_MODE`**, which also names `SMSC_DEFAULT`. They are bits 1-0 of `esm_class`, not
|
||||||
|
whole values of it. Read them from the new group, or pass `messagingMode` to `sendSms()`. A stale
|
||||||
|
`consts.ESM_CLASS.STORE_FORWARD` reads `undefined`, which OR-s into an `esm_class` carrying no mode.
|
||||||
|
- **The `error` event is `sessionError`**, and `serverError` on the server handle.
|
||||||
|
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of
|
||||||
|
a `larvitutils` one, and is silent by default: [README](README.md#logging).
|
||||||
|
- **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP
|
||||||
|
3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00.
|
||||||
|
|
||||||
|
## Behaviour that changed on the wire
|
||||||
|
|
||||||
|
0.4.0 had protocol defects. Fixing them changes the bytes on the wire, so remove any workaround you
|
||||||
|
have for these:
|
||||||
|
|
||||||
|
- Every multipart segment was one character short (152 GSM characters instead of 153, 66 UCS2
|
||||||
|
instead of 67), so long messages were split into more segments than necessary, each one billed.
|
||||||
|
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
|
||||||
|
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
|
||||||
|
defines the 7-character `UNDELIV`.
|
||||||
|
- Every receipt went out as `esm_class` 0x04, the report of a message's final state. A receipt for a
|
||||||
|
transient state, `sendDlr('ENROUTE')`, is now marked 0x20, the intermediate delivery notification.
|
||||||
|
- `flash: true` discarded UCS2, mangling flash messages with non-GSM characters, and put the GSM
|
||||||
|
alphabet on a Latin-1 message that has no `data_coding` at all; that pair is refused now. Inbound,
|
||||||
|
only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the whole 0xF0
|
||||||
|
coding group arrived as ordinary messages.
|
||||||
|
- A GSM 03.38 message declared `data_coding` 0x01, which SMPP 3.4 5.2.19 defines as IA5, so `$` and
|
||||||
|
`@` reached a peer honouring the field as STX and NUL. It goes out as 0x00, the SMSC default
|
||||||
|
alphabet, and so does a receipt `sendDlr()` writes. Latin-1 and UCS2 stay at 0x03 and 0x08, and an
|
||||||
|
inbound 0x01 is still read as GSM 03.38.
|
||||||
|
- The multipart reference counter was shared by every session in the process.
|
||||||
|
- `tls: true` never performed a handshake, so the connection was not encrypted.
|
||||||
|
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
||||||
|
- Delivery receipts carrying only the standard receipt text, with no TLVs, what Kannel and several
|
||||||
|
other SMSCs send, were rejected outright. They are parsed now.
|
||||||
|
- A message whose last octet was `0x00` was allocated one octet short while `sm_length` reported the
|
||||||
|
full length, so it went out corrupt. In UCS2 that is any message ending in a character like 一
|
||||||
|
(U+4E00), routine for CJK text.
|
||||||
|
- Every response carried a `message_id`, `deliver_sm_resp` included, where SMPP 3.4 4.6.2 makes that
|
||||||
|
field unused and NULL. Jasmin closes the connection on one. Answering an inbound message now puts
|
||||||
|
nothing in it, and `sms.smsId` is the local handle it always was.
|
||||||
|
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into
|
||||||
|
a hex string and written back as the ASCII of that string, so every round trip corrupted them.
|
||||||
|
They are `Buffer`s in both directions now; drop any hex encoding of your own.
|
||||||
|
- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
|
||||||
|
`data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach
|
||||||
|
the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer.
|
||||||
|
- A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum`
|
||||||
|
TLVs rather than a user data header was never reassembled, so each segment arrived as its own
|
||||||
|
message. Both spellings reassemble now.
|
||||||
|
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
|
||||||
|
- A PDU whose optional parameters do not end exactly on `command_length` is refused with
|
||||||
|
`ESME_RINVTLVSTREAM` and dropped, where 0.4.0 kept the TLVs it had read and ignored the octets
|
||||||
|
left over, losing the `receipted_message_id` that makes a receipt a receipt. The refusal reaches
|
||||||
|
`sessionError` as a `PduRefusedError` with `reason` `tlvs`.
|
||||||
|
- Binds declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME speaks
|
||||||
|
SMPP 3.3 or earlier, and a spec-following SMSC then withholds every optional parameter, the TLVs
|
||||||
|
delivery receipts are carried in included.
|
||||||
|
- A response reporting a failure carries no body, as the spec defines. 0.4.0 filled the body with
|
||||||
|
empty defaults, so a refused `submit_sm_resp` went out with an empty `message_id` a caller could
|
||||||
|
mistake for a real one.
|
||||||
|
- `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped.
|
||||||
|
|
||||||
|
The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an
|
||||||
|
independent implementation, in both directions and over a live session.
|
||||||
@@ -90,12 +90,12 @@ Then add the file to README.md's findings table.
|
|||||||
Every defect a phase records is fixed before the next phase runs — a fix can change behaviour in
|
Every defect a phase records is fixed before the next phase runs — a fix can change behaviour in
|
||||||
ways the next experiment must see. One fix per defect class, as its own change:
|
ways the next experiment must see. One fix per defect class, as its own change:
|
||||||
|
|
||||||
1. A worktree on a branch off `origin/typescript` (never `origin/master`, the 0.4.0 code), named
|
1. A worktree on a branch off `origin/main` (never `origin/v0.4.0`, the 0.4.0 code), named
|
||||||
for the defect.
|
for the defect.
|
||||||
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings;
|
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings;
|
||||||
then the implementation; then the decision record in the root `AGENTS.md` where the fix settles
|
then the implementation; then the decision record in the root `AGENTS.md` where the fix settles
|
||||||
a question of the wire or the session's life.
|
a question of the wire or the session's life.
|
||||||
3. `/larv-review` on the branch, with the pull request based on `typescript`. When it marks the PR
|
3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
|
||||||
ready, squash-merge it.
|
ready, squash-merge it.
|
||||||
4. Back in the experiments worktree: fast-forward `typescript`, rerun the experiment that found the
|
4. Back in the experiments worktree: fast-forward `main`, rerun the experiment that found the
|
||||||
defect, delete the workaround its test carried, and note the fix in the findings file.
|
defect, delete the workaround its test carried, and note the fix in the findings file.
|
||||||
|
|||||||
@@ -177,9 +177,9 @@ the fixtures pin that a reader would not otherwise expect:
|
|||||||
application wanting one report per message compares each `dlr.smsId` against the `smsIds` array
|
application wanting one report per message compares each `dlr.smsId` against the `smsIds` array
|
||||||
`sendSms()` returned and merges them itself.
|
`sendSms()` returned and merges them itself.
|
||||||
- **Telesign answers only the first segment of a concatenated submit with a `message_id`.**
|
- **Telesign answers only the first segment of a concatenated submit with a `message_id`.**
|
||||||
`sendSms()` returns `['<id>', '', '']` — one entry per segment, positional with `pduObjs`, empty
|
`sendSms()` returns `['<id>', undefined, undefined]` — one entry per segment, positional with
|
||||||
where the SMSC named nothing. `dlr.smsId` is never empty, so an empty entry matches no receipt,
|
`pduObjs`, `undefined` where the SMSC named nothing; it returned `''` there until #101.
|
||||||
and no merge is armed.
|
`dlr.smsId` is never empty, so an unnamed entry matches no receipt, and no merge is armed.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
@@ -203,6 +203,5 @@ the fixtures pin that a reader would not otherwise expect:
|
|||||||
`FAILED` and CM.com's `DELIVERD`. The documented-codes table in `operator-receipts.test.ts` is
|
`FAILED` and CM.com's `DELIVERD`. The documented-codes table in `operator-receipts.test.ts` is
|
||||||
the place a new one goes, and it fails loudly for anything nothing names.
|
the place a new one goes, and it fails loudly for anything nothing names.
|
||||||
- Whether `smsIds` carrying an empty entry for a segment the SMSC took but named no id for is the
|
- Whether `smsIds` carrying an empty entry for a segment the SMSC took but named no id for is the
|
||||||
right shape for a caller, or whether that case wants saying differently. It is documented in
|
right shape for a caller. Settled in #101: the entry is `undefined`, and the decision with its
|
||||||
`README.md` and pinned by the Telesign scenario; the question is a product one, not a correctness
|
rejected alternatives is recorded in `AGENTS.md`.
|
||||||
one, and belongs to the phase 11 product-owner pass.
|
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency
|
|||||||
assert.equal(sent.smsIds.length, testCase.expectedSegments);
|
assert.equal(sent.smsIds.length, testCase.expectedSegments);
|
||||||
|
|
||||||
for (const id of sent.smsIds) {
|
for (const id of sent.smsIds) {
|
||||||
|
assert.ok(id, 'expected Jasmin to name a message id for every segment');
|
||||||
assert.match(id, uuidPattern, 'expected a UUID-shaped message id from Jasmin\'s submit_sm_resp');
|
assert.match(id, uuidPattern, 'expected a UUID-shaped message id from Jasmin\'s submit_sm_resp');
|
||||||
|
|
||||||
// The full round trip - submit_sm to Jasmin's smpps, mtrouter, AMQP, the connector bind,
|
// The full round trip - submit_sm to Jasmin's smpps, mtrouter, AMQP, the connector bind,
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ function dlrLooksIntact(received: Received | undefined): received is Received {
|
|||||||
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
|
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SMPPSim names an id for every segment, so an unnamed one is the finding, not the norm. */
|
||||||
|
function namedIds(smsIds: (string | undefined)[]): string[] {
|
||||||
|
const ids = smsIds.filter(id => id !== undefined);
|
||||||
|
|
||||||
|
assert.equal(ids.length, smsIds.length, 'expected SMPPSim to name a message id for every segment');
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resends a fresh message until one attempt's segments are every one matched by an intact `dlr`.
|
* Resends a fresh message until one attempt's segments are every one matched by an intact `dlr`.
|
||||||
* Retrying works around the peer+library interaction above without hiding it: a scenario only
|
* Retrying works around the peer+library interaction above without hiding it: a scenario only
|
||||||
@@ -119,12 +128,14 @@ async function sendUntilAllDlrsArrive(
|
|||||||
|
|
||||||
assert.equal(sent.err, undefined);
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const ids = namedIds(sent.smsIds);
|
||||||
|
|
||||||
const complete = await waitFor(
|
const complete = await waitFor(
|
||||||
() => (sent.smsIds.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))) ? true : undefined),
|
() => (ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))) ? true : undefined),
|
||||||
DLR_RETRY_BUDGET_MS,
|
DLR_RETRY_BUDGET_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (complete) return sent.smsIds;
|
if (complete) return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`no attempt got an intact DLR for every segment within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
throw new Error(`no attempt got an intact DLR for every segment within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
||||||
@@ -149,14 +160,16 @@ async function sendUntilComplete(
|
|||||||
|
|
||||||
assert.equal(sent.err, undefined);
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const ids = namedIds(sent.smsIds);
|
||||||
|
|
||||||
const complete = await waitFor(() => {
|
const complete = await waitFor(() => {
|
||||||
const allIntact = sent.smsIds.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id)));
|
const allIntact = ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id)));
|
||||||
const reassembled = sms.find(s => s.message === message);
|
const reassembled = sms.find(s => s.message === message);
|
||||||
|
|
||||||
return allIntact && reassembled ? { reassembled } : undefined;
|
return allIntact && reassembled ? { reassembled } : undefined;
|
||||||
}, DLR_RETRY_BUDGET_MS);
|
}, DLR_RETRY_BUDGET_MS);
|
||||||
|
|
||||||
if (complete) return { reassembled: complete.reassembled, smsIds: sent.smsIds };
|
if (complete) return { reassembled: complete.reassembled, smsIds: ids };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`no attempt got both an intact DLR per segment and a loopback reassembly within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
throw new Error(`no attempt got both an intact DLR per segment and a loopback reassembly within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
||||||
@@ -276,7 +289,7 @@ describe('smppsim-transition - C4 intermediate then final', () => {
|
|||||||
const sent = await session.send({
|
const sent = await session.send({
|
||||||
cmdName: 'submit_sm',
|
cmdName: 'submit_sm',
|
||||||
params: {
|
params: {
|
||||||
data_coding: consts.ENCODING.ASCII,
|
data_coding: 0,
|
||||||
destination_addr: TO,
|
destination_addr: TO,
|
||||||
registered_delivery: 0x11,
|
registered_delivery: 0x11,
|
||||||
short_message: Buffer.from('transition test', 'latin1'),
|
short_message: Buffer.from('transition test', 'latin1'),
|
||||||
|
|||||||
@@ -38,13 +38,17 @@ async function sendAndAwaitDlrs(session: Session, dlrs: Dlr[], message: string):
|
|||||||
|
|
||||||
assert.equal(sent.err, undefined);
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const ids = sent.smsIds.filter(id => id !== undefined);
|
||||||
|
|
||||||
|
assert.equal(ids.length, sent.smsIds.length, 'expected smscsim to name a message id for every segment');
|
||||||
|
|
||||||
const complete = await waitFor(() => (
|
const complete = await waitFor(() => (
|
||||||
sent.smsIds.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined
|
ids.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined
|
||||||
));
|
));
|
||||||
|
|
||||||
assert.ok(complete, 'every segment of the first send should get a DLR');
|
assert.ok(complete, 'every segment of the first send should get a DLR');
|
||||||
|
|
||||||
return sent.smsIds;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('smscsim - C1 bind, keepalive, unbind', () => {
|
describe('smscsim - C1 bind, keepalive, unbind', () => {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@larvit/smpp",
|
"name": "@larvit/smpp",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Simplified SMPP implementation",
|
"description": "SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send window, long messages and delivery receipts",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"esm",
|
"esm",
|
||||||
"pdu",
|
"pdu",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const consts = {
|
|||||||
YEARS: 0x0E,
|
YEARS: 0x0E,
|
||||||
},
|
},
|
||||||
ENCODING: {
|
ENCODING: {
|
||||||
ASCII: 0x01,
|
|
||||||
BINARY: 0x04,
|
BINARY: 0x04,
|
||||||
CYRILLIC: 0x06,
|
CYRILLIC: 0x06,
|
||||||
EXTENDED_KANJI_JIS: 0x0D,
|
EXTENDED_KANJI_JIS: 0x0D,
|
||||||
|
|||||||
@@ -121,6 +121,29 @@ export function detect(value: string): EncodingName {
|
|||||||
return 'UCS2';
|
return 'UCS2';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Unencodable = { char: string; index: number };
|
||||||
|
|
||||||
|
/** What an alphabet could not carry, said the one way: the character, its code point and its index. */
|
||||||
|
export function unencodableText(at: Unencodable): string {
|
||||||
|
const point = (at.char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0');
|
||||||
|
|
||||||
|
return `${JSON.stringify(at.char)} (U+${point}) at index ${String(at.index)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first character `encoding` cannot carry, or undefined where it carries every one of them. */
|
||||||
|
export function unencodable(message: string, encoding: EncodingName): Unencodable | undefined {
|
||||||
|
const codec = encodings[encoding];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
for (const char of message) {
|
||||||
|
if (codec.decode(codec.encode(char)) !== char) return { char, index };
|
||||||
|
|
||||||
|
index += char.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined
|
* The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined
|
||||||
* where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is.
|
* where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is.
|
||||||
@@ -165,3 +188,13 @@ export function encodingByDataCoding(dataCoding: number): EncodingName {
|
|||||||
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
|
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
|
||||||
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
|
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `data_coding` an alphabet is written under, the mirror of `encodingByDataCoding()`. GSM 03.38
|
||||||
|
* takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5.
|
||||||
|
*/
|
||||||
|
export const dataCodingByEncoding: Readonly<Record<EncodingName, number>> = {
|
||||||
|
ASCII: 0x00,
|
||||||
|
LATIN1: 0x03,
|
||||||
|
UCS2: 0x08,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ParamValue, WireType } from './types.ts';
|
import type { ParamValue, WireType } from './types.ts';
|
||||||
|
import type { Result } from '../result.ts';
|
||||||
import { tlv } from './types.ts';
|
import { tlv } from './types.ts';
|
||||||
|
|
||||||
export type TlvDefinition = {
|
export type TlvDefinition = {
|
||||||
@@ -104,3 +105,60 @@ export type Tlv = {
|
|||||||
tagName: string | undefined;
|
tagName: string | undefined;
|
||||||
tagValue: ParamValue;
|
tagValue: ParamValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TlvInput = {
|
||||||
|
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
|
||||||
|
tagId?: number | undefined;
|
||||||
|
tagValue: ParamValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
||||||
|
const tagId = input.tagId ?? tlvs[name]?.id;
|
||||||
|
|
||||||
|
if (tagId === undefined) {
|
||||||
|
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
|
||||||
|
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tagId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each TLV as its four octet header and the value the tag's own wire type writes. */
|
||||||
|
export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
for (const [name, input] of Object.entries(inputs ?? {})) {
|
||||||
|
const tag = tagIdOf(name, input);
|
||||||
|
|
||||||
|
if (tag.err) return { err: tag.err };
|
||||||
|
|
||||||
|
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
|
||||||
|
const sized = type.size(input.tagValue);
|
||||||
|
|
||||||
|
if (sized.err) {
|
||||||
|
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sized.size > 0xffff) {
|
||||||
|
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = Buffer.alloc(sized.size + 4);
|
||||||
|
|
||||||
|
chunk.writeUInt16BE(tag.tagId, 0);
|
||||||
|
chunk.writeUInt16BE(sized.size, 2);
|
||||||
|
|
||||||
|
const written = type.write(input.tagValue, chunk, 4);
|
||||||
|
|
||||||
|
if (written.err) {
|
||||||
|
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { chunks };
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -37,12 +37,12 @@ const severity: Record<MessageState, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** The base and the part numbers one send's ids carry, or nothing when they do not spell out one message. */
|
/** The base and the part numbers one send's ids carry, or nothing when they do not spell out one message. */
|
||||||
function idNumbering(smsIds: string[]): { base: string; parts: Set<number> } | undefined {
|
function idNumbering(smsIds: (string | undefined)[]): { base: string; parts: Set<number> } | undefined {
|
||||||
const bases = new Set<string>();
|
const bases = new Set<string>();
|
||||||
const parts = new Set<number>();
|
const parts = new Set<number>();
|
||||||
|
|
||||||
for (const smsId of smsIds) {
|
for (const smsId of smsIds) {
|
||||||
const numbering = parseSegmentId(smsId);
|
const numbering = smsId === undefined ? undefined : parseSegmentId(smsId);
|
||||||
|
|
||||||
if (!numbering) return undefined;
|
if (!numbering) return undefined;
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export class DlrMerger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Registers the ids one multipart send got back, so their receipts can be merged. */
|
/** Registers the ids one multipart send got back, so their receipts can be merged. */
|
||||||
expect(smsIds: string[]): void {
|
expect(smsIds: (string | undefined)[]): void {
|
||||||
if (smsIds.length < 2) return;
|
if (smsIds.length < 2) return;
|
||||||
|
|
||||||
const numbering = idNumbering(smsIds);
|
const numbering = idNumbering(smsIds);
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ export { Session } from './session.ts';
|
|||||||
|
|
||||||
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
||||||
export { consts, constsById } from './defs/constants.ts';
|
export { consts, constsById } from './defs/constants.ts';
|
||||||
export { detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf } from './defs/encodings.ts';
|
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
|
||||||
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
||||||
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
||||||
export { types } from './defs/types.ts';
|
export { types } from './defs/types.ts';
|
||||||
@@ -62,7 +62,7 @@ export type {
|
|||||||
} from './session.ts';
|
} from './session.ts';
|
||||||
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
||||||
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
|
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
|
||||||
export type { Encoding, EncodingName } from './defs/encodings.ts';
|
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
|
||||||
export type { ErrorName } from './defs/errors.ts';
|
export type { ErrorName } from './defs/errors.ts';
|
||||||
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||||
export type { PduHeader } from './pdu-refusal.ts';
|
export type { PduHeader } from './pdu-refusal.ts';
|
||||||
|
|||||||
+53
-16
@@ -1,6 +1,6 @@
|
|||||||
import type { Result } from './result.ts';
|
import type { Result } from './result.ts';
|
||||||
import type { EncodingName } from './defs/encodings.ts';
|
import type { EncodingName } from './defs/encodings.ts';
|
||||||
import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
|
||||||
import { hasUdh } from './defs/constants.ts';
|
import { hasUdh } from './defs/constants.ts';
|
||||||
import { udhLength } from './udh.ts';
|
import { udhLength } from './udh.ts';
|
||||||
|
|
||||||
@@ -27,6 +27,25 @@ export function encodeMessage(
|
|||||||
return { buffer: encodings[resolved].encode(message), encoding: resolved };
|
return { buffer: encodings[resolved].encode(message), encoding: resolved };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A message body as octets, under the alphabet `dataCoding` resolves to, or one detected for it. */
|
||||||
|
export function encodeBody(
|
||||||
|
text: string,
|
||||||
|
dataCoding: number | undefined,
|
||||||
|
): Result<{ buffer: Buffer; dataCoding: number }> {
|
||||||
|
if (dataCoding === undefined) {
|
||||||
|
const detected = encodeMessage(text);
|
||||||
|
|
||||||
|
return { buffer: detected.buffer, dataCoding: dataCodingByEncoding[detected.encoding] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoding = encodingByDataCoding(dataCoding);
|
||||||
|
const lost = unencodable(text, encoding);
|
||||||
|
|
||||||
|
return lost
|
||||||
|
? { err: new Error(`data_coding ${String(dataCoding)} resolves to ${encoding}, which cannot carry ${unencodableText(lost)}; pass a Buffer of octets, or a data_coding whose alphabet carries them`) }
|
||||||
|
: { buffer: encodings[encoding].encode(text), dataCoding };
|
||||||
|
}
|
||||||
|
|
||||||
export function decodeMessage(
|
export function decodeMessage(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
dataCoding: number,
|
dataCoding: number,
|
||||||
@@ -113,39 +132,57 @@ export function smppDate(date: Date): string {
|
|||||||
+ pad(date.getUTCMinutes(), 2);
|
+ pad(date.getUTCMinutes(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A second count is spelled in days and below: no fixed number of seconds is a month or a year. */
|
||||||
|
const maxRelativeSeconds = 99 * 86400 + 86399;
|
||||||
|
|
||||||
const absoluteTime = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d)(\d\d)([+-])$/;
|
const absoluteTime = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d)(\d\d)([+-])$/;
|
||||||
const relativeTime = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)000R$/;
|
const relativeTime = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)000R$/;
|
||||||
|
|
||||||
/** The SMPP absolute and relative time format, as used by validity_period and friends. */
|
/** The SMPP absolute and relative time format, as used by validity_period and friends. */
|
||||||
export const smppTime = {
|
export const smppTime = {
|
||||||
/**
|
/**
|
||||||
* A Date becomes an absolute UTC time; a number is a relative period in seconds. Relative
|
* A Date becomes an absolute UTC time; a number is a relative period in seconds, expressed in
|
||||||
* periods are expressed in days and below, so anything past 99 days is clamped to that.
|
* days and below; a string is a stamp the caller formatted itself and is passed through. A value
|
||||||
|
* naming no instant or period, and a second count no day field reaches, are refused.
|
||||||
*/
|
*/
|
||||||
encode(value: Date | number | string): string {
|
encode(value: Date | number | string): Result<{ text: string }> {
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return { text: value };
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
const total = Math.max(0, Math.floor(value));
|
if (!Number.isFinite(value)) return { err: new Error(`Not an SMPP time: ${String(value)}`) };
|
||||||
const days = Math.min(99, Math.floor(total / 86400));
|
|
||||||
const capped = days === 99 ? 99 * 86400 + 86399 : total;
|
|
||||||
|
|
||||||
return '0000'
|
const seconds = Math.floor(value);
|
||||||
+ pad(Math.floor(capped / 86400), 2)
|
|
||||||
+ pad(Math.floor(capped / 3600) % 24, 2)
|
if (seconds < 0) {
|
||||||
+ pad(Math.floor(capped / 60) % 60, 2)
|
return { err: new Error(`A relative period cannot be negative, got ${String(value)}`) };
|
||||||
+ pad(capped % 60, 2)
|
|
||||||
+ '000R';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return pad(value.getUTCFullYear() % 100, 2)
|
if (seconds > maxRelativeSeconds) {
|
||||||
|
return { err: new Error(`A period in seconds is spelled in days and below, so ${String(maxRelativeSeconds)} (99d 23:59:59) is the most, got ${String(value)}; pass a Date for an instant further out`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: '0000'
|
||||||
|
+ pad(Math.floor(seconds / 86400), 2)
|
||||||
|
+ pad(Math.floor(seconds / 3600) % 24, 2)
|
||||||
|
+ pad(Math.floor(seconds / 60) % 60, 2)
|
||||||
|
+ pad(seconds % 60, 2)
|
||||||
|
+ '000R',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(value.getTime())) return { err: new Error('Not an SMPP time: an invalid Date') };
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: pad(value.getUTCFullYear() % 100, 2)
|
||||||
+ pad(value.getUTCMonth() + 1, 2)
|
+ pad(value.getUTCMonth() + 1, 2)
|
||||||
+ pad(value.getUTCDate(), 2)
|
+ pad(value.getUTCDate(), 2)
|
||||||
+ pad(value.getUTCHours(), 2)
|
+ pad(value.getUTCHours(), 2)
|
||||||
+ pad(value.getUTCMinutes(), 2)
|
+ pad(value.getUTCMinutes(), 2)
|
||||||
+ pad(value.getUTCSeconds(), 2)
|
+ pad(value.getUTCSeconds(), 2)
|
||||||
+ pad(Math.floor(value.getUTCMilliseconds() / 100), 1)
|
+ pad(Math.floor(value.getUTCMilliseconds() / 100), 1)
|
||||||
+ '00+';
|
+ '00+',
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
decode(value: string): Result<{ date: Date }> {
|
decode(value: string): Result<{ date: Date }> {
|
||||||
|
|||||||
+96
-79
@@ -3,15 +3,14 @@ import type { ErrorName } from './defs/errors.ts';
|
|||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from './defs/types.ts';
|
||||||
import type { PduHeader } from './pdu-refusal.ts';
|
import type { PduHeader } from './pdu-refusal.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { Tlv } from './defs/tlvs.ts';
|
import type { Tlv, TlvInput } from './defs/tlvs.ts';
|
||||||
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
|
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
|
||||||
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
||||||
import { consts, hasUdh } from './defs/constants.ts';
|
import { hasUdh } from './defs/constants.ts';
|
||||||
import { decodeMessage, encodeMessage } from './message.ts';
|
import { decodeMessage, encodeBody } from './message.ts';
|
||||||
import { detect, encodingByDataCoding } from './defs/encodings.ts';
|
|
||||||
import { errorNameById, errors, isErrorName } from './defs/errors.ts';
|
import { errorNameById, errors, isErrorName } from './defs/errors.ts';
|
||||||
import { paramNumber } from './defs/types.ts';
|
import { paramNumber } from './defs/types.ts';
|
||||||
import { tlvDefault, tlvs, tlvsById } from './defs/tlvs.ts';
|
import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
|
||||||
|
|
||||||
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
|
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
|
||||||
export const maxSeqNr = 2147483646;
|
export const maxSeqNr = 2147483646;
|
||||||
@@ -19,12 +18,6 @@ export const maxSeqNr = 2147483646;
|
|||||||
/** What the field holds. Read and echoed in full, because peers do write above the spec's range. */
|
/** What the field holds. Read and echoed in full, because peers do write above the spec's range. */
|
||||||
const maxWireSeqNr = 0xFFFFFFFF;
|
const maxWireSeqNr = 0xFFFFFFFF;
|
||||||
|
|
||||||
export type TlvInput = {
|
|
||||||
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
|
|
||||||
tagId?: number | undefined;
|
|
||||||
tagValue: ParamValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PduObjectInput<C extends CommandName = CommandName> = {
|
export type PduObjectInput<C extends CommandName = CommandName> = {
|
||||||
cmdName: C;
|
cmdName: C;
|
||||||
cmdStatus?: ErrorName;
|
cmdStatus?: ErrorName;
|
||||||
@@ -53,6 +46,8 @@ export type PduObject = {
|
|||||||
tlvs: Record<string, Tlv>;
|
tlvs: Record<string, Tlv>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { TlvInput };
|
||||||
|
|
||||||
const respBit = 0x80000000;
|
const respBit = 0x80000000;
|
||||||
|
|
||||||
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
|
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
|
||||||
@@ -71,44 +66,98 @@ export function isCommand<C extends CommandName>(
|
|||||||
return pduObj.cmdName === cmdName;
|
return pduObj.cmdName === cmdName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
type ResolvedBody = {
|
||||||
const tagId = input.tagId ?? tlvs[name]?.id;
|
params: Record<string, ParamValue | undefined>;
|
||||||
|
tlvs: Record<string, TlvInput> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
if (tagId === undefined) {
|
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
|
||||||
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
|
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
|
|
||||||
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { tagId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encoding a string short_message settles data_coding and sm_length; a buffer settles sm_length. */
|
type CarriedBody = { name: string; text: string; tlv: TlvInput };
|
||||||
function resolveShortMessage(
|
|
||||||
params: Record<string, ParamValue | undefined>,
|
|
||||||
): Record<string, ParamValue | undefined> {
|
|
||||||
const message = params.short_message;
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(message)) {
|
/** Every entry carrying body text, under whatever names their tagIds are keyed to. */
|
||||||
return params.sm_length === undefined
|
function carriedBodies(input: Record<string, TlvInput> | undefined): CarriedBody[] {
|
||||||
? { ...params, sm_length: message.length }
|
const carried: CarriedBody[] = [];
|
||||||
: { ...params };
|
|
||||||
|
for (const [name, tlv] of Object.entries(input ?? {})) {
|
||||||
|
const tag = tagIdOf(name, tlv);
|
||||||
|
|
||||||
|
if (!tag.err && tag.tagId === tlvs.message_payload.id && typeof tlv.tagValue === 'string') {
|
||||||
|
carried.push({ name, text: tlv.tagValue, tlv });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof message !== 'string') return { ...params };
|
return carried;
|
||||||
|
}
|
||||||
|
|
||||||
const dataCoding = params.data_coding;
|
/** messageOctets() reads short_message wherever it holds an octet, and the TLV only where it does not. */
|
||||||
const encoding = typeof dataCoding === 'number' ? encodingByDataCoding(dataCoding) : detect(message);
|
function carriesOctets(value: ParamValue | undefined): boolean {
|
||||||
const encoded = encodeMessage(message, encoding);
|
return Buffer.isBuffer(value) && value.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
/** The short_message the command's own table will write, since writeParams() ignores any other. */
|
||||||
...params,
|
function writtenBody(definition: CommandDefinition, value: ParamValue | undefined): ParamValue | undefined {
|
||||||
data_coding: typeof dataCoding === 'number' ? dataCoding : consts.ENCODING[encoded.encoding],
|
return definition.params?.short_message === undefined ? undefined : value;
|
||||||
short_message: encoded.buffer,
|
}
|
||||||
sm_length: encoded.buffer.length,
|
|
||||||
};
|
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
|
||||||
|
function resolveCarried(
|
||||||
|
resolved: ResolvedBody,
|
||||||
|
inputs: Record<string, TlvInput> | undefined,
|
||||||
|
dataCoding: number | undefined,
|
||||||
|
settles: boolean,
|
||||||
|
): VoidResult {
|
||||||
|
for (const carried of carriedBodies(inputs)) {
|
||||||
|
const encoded = encodeBody(carried.text, dataCoding);
|
||||||
|
|
||||||
|
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
|
||||||
|
|
||||||
|
if (settles) resolved.params.data_coding = encoded.dataCoding;
|
||||||
|
|
||||||
|
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** data_coding names the alphabet of the body, and short_message settles it where it carries octets. */
|
||||||
|
function resolveBody(
|
||||||
|
params: Record<string, ParamValue | undefined>,
|
||||||
|
tlvs: Record<string, TlvInput> | undefined,
|
||||||
|
definition: CommandDefinition,
|
||||||
|
): Result<ResolvedBody> {
|
||||||
|
const message = writtenBody(definition, params.short_message);
|
||||||
|
const resolved: ResolvedBody = { params: { ...params }, tlvs };
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(message) && params.sm_length === undefined) {
|
||||||
|
resolved.params.sm_length = message.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
const encoded = encodeBody(message, codingOf(params));
|
||||||
|
|
||||||
|
if (encoded.err) {
|
||||||
|
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carriesOctets(encoded.buffer)) resolved.params.data_coding = encoded.dataCoding;
|
||||||
|
|
||||||
|
resolved.params.short_message = encoded.buffer;
|
||||||
|
resolved.params.sm_length = encoded.buffer.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only octets the command's own table will write can settle the alphabet the PDU declares.
|
||||||
|
const settles = !carriesOctets(writtenBody(definition, resolved.params.short_message));
|
||||||
|
const carried = resolveCarried(
|
||||||
|
resolved,
|
||||||
|
tlvs,
|
||||||
|
settles ? codingOf(params) : codingOf(resolved.params),
|
||||||
|
settles,
|
||||||
|
);
|
||||||
|
|
||||||
|
return carried.err ? { err: carried.err } : resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeParams(
|
function writeParams(
|
||||||
@@ -139,42 +188,6 @@ function writeParams(
|
|||||||
return { chunks };
|
return { chunks };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeTlvs(tlvs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
|
|
||||||
for (const [name, tlv] of Object.entries(tlvs ?? {})) {
|
|
||||||
const tag = tagIdOf(name, tlv);
|
|
||||||
|
|
||||||
if (tag.err) return { err: tag.err };
|
|
||||||
|
|
||||||
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
|
|
||||||
const sized = type.size(tlv.tagValue);
|
|
||||||
|
|
||||||
if (sized.err) {
|
|
||||||
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sized.size > 0xffff) {
|
|
||||||
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunk = Buffer.alloc(sized.size + 4);
|
|
||||||
|
|
||||||
chunk.writeUInt16BE(tag.tagId, 0);
|
|
||||||
chunk.writeUInt16BE(sized.size, 2);
|
|
||||||
|
|
||||||
const written = type.write(tlv.tagValue, chunk, 4);
|
|
||||||
|
|
||||||
if (written.err) {
|
|
||||||
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks.push(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { chunks };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMPP 3.4: a response reporting a failure carries no body, so its fields are not "unused but
|
* SMPP 3.4: a response reporting a failure carries no body, so its fields are not "unused but
|
||||||
* present" — they are absent, and a peer that reads them anyway reads past the end of the PDU.
|
* present" — they are absent, and a peer that reads them anyway reads past the end of the PDU.
|
||||||
@@ -188,11 +201,15 @@ function buildBody(
|
|||||||
): Result<{ body: Buffer }> {
|
): Result<{ body: Buffer }> {
|
||||||
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
|
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
|
||||||
|
|
||||||
const written = writeParams(definition, resolveShortMessage(params), cmdName);
|
const body = resolveBody(params, tlvs, definition);
|
||||||
|
|
||||||
|
if (body.err) return { err: body.err };
|
||||||
|
|
||||||
|
const written = writeParams(definition, body.params, cmdName);
|
||||||
|
|
||||||
if (written.err) return { err: written.err };
|
if (written.err) return { err: written.err };
|
||||||
|
|
||||||
const writtenTlvs = writeTlvs(tlvs);
|
const writtenTlvs = writeTlvs(body.tlvs);
|
||||||
|
|
||||||
if (writtenTlvs.err) return { err: writtenTlvs.err };
|
if (writtenTlvs.err) return { err: writtenTlvs.err };
|
||||||
|
|
||||||
|
|||||||
+75
-19
@@ -1,4 +1,4 @@
|
|||||||
import type { EncodingName } from './defs/encodings.ts';
|
import type { EncodingName, Unencodable } from './defs/encodings.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from './defs/types.ts';
|
||||||
import type { SubmitMessagingMode } from './defs/constants.ts';
|
import type { SubmitMessagingMode } from './defs/constants.ts';
|
||||||
import type { PduObject, PduObjectInput } from './pdu.ts';
|
import type { PduObject, PduObjectInput } from './pdu.ts';
|
||||||
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
|
|||||||
import type { SmsIdNotation } from './sms-id.ts';
|
import type { SmsIdNotation } from './sms-id.ts';
|
||||||
import { UnansweredError } from './unanswered-error.ts';
|
import { UnansweredError } from './unanswered-error.ts';
|
||||||
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
||||||
import { detect, encodingNames, isEncodingName } from './defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
|
||||||
import { namedValue } from './error-from.ts';
|
import { namedValue } from './error-from.ts';
|
||||||
import { normaliseSmsId } from './sms-id.ts';
|
import { normaliseSmsId } from './sms-id.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { paramText } from './defs/types.ts';
|
||||||
@@ -33,16 +33,20 @@ export type SendSmsOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** The options as they arrive: a caller without types can put anything in the checked fields. */
|
/** The options as they arrive: a caller without types can put anything in the checked fields. */
|
||||||
export type SendSmsInput = Omit<SendSmsOptions, 'encoding' | 'messagingMode'> & {
|
export type SendSmsInput =
|
||||||
|
Omit<SendSmsOptions, 'encoding' | 'messagingMode' | 'scheduleDeliveryTime' | 'validityPeriod'> & {
|
||||||
encoding?: unknown;
|
encoding?: unknown;
|
||||||
messagingMode?: unknown;
|
messagingMode?: unknown;
|
||||||
};
|
scheduleDeliveryTime?: unknown;
|
||||||
|
validityPeriod?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
||||||
export type SendSmsResult = {
|
export type SendSmsResult = {
|
||||||
err?: Error;
|
err?: Error;
|
||||||
pduObjs: PduObject[];
|
pduObjs: PduObject[];
|
||||||
smsIds: string[];
|
/** Positional with `pduObjs`, and undefined where the SMSC took the segment and named no id. */
|
||||||
|
smsIds: (string | undefined)[];
|
||||||
/** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */
|
/** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */
|
||||||
unanswered: number;
|
unanswered: number;
|
||||||
};
|
};
|
||||||
@@ -60,16 +64,24 @@ export type SendSmsDeps = {
|
|||||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The time fields as the wire spells them, settled once for a message rather than per segment. */
|
||||||
|
type SendTimes = {
|
||||||
|
scheduleDeliveryTime?: string;
|
||||||
|
validityPeriod?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** What the checks below settle, before a segment exists to carry it. */
|
/** What the checks below settle, before a segment exists to carry it. */
|
||||||
type CheckedOptions = {
|
type CheckedOptions = {
|
||||||
encoding: EncodingName;
|
encoding: EncodingName;
|
||||||
messagingMode: SubmitMessagingMode;
|
messagingMode: SubmitMessagingMode;
|
||||||
|
times: SendTimes;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SegmentOptions = {
|
type SegmentOptions = {
|
||||||
encoding: EncodingName;
|
encoding: EncodingName;
|
||||||
messagingMode?: SubmitMessagingMode;
|
messagingMode?: SubmitMessagingMode;
|
||||||
multipart: boolean;
|
multipart: boolean;
|
||||||
|
times?: SendTimes;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Alphanumeric senders must be TON 5. */
|
/** Alphanumeric senders must be TON 5. */
|
||||||
@@ -78,7 +90,7 @@ function addressTon(address: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dataCodingFor(encoding: EncodingName, flash: boolean): number {
|
function dataCodingFor(encoding: EncodingName, flash: boolean): number {
|
||||||
if (!flash) return consts.ENCODING[encoding];
|
if (!flash) return dataCodingByEncoding[encoding];
|
||||||
|
|
||||||
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
|
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
|
||||||
return encoding === 'UCS2' ? 0x18 : 0x10;
|
return encoding === 'UCS2' ? 0x18 : 0x10;
|
||||||
@@ -92,7 +104,7 @@ function esmClassFor(mode: SubmitMessagingMode | undefined, multipart: boolean):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function submitSmParams(
|
export function submitSmParams(
|
||||||
sms: SendSmsInput,
|
sms: Omit<SendSmsInput, 'scheduleDeliveryTime' | 'validityPeriod'>,
|
||||||
segment: Buffer,
|
segment: Buffer,
|
||||||
options: SegmentOptions,
|
options: SegmentOptions,
|
||||||
): Record<string, ParamValue> {
|
): Record<string, ParamValue> {
|
||||||
@@ -108,13 +120,12 @@ export function submitSmParams(
|
|||||||
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
|
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const schedule = options.times?.scheduleDeliveryTime;
|
||||||
|
const validity = options.times?.validityPeriod;
|
||||||
|
|
||||||
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
|
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
|
||||||
if (sms.scheduleDeliveryTime !== undefined) {
|
if (schedule !== undefined) params.schedule_delivery_time = schedule;
|
||||||
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
|
if (validity !== undefined) params.validity_period = validity;
|
||||||
}
|
|
||||||
if (sms.validityPeriod !== undefined) {
|
|
||||||
params.validity_period = smppTime.encode(sms.validityPeriod);
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
@@ -157,11 +168,40 @@ function refusedEncoding(encoding: unknown): Error {
|
|||||||
return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`);
|
return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refusedText(encoding: EncodingName, at: Unencodable): Error {
|
||||||
|
return new Error(`encoding ${encoding} cannot carry ${unencodableText(at)}; name UCS2 or leave encoding out`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An alphabet the caller named has to carry the message; the one detect() picks always does. */
|
||||||
function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> {
|
function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> {
|
||||||
if (encoding === undefined) return { encoding: detect(message) };
|
if (encoding === undefined) return { encoding: detect(message) };
|
||||||
if (isEncodingName(encoding)) return { encoding };
|
if (!isEncodingName(encoding)) return { err: refusedEncoding(encoding) };
|
||||||
|
|
||||||
return { err: refusedEncoding(encoding) };
|
const lost = unencodable(message, encoding);
|
||||||
|
|
||||||
|
return lost ? { err: refusedText(encoding, lost) } : { encoding };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkTimes(sms: SendSmsInput): Result<{ times: SendTimes }> {
|
||||||
|
const times: SendTimes = {};
|
||||||
|
|
||||||
|
for (const option of ['scheduleDeliveryTime', 'validityPeriod'] as const) {
|
||||||
|
const value = sms[option];
|
||||||
|
|
||||||
|
if (value === undefined) continue;
|
||||||
|
|
||||||
|
if (typeof value !== 'number' && typeof value !== 'string' && !(value instanceof Date)) {
|
||||||
|
return { err: new Error(`${option} must be a Date, a number of seconds or an SMPP stamp, got ${namedValue(value)}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = smppTime.encode(value);
|
||||||
|
|
||||||
|
if (encoded.err) return { err: new Error(`${option}: ${encoded.err.message}`) };
|
||||||
|
|
||||||
|
times[option] = encoded.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { times };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */
|
/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */
|
||||||
@@ -185,7 +225,11 @@ function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
|||||||
|
|
||||||
if (unspellable) return { err: unspellable };
|
if (unspellable) return { err: unspellable };
|
||||||
|
|
||||||
return { encoding: chosen.encoding, messagingMode: mode.messagingMode };
|
const times = checkTimes(sms);
|
||||||
|
|
||||||
|
if (times.err) return { err: times.err };
|
||||||
|
|
||||||
|
return { encoding: chosen.encoding, messagingMode: mode.messagingMode, times: times.times };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
|
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
|
||||||
@@ -205,12 +249,19 @@ function checkSegments(allowed: number, segments: number): Error | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An absent message_id and one a peer wrote empty are the same octets, and neither names an id. */
|
||||||
|
function acceptedId(pduObj: PduObject, notation: SmsIdNotation | undefined): string | undefined {
|
||||||
|
const smsId = normaliseSmsId(paramText(pduObj.params.message_id), notation);
|
||||||
|
|
||||||
|
return smsId === '' ? undefined : smsId;
|
||||||
|
}
|
||||||
|
|
||||||
function collectSent(
|
function collectSent(
|
||||||
sent: Result<{ pduObj: PduObject }>[],
|
sent: Result<{ pduObj: PduObject }>[],
|
||||||
notation: SmsIdNotation | undefined,
|
notation: SmsIdNotation | undefined,
|
||||||
): SendSmsResult {
|
): SendSmsResult {
|
||||||
const pduObjs: PduObject[] = [];
|
const pduObjs: PduObject[] = [];
|
||||||
const smsIds: string[] = [];
|
const smsIds: (string | undefined)[] = [];
|
||||||
let failure: Error | undefined;
|
let failure: Error | undefined;
|
||||||
let unanswered = 0;
|
let unanswered = 0;
|
||||||
|
|
||||||
@@ -221,7 +272,7 @@ function collectSent(
|
|||||||
failure ??= one.err;
|
failure ??= one.err;
|
||||||
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
|
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
|
||||||
pduObjs.push(one.pduObj);
|
pduObjs.push(one.pduObj);
|
||||||
smsIds.push(normaliseSmsId(paramText(one.pduObj.params.message_id), notation));
|
smsIds.push(acceptedId(one.pduObj, notation));
|
||||||
} else {
|
} else {
|
||||||
const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId);
|
const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId);
|
||||||
|
|
||||||
@@ -252,7 +303,12 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
|
|||||||
// segment before answering — this library's own server does — would otherwise deadlock.
|
// segment before answering — this library's own server does — would otherwise deadlock.
|
||||||
const sent = await Promise.all(segments.map(segment => deps.send({
|
const sent = await Promise.all(segments.map(segment => deps.send({
|
||||||
cmdName: 'submit_sm',
|
cmdName: 'submit_sm',
|
||||||
params: submitSmParams(sms, segment, { encoding, messagingMode: options.messagingMode, multipart }),
|
params: submitSmParams(sms, segment, {
|
||||||
|
encoding,
|
||||||
|
messagingMode: options.messagingMode,
|
||||||
|
multipart,
|
||||||
|
times: options.times,
|
||||||
|
}),
|
||||||
})));
|
})));
|
||||||
|
|
||||||
return collectSent(sent, deps.respIdNotation);
|
return collectSent(sent, deps.respIdNotation);
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
|
import { client } from '../src/client.ts';
|
||||||
|
import { closeAfter } from './teardown.ts';
|
||||||
|
import { consts } from '../src/defs/constants.ts';
|
||||||
|
import { decodeMessage } from '../src/message.ts';
|
||||||
|
import { dlrFromPdu } from '../src/dlr.ts';
|
||||||
|
import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
|
||||||
|
import { objToPdu, pduToObj } from '../src/pdu.ts';
|
||||||
|
import { paramNumber } from '../src/defs/types.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
import type { PduObject } from '../src/pdu.ts';
|
||||||
|
|
||||||
|
const from = '46701113311';
|
||||||
|
const to = '46709771337';
|
||||||
|
|
||||||
|
/** GSM 03.38 puts $ at 0x02 and @ at 0x00, where IA5 has STX and NUL. */
|
||||||
|
const bothTables = 'Cost 5$ @home';
|
||||||
|
|
||||||
|
/** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */
|
||||||
|
const byTheSpecsTable: Record<number, (octets: Buffer) => string> = {
|
||||||
|
// The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38.
|
||||||
|
0x00: octets => encodings.ASCII.decode(octets),
|
||||||
|
// IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80.
|
||||||
|
[consts.ENCODING.IA5]: octets => octets.toString('latin1'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */
|
||||||
|
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error('waited 5000 ms for an event that never fired'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
register(value => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitted(octets: Buffer[]): PduObject[] {
|
||||||
|
return octets.map(pdu => {
|
||||||
|
const { pduObj } = pduToObj(pdu);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
return pduObj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredBy(pduObj: PduObject): number {
|
||||||
|
return paramNumber(pduObj.params.data_coding, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliver_sm carrying `body` under `dataCoding`, as a peer answering our own send would write it. */
|
||||||
|
function delivered(body: Buffer | string, dataCoding: number, esmClass: number): PduObject {
|
||||||
|
const { buffer } = objToPdu({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: dataCoding,
|
||||||
|
destination_addr: from,
|
||||||
|
esm_class: esmClass,
|
||||||
|
short_message: body,
|
||||||
|
source_addr: to,
|
||||||
|
},
|
||||||
|
seqNr: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
return pduObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the alphabet a message declares is the one its octets are written in', () => {
|
||||||
|
test('sends GSM 03.38 under data_coding 0x00, the SMSC default alphabet', async t => {
|
||||||
|
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de97-7caa-9d26-e6d50f5c4888'] });
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
const sent = await session.sendSms({ from, message: bothTables, to });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x00]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps $ and @ for a peer that honours the declaration, where IA5 read STX and NUL', async t => {
|
||||||
|
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de98-7d24-9542-e652e0d3761c'] });
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
|
||||||
|
assert.equal((await session.sendSms({ from, message: bothTables, to })).err, undefined);
|
||||||
|
|
||||||
|
const [pduObj] = submitted(smsc.octets);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
const octets = pduObj.shortMessageOctets;
|
||||||
|
|
||||||
|
assert.ok(octets);
|
||||||
|
assert.equal(octets.toString('hex'), '436f73742035022000686f6d65');
|
||||||
|
|
||||||
|
const read = byTheSpecsTable[declaredBy(pduObj)];
|
||||||
|
|
||||||
|
assert.ok(read, 'the coding declared must be one SMPP 3.4 5.2.19 names an alphabet for');
|
||||||
|
assert.equal(read(octets), bothTables);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves Latin-1 at 0x03 and UCS2 at 0x08, the codings those alphabets always had', async t => {
|
||||||
|
const smsc = await dummySmsc(t, {
|
||||||
|
messageIds: ['01a08779-de99-7fa5-bcac-18feef55aeee', '01a08779-de99-72ec-8dbb-9365a00158c3'],
|
||||||
|
});
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
|
||||||
|
assert.equal((await session.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined);
|
||||||
|
assert.equal((await session.sendSms({ from, message: 'あいう', to })).err, undefined);
|
||||||
|
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x03, 0x08]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too.
|
||||||
|
test('declares 0x00 on a receipt it writes itself', async t => {
|
||||||
|
const { err, server: smpp } = await server({ port: 0 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
closeAfter(t, smpp);
|
||||||
|
|
||||||
|
smpp.on('session', peer => peer.on('sms', async sms => {
|
||||||
|
await sms.sendResp();
|
||||||
|
await sms.sendDlr('DELIVERED');
|
||||||
|
}));
|
||||||
|
|
||||||
|
const connected = await client({ port: smpp.port, reconnect: false });
|
||||||
|
|
||||||
|
assert.equal(connected.err, undefined);
|
||||||
|
assert.ok(connected.session);
|
||||||
|
closeAfter(t, connected.session);
|
||||||
|
|
||||||
|
const session = connected.session;
|
||||||
|
const reported = once<PduObject>(resolve => {
|
||||||
|
session.on('dlr', (_report, pduObj) => { resolve(pduObj); });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((await session.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined);
|
||||||
|
assert.equal(declaredBy(await reported), 0x00);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The low-level surface settles data_coding off the same detection, so it carried the same defect.
|
||||||
|
test('settles a detected string body at 0x00 where the caller named no data_coding', () => {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: { destination_addr: to, short_message: bothTables, source_addr: from },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(built.buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(declaredBy(pduObj), 0x00);
|
||||||
|
assert.equal(pduObj.shortMessageOctets?.toString('hex'), '436f73742035022000686f6d65');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what a peer declares is read as generously as it was before', () => {
|
||||||
|
test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => {
|
||||||
|
assert.equal(encodingByDataCoding(0x01), 'ASCII');
|
||||||
|
|
||||||
|
const octets = Buffer.from('436f73742035022000686f6d65', 'hex');
|
||||||
|
|
||||||
|
for (const dataCoding of [0x00, 0x01]) {
|
||||||
|
assert.equal(decodeMessage(octets, dataCoding).message, bothTables, String(dataCoding));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves a receipt and an inbound message reading the same under either coding', () => {
|
||||||
|
const receiptId = '01a08779-de97-7caa-9d26-e6d50f5c4888';
|
||||||
|
const body = `id:${receiptId} sub:001 dlvrd:001 submit date:2509091430 done date:2509091431 stat:DELIVRD err:000 text:${bothTables}`;
|
||||||
|
|
||||||
|
for (const dataCoding of [0x00, 0x01]) {
|
||||||
|
const receipt = dlrFromPdu(delivered(body, dataCoding, consts.ESM_CLASS.MC_DELIVERY_RECEIPT));
|
||||||
|
|
||||||
|
assert.ok(receipt, String(dataCoding));
|
||||||
|
assert.equal(receipt.smsId, receiptId, String(dataCoding));
|
||||||
|
assert.equal(receipt.statusMsg, 'DELIVERED', String(dataCoding));
|
||||||
|
|
||||||
|
const inbound = delivered(Buffer.from('436f73742035022000686f6d65', 'hex'), dataCoding, 0);
|
||||||
|
|
||||||
|
assert.equal(dlrFromPdu(inbound), undefined, String(dataCoding));
|
||||||
|
assert.equal(inbound.params.short_message, bothTables, String(dataCoding));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+54
-1
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test, { describe } from 'node:test';
|
import test, { describe } from 'node:test';
|
||||||
import { detect, encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts';
|
||||||
|
|
||||||
describe('ASCII (GSM 03.38)', () => {
|
describe('ASCII (GSM 03.38)', () => {
|
||||||
const samples: [string, number[]][] = [
|
const samples: [string, number[]][] = [
|
||||||
@@ -105,6 +105,51 @@ describe('detect()', () => {
|
|||||||
assert.equal(detect('تست'), 'UCS2');
|
assert.equal(detect('تست'), 'UCS2');
|
||||||
assert.equal(detect('۱۲۳۴۵۶۷۸۹۰'), 'UCS2');
|
assert.equal(detect('۱۲۳۴۵۶۷۸۹۰'), 'UCS2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Exhaustive: sendSms() leaves the automatic path unchecked on this and nothing else.
|
||||||
|
test('never picks an alphabet that loses a character, for any code point there is', () => {
|
||||||
|
for (let code = 0; code <= 0x10FFFF; code++) {
|
||||||
|
if (code >= 0xD800 && code <= 0xDFFF) continue;
|
||||||
|
|
||||||
|
const char = String.fromCodePoint(code);
|
||||||
|
const lost = unencodable(char, detect(char));
|
||||||
|
|
||||||
|
if (lost) assert.fail(`detect() picked ${detect(char)} for U+${code.toString(16)}, which loses it`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unencodable()', () => {
|
||||||
|
test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => {
|
||||||
|
assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 });
|
||||||
|
assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 });
|
||||||
|
assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined);
|
||||||
|
assert.equal(unencodable('Åsa', 'ASCII'), undefined);
|
||||||
|
assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined);
|
||||||
|
assert.equal(unencodable('あいう😀', 'UCS2'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => {
|
||||||
|
assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 });
|
||||||
|
assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => {
|
||||||
|
const every = Buffer.from(Array.from({ length: 256 }, (_, byte) => byte));
|
||||||
|
|
||||||
|
assert.equal(unencodable(every.toString('latin1'), 'LATIN1'), undefined);
|
||||||
|
assert.deepEqual(unencodable('Ā', 'LATIN1'), { char: 'Ā', index: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is not match(), which says what detect() may pick rather than what the alphabet holds', () => {
|
||||||
|
assert.equal(encodings.LATIN1.match('Hello world'), false);
|
||||||
|
assert.equal(unencodable('Hello world', 'LATIN1'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => {
|
||||||
|
assert.equal(unencodable('\x1Be', 'ASCII'), undefined);
|
||||||
|
assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('encodingByDataCoding()', () => {
|
describe('encodingByDataCoding()', () => {
|
||||||
@@ -143,6 +188,14 @@ describe('encodingByDataCoding()', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reads every coding dataCodingByEncoding writes back as the alphabet that wrote it', () => {
|
||||||
|
for (const name of Object.keys(dataCodingByEncoding)) {
|
||||||
|
if (!isEncodingName(name)) return assert.fail(`${name} names no alphabet`);
|
||||||
|
|
||||||
|
assert.equal(encodingByDataCoding(dataCodingByEncoding[name]), name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('falls back to ASCII for alphabets it has no codec for', () => {
|
test('falls back to ASCII for alphabets it has no codec for', () => {
|
||||||
assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
||||||
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ describe('sendSms() flash', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
|
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
|
||||||
assert.deepEqual(dataCodingsOf(smsc.octets), [0x01, 0x10, 0x08, 0x18, 0x03]);
|
assert.deepEqual(dataCodingsOf(smsc.octets), [0x00, 0x10, 0x08, 0x18, 0x03]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
|
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
|
||||||
|
|||||||
+61
-4
@@ -234,25 +234,82 @@ describe('smppDate()', () => {
|
|||||||
|
|
||||||
describe('smppTime', () => {
|
describe('smppTime', () => {
|
||||||
test('encodes an absolute time', () => {
|
test('encodes an absolute time', () => {
|
||||||
assert.equal(smppTime.encode(new Date(Date.UTC(2026, 7, 25, 14, 30, 0))), '260825143000000+');
|
assert.equal(smppTime.encode(new Date(Date.UTC(2026, 7, 25, 14, 30, 0))).text, '260825143000000+');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('encodes a relative time given in seconds', () => {
|
test('encodes a relative time given in seconds', () => {
|
||||||
assert.equal(smppTime.encode(3600), '000000010000000R');
|
assert.equal(smppTime.encode(0).text, '000000000000000R');
|
||||||
|
assert.equal(smppTime.encode(3600).text, '000000010000000R');
|
||||||
|
assert.equal(smppTime.encode(99 * 86400).text, '000099000000000R');
|
||||||
|
assert.equal(smppTime.encode(99 * 86400 + 86399).text, '000099235959000R');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a second count past the day field rather than clamping it, naming the Date spelling', () => {
|
||||||
|
for (const seconds of [99 * 86400 + 86400, 86400 * 365]) {
|
||||||
|
const encoded = smppTime.encode(seconds);
|
||||||
|
|
||||||
|
assert.ok(encoded.err instanceof Error, String(seconds));
|
||||||
|
assert.equal(encoded.text, undefined);
|
||||||
|
assert.match(encoded.err.message, /pass a Date/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a negative period without sending the caller to the Date that cannot help', () => {
|
||||||
|
const encoded = smppTime.encode(-1);
|
||||||
|
|
||||||
|
assert.ok(encoded.err instanceof Error);
|
||||||
|
assert.equal(encoded.text, undefined);
|
||||||
|
assert.match(encoded.err.message, /cannot be negative/);
|
||||||
|
assert.doesNotMatch(encoded.err.message, /Date/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodes the years and months the relative format holds but a second count cannot name', () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const year = smppTime.decode('010000000000000R');
|
||||||
|
const months = smppTime.decode('001000000000000R');
|
||||||
|
const day = 86400_000;
|
||||||
|
|
||||||
|
assert.equal(year.err, undefined);
|
||||||
|
assert.ok(year.date, 'a year is a period the format holds');
|
||||||
|
assert.ok(year.date.getTime() >= before + 365 * day, year.date.toISOString());
|
||||||
|
assert.ok(year.date.getTime() <= Date.now() + 366 * day, year.date.toISOString());
|
||||||
|
|
||||||
|
assert.equal(months.err, undefined);
|
||||||
|
assert.ok(months.date, 'ten months is a period the format holds');
|
||||||
|
assert.ok(months.date.getTime() >= before + 290 * day, months.date.toISOString());
|
||||||
|
assert.ok(months.date.getTime() <= Date.now() + 320 * day, months.date.toISOString());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('passes an already-formatted string through', () => {
|
test('passes an already-formatted string through', () => {
|
||||||
assert.equal(smppTime.encode('260825143000000+'), '260825143000000+');
|
assert.equal(smppTime.encode('260825143000000+').text, '260825143000000+');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decodes an absolute time back to the same instant', () => {
|
test('decodes an absolute time back to the same instant', () => {
|
||||||
const when = new Date(Date.UTC(2026, 7, 25, 14, 30, 0));
|
const when = new Date(Date.UTC(2026, 7, 25, 14, 30, 0));
|
||||||
const { err, date } = smppTime.decode(smppTime.encode(when));
|
const encoded = smppTime.encode(when);
|
||||||
|
|
||||||
|
assert.ok(encoded.text);
|
||||||
|
|
||||||
|
const { err, date } = smppTime.decode(encoded.text);
|
||||||
|
|
||||||
assert.equal(err, undefined);
|
assert.equal(err, undefined);
|
||||||
assert.equal(date.toISOString(), when.toISOString());
|
assert.equal(date.toISOString(), when.toISOString());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reports a time it cannot express rather than encoding it as a string of NaNs', () => {
|
||||||
|
for (const value of [new Date('nope'), NaN, Infinity, -Infinity]) {
|
||||||
|
const encoded = smppTime.encode(value);
|
||||||
|
|
||||||
|
assert.ok(encoded.err instanceof Error, String(value));
|
||||||
|
assert.equal(encoded.text, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidDate = smppTime.encode(new Date('nope'));
|
||||||
|
|
||||||
|
assert.ok(invalidDate.err);
|
||||||
|
assert.match(invalidDate.err.message, /invalid Date/);
|
||||||
|
});
|
||||||
|
|
||||||
test('reports malformed input rather than returning an invalid date', () => {
|
test('reports malformed input rather than returning an invalid date', () => {
|
||||||
assert.ok(smppTime.decode('nonsense').err instanceof Error);
|
assert.ok(smppTime.decode('nonsense').err instanceof Error);
|
||||||
assert.ok(smppTime.decode('').err instanceof Error);
|
assert.ok(smppTime.decode('').err instanceof Error);
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ const to = '46709771337';
|
|||||||
const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7);
|
const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7);
|
||||||
|
|
||||||
/** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */
|
/** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */
|
||||||
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000001000b48656c6c6f20776f726c64';
|
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000000000b48656c6c6f20776f726c64';
|
||||||
|
|
||||||
/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */
|
/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */
|
||||||
const threeSegmentOctets = [
|
const threeSegmentOctets = [
|
||||||
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000001009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
|
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000000009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
|
||||||
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000001009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
|
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000000009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
|
||||||
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000100710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
|
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000000710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
|
||||||
];
|
];
|
||||||
|
|
||||||
type BoundPeer = {
|
type BoundPeer = {
|
||||||
|
|||||||
@@ -489,6 +489,38 @@ describe('an SMSC that reports each segment under an id of its own', () => {
|
|||||||
|
|
||||||
assert.equal(sent.err, undefined);
|
assert.equal(sent.err, undefined);
|
||||||
assert.equal(smsc.octets.length, 3, 'every segment goes out whatever the peer answers');
|
assert.equal(smsc.octets.length, 3, 'every segment goes out whatever the peer answers');
|
||||||
assert.deepEqual(sent.smsIds, [id, '', '']);
|
assert.deepEqual(sent.smsIds, [id, undefined, undefined]);
|
||||||
|
assert.equal(sent.smsIds.length, sent.pduObjs.length, 'an entry per accepted segment, positional with pduObjs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports the segment that was named and merges nothing where the others were not', async t => {
|
||||||
|
const base = '01a09739-0a98-7ac0-8a9a-fcd3a7648e62';
|
||||||
|
const smsc = await dummySmsc(t, { messageIds: [`${base}-1`] });
|
||||||
|
const session = await bindToSmsc(t, smsc.port);
|
||||||
|
const merged: MessageDlr[] = [];
|
||||||
|
const reported = collect<Dlr>(1, push => { session.on('dlr', push); });
|
||||||
|
|
||||||
|
session.on('messageDlr', report => merged.push(report));
|
||||||
|
|
||||||
|
const sent = await session.sendSms({ dlr: true, message: 'x'.repeat(400), ...message });
|
||||||
|
|
||||||
|
assert.deepEqual(sent.smsIds, [`${base}-1`, undefined, undefined]);
|
||||||
|
smsc.deliver(receiptBody(`${base}-1`));
|
||||||
|
|
||||||
|
const [dlr] = await reported;
|
||||||
|
|
||||||
|
assert.equal(dlr?.smsId, `${base}-1`);
|
||||||
|
assert.deepEqual(merged, [], 'a send with an unnamed segment spells out no message to merge');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('an SMSC that answers a submit with no message id at all', () => {
|
||||||
|
test('reads an empty message_id as no id, on a single-segment send as much as a split one', async t => {
|
||||||
|
const smsc = await dummySmsc(t, { messageIds: [] });
|
||||||
|
const session = await bindToSmsc(t, smsc.port);
|
||||||
|
const sent = await session.sendSms({ dlr: true, message: 'one segment', ...message });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.deepEqual(sent.smsIds, [undefined]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -130,7 +130,8 @@ describe('parsing real PDUs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('encoding submit_sm', () => {
|
describe('encoding submit_sm', () => {
|
||||||
test('produces the same bytes as 0.4.0 for a GSM message', () => {
|
// 0.4.0 declared IA5 for the GSM octets it wrote; that one field is the whole difference.
|
||||||
|
test('produces the 0.4.0 bytes for a GSM message, under the alphabet those octets are in', () => {
|
||||||
const pdu = encode({
|
const pdu = encode({
|
||||||
cmdName: 'submit_sm',
|
cmdName: 'submit_sm',
|
||||||
cmdStatus: 'ESME_ROK',
|
cmdStatus: 'ESME_ROK',
|
||||||
@@ -144,7 +145,7 @@ describe('encoding submit_sm', () => {
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
pdu.toString('hex'),
|
pdu.toString('hex'),
|
||||||
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000001000b48656c6c6f20776f726c64',
|
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000000000b48656c6c6f20776f726c64',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2872,6 +2872,7 @@ describe('message id notation', () => {
|
|||||||
assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`));
|
assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`));
|
||||||
|
|
||||||
for (const smsId of sent.smsIds) {
|
for (const smsId of sent.smsIds) {
|
||||||
|
assert.ok(smsId);
|
||||||
await sendReceipt(peerOf(smpp), smsId);
|
await sendReceipt(peerOf(smpp), smsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2498,11 +2498,18 @@ describe('merged delivery report bounds', () => {
|
|||||||
test('arms nothing for a send whose ids do not number one message', () => {
|
test('arms nothing for a send whose ids do not number one message', () => {
|
||||||
const dlrMerger = merger();
|
const dlrMerger = merger();
|
||||||
|
|
||||||
dlrMerger.expect(['5cb0ea53b5d61093529174ca44e23871', '', '']);
|
dlrMerger.expect(['5cb0ea53b5d61093529174ca44e23871', undefined, undefined]);
|
||||||
assert.equal(dlrMerger.size, 0, 'an id the peer never named numbers nothing');
|
assert.equal(dlrMerger.size, 0, 'an id the peer never named numbers nothing');
|
||||||
|
|
||||||
dlrMerger.expect(['bf53ad8b-1', '40ccdce2-2']);
|
dlrMerger.expect(['bf53ad8b-1', '40ccdce2-2']);
|
||||||
assert.equal(dlrMerger.size, 0, 'nor do ids numbered off a base each');
|
assert.equal(dlrMerger.size, 0, 'nor do ids numbered off a base each');
|
||||||
|
|
||||||
|
const holed = '01a09739-0a9a-767d-ad1d-1c27667e1ca4';
|
||||||
|
|
||||||
|
// Dropping the unnamed entry rather than passing it through leaves 1 and 3 spelling out a
|
||||||
|
// whole 2-part message, and a send of 3 reports as delivered on two receipts.
|
||||||
|
dlrMerger.expect([`${holed}-1`, undefined, `${holed}-3`]);
|
||||||
|
assert.equal(dlrMerger.size, 0, 'nor does a hole between two parts of one base');
|
||||||
});
|
});
|
||||||
|
|
||||||
// A receipt for whole-3 would otherwise fill the slot whole-2 was registered for, truncating the report.
|
// A receipt for whole-3 would otherwise fill the slot whole-2 was registered for, truncating the report.
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import type { PduObjectInput } from '../src/pdu.ts';
|
||||||
|
import type { SendSmsDeps, SendSmsInput } from '../src/send-sms.ts';
|
||||||
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
|
import { decodeMessage } from '../src/message.ts';
|
||||||
|
import { messageOctets } from '../src/message-body.ts';
|
||||||
|
import { objToPdu, pduToObj } from '../src/pdu.ts';
|
||||||
|
import { paramNumber, paramText } from '../src/defs/types.ts';
|
||||||
|
import { silentLog } from '../src/log.ts';
|
||||||
|
import { submitSms } from '../src/send-sms.ts';
|
||||||
|
|
||||||
|
const from = '46701113311';
|
||||||
|
const to = '46709771337';
|
||||||
|
|
||||||
|
/** A send that records what it is handed, so a refusal shows up as an empty attempt log. */
|
||||||
|
function recordingDeps(attempts: PduObjectInput[]): SendSmsDeps {
|
||||||
|
return {
|
||||||
|
log: silentLog,
|
||||||
|
reference: 1,
|
||||||
|
send: input => {
|
||||||
|
attempts.push(input);
|
||||||
|
|
||||||
|
return Promise.resolve({ err: new Error('the recording peer never answers') });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alphabet each submit_sm declared, beside the text those octets read back as. */
|
||||||
|
function sentAs(octets: Buffer[]): [number, string][] {
|
||||||
|
return octets.map(pdu => {
|
||||||
|
const { pduObj } = pduToObj(pdu);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
const dataCoding = paramNumber(pduObj.params.data_coding, 0);
|
||||||
|
|
||||||
|
return [dataCoding, decodeMessage(pduObj.shortMessageOctets ?? Buffer.alloc(0), dataCoding).message];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('an alphabet the caller named that cannot carry the message', () => {
|
||||||
|
// Latin-1 takes the low octet of every code point, so あ (U+3042) went out as 0x42 — the letter B.
|
||||||
|
test('refuses a Latin-1 send of a character above 0xFF, naming the character', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const sent = await submitSms(recordingDeps(attempts), { encoding: 'LATIN1', from, message: 'あいう', to });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error);
|
||||||
|
assert.match(sent.err.message, /LATIN1/);
|
||||||
|
assert.match(sent.err.message, /"あ"/);
|
||||||
|
assert.match(sent.err.message, /U\+3042/);
|
||||||
|
assert.deepEqual(sent.pduObjs, []);
|
||||||
|
assert.deepEqual(sent.smsIds, []);
|
||||||
|
assert.equal(sent.unanswered, 0);
|
||||||
|
assert.equal(attempts.length, 0, 'a message the named alphabet cannot carry puts nothing on the wire');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space.
|
||||||
|
test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error);
|
||||||
|
assert.match(sent.err.message, /ASCII/);
|
||||||
|
assert.match(sent.err.message, /"ï"/);
|
||||||
|
assert.match(sent.err.message, /U\+00EF/);
|
||||||
|
assert.match(sent.err.message, /index 6/);
|
||||||
|
assert.equal(attempts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never refuses UCS2, which carries every character there is', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const deps = recordingDeps(attempts);
|
||||||
|
|
||||||
|
for (const message of ['あいう', 'Åsa naïve', '😀 beyond the basic plane', '�']) {
|
||||||
|
const sent = await submitSms(deps, { encoding: 'UCS2', from, message, to });
|
||||||
|
|
||||||
|
assert.equal(sent.err?.message, 'the recording peer never answers', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(attempts.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends 8-bit binary named as Latin-1 unchanged, the case that codec is named for', async t => {
|
||||||
|
const messageId = '01a086cc-5ee9-759d-ba4c-96a9afd9aed9';
|
||||||
|
const smsc = await dummySmsc(t, { messageIds: [messageId] });
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
const payload = Buffer.from(Array.from({ length: 140 }, (_, at) => (at * 7 + 3) % 256));
|
||||||
|
const sent = await session.sendSms({ encoding: 'LATIN1', from, message: payload.toString('latin1'), to });
|
||||||
|
const first = smsc.octets[0];
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.deepEqual(sent.smsIds, [messageId]);
|
||||||
|
assert.equal(smsc.octets.length, 1);
|
||||||
|
assert.ok(first);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(first);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(paramNumber(pduObj.params.data_coding, 0), 0x03);
|
||||||
|
assert.deepEqual(pduObj.shortMessageOctets, payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks an alphabet that fits where the caller named none, so nothing is ever refused for one', async t => {
|
||||||
|
const smsc = await dummySmsc(t);
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
|
||||||
|
for (const message of ['Hello world', 'Åsa naïve', 'あいう']) {
|
||||||
|
assert.equal((await session.sendSms({ from, message, to })).err, undefined, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(sentAs(smsc.octets), [[0x00, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a body the PDU\'s own data_coding cannot carry', () => {
|
||||||
|
/** The octets a built PDU carries as its body, wherever the caller put them. */
|
||||||
|
function bodyOf(built: ReturnType<typeof objToPdu>): Buffer | undefined {
|
||||||
|
assert.equal(built.err, undefined);
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(built.buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
return messageOctets(pduObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latin-1 takes the low octet of every code point, so あ (U+3042) went out as 0x42 — the letter B.
|
||||||
|
test('refuses a string short_message the named alphabet cannot carry, naming the character', () => {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: { data_coding: 0x03, destination_addr: to, short_message: 'あいう', source_addr: from },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(built.err instanceof Error);
|
||||||
|
assert.equal(built.buffer, undefined);
|
||||||
|
assert.match(built.err.message, /short_message/);
|
||||||
|
assert.match(built.err.message, /LATIN1/);
|
||||||
|
assert.match(built.err.message, /"あ"/);
|
||||||
|
assert.match(built.err.message, /U\+3042/);
|
||||||
|
assert.match(built.err.message, /index 0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space.
|
||||||
|
test('refuses a string short_message GSM 03.38 has no code for, naming that one', () => {
|
||||||
|
for (const dataCoding of [0x00, 0x01]) {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: { data_coding: dataCoding, destination_addr: to, short_message: 'Åsa naïve', source_addr: from },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(built.err instanceof Error, String(dataCoding));
|
||||||
|
assert.match(built.err.message, /ASCII/);
|
||||||
|
assert.match(built.err.message, /"ï"/);
|
||||||
|
assert.match(built.err.message, /U\+00EF/);
|
||||||
|
assert.match(built.err.message, /index 6/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a string message_payload on the same terms as short_message', () => {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'data_sm',
|
||||||
|
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
|
||||||
|
tlvs: { message_payload: { tagValue: 'あいう' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(built.err instanceof Error);
|
||||||
|
assert.equal(built.buffer, undefined);
|
||||||
|
assert.match(built.err.message, /message_payload/);
|
||||||
|
assert.match(built.err.message, /LATIN1/);
|
||||||
|
assert.match(built.err.message, /"あ"/);
|
||||||
|
assert.match(built.err.message, /U\+3042/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses the body TLV under whatever name the caller keyed its tagId to', () => {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'data_sm',
|
||||||
|
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
|
||||||
|
tlvs: { body: { tagId: 0x0424, tagValue: 'あいう' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(built.err instanceof Error);
|
||||||
|
assert.equal(built.buffer, undefined);
|
||||||
|
assert.match(built.err.message, /"body"/);
|
||||||
|
assert.match(built.err.message, /LATIN1/);
|
||||||
|
assert.match(built.err.message, /U\+3042/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves data_coding to short_message wherever it carries octets, as messageOctets() reads it', () => {
|
||||||
|
const short = Buffer.from([0xDE, 0xAD]);
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: { destination_addr: to, short_message: short, source_addr: from },
|
||||||
|
tlvs: { message_payload: { tagValue: 'あいう' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(built.err, undefined);
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(built.buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(pduObj.params.data_coding, 0, 'the TLV must not name an alphabet for octets nothing reads it as');
|
||||||
|
assert.deepEqual(messageOctets(pduObj), short);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encodes every entry carrying the body tag, so a second one cannot go out truncated', () => {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'data_sm',
|
||||||
|
params: { data_coding: 0x08, destination_addr: to, source_addr: from },
|
||||||
|
tlvs: { alias: { tagId: 0x0424, tagValue: 'あいう' }, message_payload: { tagValue: 'あいう' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(built.err, undefined);
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const hex = built.buffer.toString('hex');
|
||||||
|
|
||||||
|
assert.equal(hex.split('304230443046').length - 1, 2, 'both entries carry the UCS2 octets');
|
||||||
|
assert.ok(!hex.includes('424446'), 'no entry goes out as the low octets of its code points');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves the alphabet to the body TLV wherever short_message carries no octets', () => {
|
||||||
|
for (const short of [undefined, '', Buffer.alloc(0)]) {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: {
|
||||||
|
destination_addr: to,
|
||||||
|
source_addr: from,
|
||||||
|
...(short === undefined ? {} : { short_message: short }),
|
||||||
|
},
|
||||||
|
tlvs: { message_payload: { tagValue: 'あいう' } },
|
||||||
|
});
|
||||||
|
const name = short === undefined ? 'absent' : JSON.stringify(short);
|
||||||
|
|
||||||
|
assert.equal(built.err, undefined, name);
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(built.buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(pduObj.params.data_coding, 0x08, name);
|
||||||
|
assert.equal(messageOctets(pduObj)?.toString('hex'), '304230443046', name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a short_message on a command that declares none, which the wire never carries', () => {
|
||||||
|
for (const short of ['x', Buffer.from([0xDE, 0xAD])]) {
|
||||||
|
const input: PduObjectInput = {
|
||||||
|
cmdName: 'data_sm',
|
||||||
|
params: { destination_addr: to, short_message: short, source_addr: from },
|
||||||
|
tlvs: { message_payload: { tagValue: 'あいう' } },
|
||||||
|
};
|
||||||
|
const built = objToPdu(input);
|
||||||
|
|
||||||
|
assert.equal(built.err, undefined);
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(built.buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(pduObj.params.data_coding, 0x08, JSON.stringify(short));
|
||||||
|
assert.equal(messageOctets(pduObj)?.toString('hex'), '304230443046');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves data_coding at its default where an empty short_message is the whole body', () => {
|
||||||
|
for (const short of ['', Buffer.alloc(0)]) {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: { destination_addr: to, short_message: short, source_addr: from },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(built.err, undefined);
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(built.buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(pduObj.params.data_coding, 0, JSON.stringify(short));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never refuses a Buffer body, whatever the coding, because that is how binary is sent', () => {
|
||||||
|
const payload = Buffer.from([0x30, 0x42, 0xC3, 0x28, 0xFF, 0x00]);
|
||||||
|
|
||||||
|
for (const dataCoding of [0x00, 0x01, 0x03, 0x04, 0x08, 0xF0]) {
|
||||||
|
const params = { data_coding: dataCoding, destination_addr: to, source_addr: from };
|
||||||
|
const short = objToPdu({ cmdName: 'submit_sm', params: { ...params, short_message: payload } });
|
||||||
|
const carried = objToPdu({
|
||||||
|
cmdName: 'data_sm',
|
||||||
|
params,
|
||||||
|
tlvs: { message_payload: { tagValue: payload } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(bodyOf(short), payload, `short_message under data_coding ${String(dataCoding)}`);
|
||||||
|
assert.deepEqual(bodyOf(carried), payload, `message_payload under data_coding ${String(dataCoding)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never refuses a string the caller named no data_coding for, since detection always fits', () => {
|
||||||
|
for (const message of ['Hello world', 'Åsa naïve', 'あいう', '😀 beyond the basic plane']) {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: { destination_addr: to, short_message: message, source_addr: from },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(built.err, undefined, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes a body the coding does carry as the octets that alphabet spells it in', () => {
|
||||||
|
const bodies: [number, string, string][] = [
|
||||||
|
[0x00, 'Hello world', '48656c6c6f20776f726c64'],
|
||||||
|
[0x03, 'Räksmörgås', '52e46b736df67267e573'],
|
||||||
|
[0x08, 'あいう', '304230443046'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dataCoding, message, hex] of bodies) {
|
||||||
|
const params = { data_coding: dataCoding, destination_addr: to, source_addr: from };
|
||||||
|
const short = objToPdu({ cmdName: 'submit_sm', params: { ...params, short_message: message } });
|
||||||
|
const carried = objToPdu({
|
||||||
|
cmdName: 'data_sm',
|
||||||
|
params,
|
||||||
|
tlvs: { message_payload: { tagValue: message } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(bodyOf(short)?.toString('hex'), hex, `short_message: ${message}`);
|
||||||
|
assert.equal(bodyOf(carried)?.toString('hex'), hex, `message_payload: ${message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses the same body through session.send(), with nothing reaching the socket', async t => {
|
||||||
|
const smsc = await dummySmsc(t);
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: { data_coding: 0x03, destination_addr: to, short_message: 'あいう', source_addr: from },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error);
|
||||||
|
assert.match(sent.err.message, /LATIN1/);
|
||||||
|
assert.deepEqual(smsc.octets, [], 'a body the named alphabet cannot carry never reaches the socket');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a time no peer can read', () => {
|
||||||
|
const invalid = new Date('nope');
|
||||||
|
|
||||||
|
test('refuses an invalid Date rather than writing NaN into the PDU, naming the option', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const deps = recordingDeps(attempts);
|
||||||
|
const sends: [string, SendSmsInput][] = [
|
||||||
|
['scheduleDeliveryTime', { from, message: 'Hello world', scheduleDeliveryTime: invalid, to }],
|
||||||
|
['validityPeriod', { from, message: 'Hello world', to, validityPeriod: invalid }],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [option, send] of sends) {
|
||||||
|
const sent = await submitSms(deps, send);
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error, option);
|
||||||
|
assert.match(sent.err.message, new RegExp(option));
|
||||||
|
assert.match(sent.err.message, /invalid Date/);
|
||||||
|
assert.deepEqual(sent.smsIds, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(attempts.length, 0, 'a time nobody can read puts nothing on the wire');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a relative period naming no number of seconds', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const deps = recordingDeps(attempts);
|
||||||
|
|
||||||
|
for (const validityPeriod of [NaN, Infinity, -Infinity]) {
|
||||||
|
const sent = await submitSms(deps, { from, message: 'Hello world', to, validityPeriod });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error, String(validityPeriod));
|
||||||
|
assert.match(sent.err.message, /validityPeriod/);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(attempts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a year-long relative period rather than sending 99 days and reporting a year', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const sent = await submitSms(recordingDeps(attempts), { from, message: 'Hello world', to, validityPeriod: 86400 * 365 });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error);
|
||||||
|
assert.match(sent.err.message, /validityPeriod/);
|
||||||
|
assert.match(sent.err.message, /Date/);
|
||||||
|
assert.deepEqual(sent.smsIds, []);
|
||||||
|
assert.equal(attempts.length, 0, 'a period the relative format cannot hold puts nothing on the wire');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a time that is no kind of time rather than throwing out of the send', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const deps = recordingDeps(attempts);
|
||||||
|
|
||||||
|
for (const validityPeriod of [null, {}, true, []]) {
|
||||||
|
const sent = await submitSms(deps, { from, message: 'Hello world', to, validityPeriod });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error, JSON.stringify(validityPeriod));
|
||||||
|
assert.match(sent.err.message, /validityPeriod must be a Date/);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(attempts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes the times it can express into every segment it built them for', async t => {
|
||||||
|
const smsc = await dummySmsc(t);
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
const sent = await session.sendSms({
|
||||||
|
from,
|
||||||
|
message: 'A message long enough to need a header numbering its segments. '.repeat(4),
|
||||||
|
scheduleDeliveryTime: new Date(Date.UTC(2026, 8, 9, 14, 30, 0)),
|
||||||
|
to,
|
||||||
|
validityPeriod: 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.equal(smsc.octets.length, 2, 'the fixture must need more than one segment');
|
||||||
|
|
||||||
|
for (const octets of smsc.octets) {
|
||||||
|
const { pduObj } = pduToObj(octets);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(paramText(pduObj.params.schedule_delivery_time), '260909143000000+');
|
||||||
|
assert.equal(paramText(pduObj.params.validity_period), '000000010000000R');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
# todo.md
|
# todo.md
|
||||||
|
|
||||||
Remaining work for the `@larvit/smpp` 1.0.0 rewrite. Read [AGENTS.md](AGENTS.md) first — the goals
|
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules
|
||||||
and hard rules there constrain every item below.
|
there constrain every item below.
|
||||||
|
|
||||||
This is a temporary working file: it is deleted when 1.0.0 ships, and until then it sets its own
|
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
|
||||||
rules. The documentation conventions in AGENTS.md do not govern it, and nothing here is a source
|
govern it, and nothing here is a source anything else may cite.
|
||||||
anything else may cite.
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18,
|
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
|
||||||
20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
|
to 26. What is left is release work and a few things worth adding before or after 1.0.0.
|
||||||
|
|
||||||
## The agreed API
|
## The agreed API
|
||||||
|
|
||||||
@@ -68,64 +67,15 @@ Rules the API follows:
|
|||||||
| A transient state sent under the marker the spec gives it, off the same list the reader uses | `test/session-extras.test.ts` |
|
| A transient state sent under the marker the spec gives it, off the same list the reader uses | `test/session-extras.test.ts` |
|
||||||
| A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` |
|
| A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` |
|
||||||
| Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` |
|
| Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` |
|
||||||
| CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` |
|
| CI on Node 18 to 26, Renovate, tag-triggered publish | `.github/workflows/` |
|
||||||
|
|
||||||
Every defect listed in the AGENTS.md table has a regression test naming the behaviour.
|
Every defect listed in the AGENTS.md table has a regression test naming the behaviour.
|
||||||
|
|
||||||
## The GitHub backlog, once this branch is `main`
|
|
||||||
|
|
||||||
Nothing below is closed while the default branch is still 0.4.0 — declining a security bump on a live
|
|
||||||
default branch is worse than leaving it open. Work through this immediately after the rename.
|
|
||||||
|
|
||||||
**Close as fixed by 1.0.0**, naming the replacement in the comment:
|
|
||||||
|
|
||||||
| | Fixed by |
|
|
||||||
| --- | --- |
|
|
||||||
| [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing | `dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent |
|
|
||||||
| [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw `Buffer` segments | `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms` event |
|
|
||||||
| [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages | `test/session.test.ts` |
|
|
||||||
| [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency versions | The mocha suite is gone; `node:test` on Node 18/20/22/24 |
|
|
||||||
| [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples | `test/readme.test.ts` |
|
|
||||||
| [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be settable | `sendSms()` takes all four, documented and tested |
|
|
||||||
| [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types | Bound and enforced in both directions |
|
|
||||||
| [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments | The `maxSegments` send option |
|
|
||||||
| [#68](https://github.com/larvit/larvitsmpp/pull/68) `message_id` in `submit_sm_resp`, spec DLR codes | All four hold: `sendResp()` always answers a `message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real defects. |
|
|
||||||
|
|
||||||
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
|
|
||||||
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
|
|
||||||
[#40](https://github.com/larvit/larvitsmpp/pull/40), [#41](https://github.com/larvit/larvitsmpp/pull/41),
|
|
||||||
[#42](https://github.com/larvit/larvitsmpp/pull/42), [#45](https://github.com/larvit/larvitsmpp/pull/45),
|
|
||||||
[#46](https://github.com/larvit/larvitsmpp/pull/46), [#47](https://github.com/larvit/larvitsmpp/pull/47),
|
|
||||||
[#59](https://github.com/larvit/larvitsmpp/pull/59), [#63](https://github.com/larvit/larvitsmpp/pull/63),
|
|
||||||
[#64](https://github.com/larvit/larvitsmpp/pull/64), [#65](https://github.com/larvit/larvitsmpp/pull/65),
|
|
||||||
[#67](https://github.com/larvit/larvitsmpp/pull/67), [#70](https://github.com/larvit/larvitsmpp/pull/70).
|
|
||||||
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports on the
|
|
||||||
default branch; it disappears with the runtime dependencies rather than being fixed.
|
|
||||||
|
|
||||||
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — leave it, it
|
|
||||||
re-baselines itself against the new `package.json`.
|
|
||||||
|
|
||||||
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and
|
|
||||||
port on log messages. Only `server - incoming connection` carries them today; putting them on every
|
|
||||||
session message is a change to every call site.
|
|
||||||
|
|
||||||
## Before publishing 1.0.0
|
## Before publishing 1.0.0
|
||||||
|
|
||||||
- [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which
|
- [x] `NPM_TOKEN`, which `.github/workflows/release.yaml` needs, is set as an organization secret.
|
||||||
`.github/workflows/release.yaml` needs.
|
- [ ] Tag `v1.0.0` to publish. The first publish creates `@larvit/smpp` on npm, provided the token
|
||||||
- [ ] Decide whether `smsIds` should be `(string | undefined)[]`. An SMSC that names an id for the
|
can publish under `@larvit`.
|
||||||
first segment of a concatenated message only — Telesign documents exactly that — leaves the
|
|
||||||
rest empty, so `string[]` promises what the value does not keep: taking the first id, or
|
|
||||||
building a map from the array, compiles and then misbehaves, and an empty string collides with
|
|
||||||
another message's in a correlation table. The cost is that every consumer narrows, including
|
|
||||||
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
|
|
||||||
**This one is 1.0.0-or-never** — after release it needs a major version.
|
|
||||||
- [ ] **The interop suite still asserts the defect [#95](https://github.com/larvit/larvitsmpp/pull/95)
|
|
||||||
fixed.** `interop-tests/smppsim.test.ts:621`, `a raw submit_sm with data_coding 0xF0 is not
|
|
||||||
read as flash`, fails on the next `./interop-tests/run.py smppsim`; nothing in CI runs that
|
|
||||||
suite, so it fails silently until someone does. The test name and its assertion both need
|
|
||||||
inverting, and the C17 row in `findings/02-smppsim.md` names that sub-test, so it follows.
|
|
||||||
- [ ] Tag `v1.0.0` to publish.
|
|
||||||
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
||||||
something CI should do.
|
something CI should do.
|
||||||
- [ ] **Rename the branches, once everything above is done.** Maintainer's call, 2026-09-04:
|
- [ ] **Rename the branches, once everything above is done.** Maintainer's call, 2026-09-04:
|
||||||
@@ -139,26 +89,111 @@ session message is a change to every call site.
|
|||||||
- [ ] Delete `rewrite-base` once [#71](https://github.com/larvit/larvitsmpp/pull/71) is
|
- [ ] Delete `rewrite-base` once [#71](https://github.com/larvit/larvitsmpp/pull/71) is
|
||||||
resolved; it exists only to give that PR a reviewable diff.
|
resolved; it exists only to give that PR a reviewable diff.
|
||||||
|
|
||||||
|
## Close the GitHub backlog, once this branch is `main`
|
||||||
|
|
||||||
|
Nothing below is closed while the default branch is still 0.4.0 — declining a security bump on a
|
||||||
|
live default branch is worse than leaving it open. Work through this immediately after the rename.
|
||||||
|
|
||||||
|
**Answer and close as fixed by 1.0.0**, the reply naming what fixed it:
|
||||||
|
|
||||||
|
- [ ] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
|
||||||
|
`test/readme.test.ts`.
|
||||||
|
- [ ] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
|
||||||
|
`test/session.test.ts`.
|
||||||
|
- [ ] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
|
||||||
|
`dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent.
|
||||||
|
- [ ] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
|
||||||
|
`maxSegments` send option.
|
||||||
|
- [ ] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
|
||||||
|
enforced in both directions.
|
||||||
|
- [ ] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
|
||||||
|
settable: `sendSms()` takes all four, documented and tested.
|
||||||
|
- [ ] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
|
||||||
|
versions: the mocha suite is gone; `node:test` on Node 18 to 26.
|
||||||
|
- [ ] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
|
||||||
|
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
|
||||||
|
event.
|
||||||
|
- [ ] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
|
||||||
|
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
|
||||||
|
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
|
||||||
|
defects.
|
||||||
|
|
||||||
|
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
|
||||||
|
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
|
||||||
|
|
||||||
|
- [ ] [#40](https://github.com/larvit/larvitsmpp/pull/40),
|
||||||
|
[#41](https://github.com/larvit/larvitsmpp/pull/41),
|
||||||
|
[#42](https://github.com/larvit/larvitsmpp/pull/42),
|
||||||
|
[#45](https://github.com/larvit/larvitsmpp/pull/45),
|
||||||
|
[#46](https://github.com/larvit/larvitsmpp/pull/46),
|
||||||
|
[#47](https://github.com/larvit/larvitsmpp/pull/47),
|
||||||
|
[#59](https://github.com/larvit/larvitsmpp/pull/59),
|
||||||
|
[#63](https://github.com/larvit/larvitsmpp/pull/63),
|
||||||
|
[#64](https://github.com/larvit/larvitsmpp/pull/64),
|
||||||
|
[#67](https://github.com/larvit/larvitsmpp/pull/67),
|
||||||
|
[#70](https://github.com/larvit/larvitsmpp/pull/70),
|
||||||
|
[#77](https://github.com/larvit/larvitsmpp/pull/77).
|
||||||
|
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports
|
||||||
|
on the default branch; it disappears with the runtime dependencies rather than being fixed.
|
||||||
|
|
||||||
|
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — leave it, it
|
||||||
|
re-baselines itself against the new `package.json`.
|
||||||
|
|
||||||
|
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and
|
||||||
|
port on log messages. Only `server - incoming connection` carries them today; putting them on every
|
||||||
|
session message is a change to every call site.
|
||||||
|
|
||||||
|
## Move the repository to Gitea
|
||||||
|
|
||||||
|
`gitea.larvit.se/larvit/smpp-js` becomes the repository and `github.com/larvit/smpp-js` a push mirror
|
||||||
|
of it. Maintainer's call, 2026-09-13. `@larvit/adf-codec` already runs from Gitea, without a mirror.
|
||||||
|
|
||||||
|
Decide first:
|
||||||
|
|
||||||
|
- [ ] **Before or after 1.0.0.** Before: 1.0.0's `repository`, `homepage` and `bugs` name the final
|
||||||
|
home. After: 1.0.0 ships now from the pipeline that is already green, and the renamed GitHub
|
||||||
|
repository redirects the links it carries.
|
||||||
|
- [ ] **Provenance.** npm generates it only on GitHub Actions and GitLab CI/CD. Publishing from Gitea
|
||||||
|
drops it, as adf-codec's publishing does; publishing from the mirror keeps it, but makes every
|
||||||
|
release depend on the mirror being in sync.
|
||||||
|
- [ ] **Where issues and pull requests are filed**, Gitea or the GitHub mirror. adf-codec has Gitea
|
||||||
|
issues switched off.
|
||||||
|
- [ ] **Review bot.** CodeRabbit does not support Gitea.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
- [ ] Migrate `larvit/larvitsmpp` into `larvit/smpp-js` with Gitea's GitHub migration, as a regular
|
||||||
|
repository rather than a pull mirror.
|
||||||
|
- [ ] Fast-forward as the only merge style, as on adf-codec, plus what adf-codec lacks: a protection
|
||||||
|
rule on `main` requiring a pull request and a green CI status. That is what makes the commit on
|
||||||
|
`main` the commit CI tested; GitHub's merge buttons always write a new commit.
|
||||||
|
- [ ] Rename the GitHub repository to `larvit/smpp-js` and add it to Gitea as a push mirror.
|
||||||
|
- [ ] Move `.github/workflows/` to `.gitea/workflows/` on the `docker-host` runner, with Renovate as a
|
||||||
|
scheduled workflow like adf-codec's. Gitea reads `.gitea/workflows/` and falls back to
|
||||||
|
`.github/workflows/`; left where they are, the mirror runs every workflow a second time on
|
||||||
|
GitHub, the release included.
|
||||||
|
- [ ] Point `package.json`, the README badges and links, AGENTS.md and MIGRATION.md at Gitea, and change
|
||||||
|
`interop-tests/AGENTS.md` step 3 from squash-merge to fast-forward.
|
||||||
|
- [ ] Check how npmjs.com renders the README's relative links (`MIGRATION.md`,
|
||||||
|
`interop-tests/README.md`) for a Gitea `repository`; for a GitHub one it resolves them against the
|
||||||
|
default branch.
|
||||||
|
|
||||||
## Worth doing, not blocking
|
## Worth doing, not blocking
|
||||||
|
|
||||||
- [ ] **A named alphabet that cannot hold the message corrupts it instead of refusing it.**
|
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
|
||||||
`encodeMessage('あいう', 'LATIN1')` returns `42 44 46` — `"BDF"` — and `sendSms()` puts that on
|
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
|
||||||
the wire: `checkOptions()` refuses an unknown name, `FLASH` and flash-beside-Latin-1, but never
|
that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
|
||||||
asks whether the alphabet the caller named can carry the text. GSM 7-bit does the same, mapping
|
every attempt is the same case, and #98 made it a common one. On a down link the caller waits
|
||||||
anything outside 03.38 to a space. `Encoding.match()` cannot be the guard — `latin1.match()` is
|
`responseTimeout` and is told the link failed rather than that the body could not be built —
|
||||||
hardcoded `false` because it doubles as the auto-selection policy `detect()` reads, so "can hold
|
goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is
|
||||||
this" and "should be picked for this" would have to be separated first. Send-side, so the tag
|
the open half. Raised by the architecture review of
|
||||||
is not blocked on it. Raised by the architecture review of
|
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
||||||
[#96](https://github.com/larvit/larvitsmpp/pull/96), 2026-09-09.
|
|
||||||
|
|
||||||
- [ ] **A time nobody can read goes out as `NaN` rather than being refused.**
|
- [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`,
|
||||||
`smppTime.encode(new Date('nope'))` returns `NaNNaNNaNNaNNaNNaNNaN00+` and
|
`smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four
|
||||||
`smppTime.encode(NaN)` returns `0000NaNNaNNaNNaN000R`; `sendSms({ validityPeriod })` and
|
concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is
|
||||||
`scheduleDeliveryTime` write either straight into the PDU with no check at
|
the split. Raised by the architecture review of
|
||||||
`submitSmParams()`. Goal 2, since the peer reads a field that means nothing. `Date` is not the
|
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
||||||
closed domain hard rule 1's totality clause covers, so this one wants a guard at the send
|
|
||||||
boundary rather than a `Result` on `smppTime`. Raised by the stability review of
|
|
||||||
[#96](https://github.com/larvit/larvitsmpp/pull/96), 2026-09-09.
|
|
||||||
|
|
||||||
- [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
|
- [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
|
||||||
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml`
|
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml`
|
||||||
|
|||||||
Reference in New Issue
Block a user