Compare commits
15 Commits
cced03767e
..
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f14ca85c2 | |||
| e8558aa471 | |||
| 773db43305 | |||
| 793839d0e0 | |||
| 68cd8e3f31 | |||
| 8315f1882a | |||
| 311ee7c677 | |||
| e3062066c1 | |||
| 7310634d7d | |||
| 549b3260c9 | |||
| 13e3ff8f1f | |||
| ca1a7473ed | |||
| 989eb01763 | |||
| ef13290230 | |||
| c06cc0648b |
@@ -6,7 +6,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -18,7 +17,7 @@ jobs:
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version: 24
|
||||
node-version: 24.18.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: npm ci
|
||||
- name: The tag must match the version being published
|
||||
@@ -31,6 +30,6 @@ jobs:
|
||||
}
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm publish --provenance --access public
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '43 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- name: Run Renovate against this repo
|
||||
env:
|
||||
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e GITHUB_COM_TOKEN \
|
||||
-e LOG_LEVEL=info \
|
||||
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
|
||||
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.39.2
|
||||
@@ -1,8 +1,6 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: ['main']
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
@@ -19,7 +17,7 @@ jobs:
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version: 24
|
||||
node-version: 24.18.0
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build
|
||||
@@ -5,9 +5,9 @@ Guidance for LLM agents working in this repository. What each file in it is for
|
||||
|
||||
## What this is
|
||||
|
||||
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` 0.5.0. The branch
|
||||
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.
|
||||
|
||||
## Goals
|
||||
@@ -109,7 +109,7 @@ src/
|
||||
constants.ts consts + constsById, and the SMPP version constants
|
||||
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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`
|
||||
objects plus union types.
|
||||
- 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 7 once that constraint lifts.
|
||||
|
||||
## Defects found in 0.4.0
|
||||
|
||||
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
|
||||
the only place that does. Confirmed by reading the 0.4.0 source; each row has a regression test
|
||||
naming the behaviour.
|
||||
[MIGRATION.md](MIGRATION.md) names what changed for a consumer, and is the only place that does.
|
||||
Confirmed by reading the 0.4.0 source; each row has a regression test naming the 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 |
|
||||
| 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`) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
that type fails to compile in both copies at once.
|
||||
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`,
|
||||
`message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing,
|
||||
and a field added to that type fails to compile in every copy at once.
|
||||
- `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
|
||||
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
|
||||
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
|
||||
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
|
||||
answer. The toolchain commands are the one deliberate exception — README's copy serves a contributor
|
||||
@@ -316,7 +319,34 @@ 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 —
|
||||
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
|
||||
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 breaking release, 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
|
||||
|
||||
@@ -586,6 +616,121 @@ Grouped by what each one constrains.
|
||||
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.
|
||||
|
||||
- **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
|
||||
|
||||
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
||||
@@ -827,6 +972,7 @@ Grouped by what each one constrains.
|
||||
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.
|
||||
|
||||
|
||||
### Internals and tests
|
||||
|
||||
- **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` 0.5.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
|
||||
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.
|
||||
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
|
||||
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
|
||||
ready, squash-merge it.
|
||||
4. Back in the experiments worktree: fast-forward `typescript`, rerun the experiment that found the
|
||||
3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
|
||||
ready, fast-forward it.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
`sendSms()` returned and merges them itself.
|
||||
- **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
|
||||
where the SMSC named nothing. `dlr.smsId` is never empty, so an empty entry matches no receipt,
|
||||
and no merge is armed.
|
||||
`sendSms()` returns `['<id>', undefined, undefined]` — one entry per segment, positional with
|
||||
`pduObjs`, `undefined` where the SMSC named nothing; it returned `''` there until #101.
|
||||
`dlr.smsId` is never empty, so an unnamed entry matches no receipt, and no merge is armed.
|
||||
|
||||
## 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
|
||||
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
|
||||
right shape for a caller, or whether that case wants saying differently. It is documented in
|
||||
`README.md` and pinned by the Telesign scenario; the question is a product one, not a correctness
|
||||
one, and belongs to the phase 11 product-owner pass.
|
||||
right shape for a caller. Settled in #101: the entry is `undefined`, and the decision with its
|
||||
rejected alternatives is recorded in `AGENTS.md`.
|
||||
|
||||
@@ -476,6 +476,7 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency
|
||||
assert.equal(sent.smsIds.length, testCase.expectedSegments);
|
||||
|
||||
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');
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 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`.
|
||||
* 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);
|
||||
|
||||
const ids = namedIds(sent.smsIds);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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`);
|
||||
@@ -149,14 +160,16 @@ async function sendUntilComplete(
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
|
||||
const ids = namedIds(sent.smsIds);
|
||||
|
||||
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);
|
||||
|
||||
return allIntact && reassembled ? { reassembled } : undefined;
|
||||
}, 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`);
|
||||
@@ -276,7 +289,7 @@ describe('smppsim-transition - C4 intermediate then final', () => {
|
||||
const sent = await session.send({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
data_coding: consts.ENCODING.ASCII,
|
||||
data_coding: 0,
|
||||
destination_addr: TO,
|
||||
registered_delivery: 0x11,
|
||||
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);
|
||||
|
||||
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(() => (
|
||||
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');
|
||||
|
||||
return sent.smsIds;
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('smscsim - C1 bind, keepalive, unbind', () => {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larvit/smpp",
|
||||
"version": "1.0.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larvit/smpp",
|
||||
"version": "1.0.0",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
|
||||
+7
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@larvit/smpp",
|
||||
"version": "1.0.0",
|
||||
"description": "Simplified SMPP implementation",
|
||||
"version": "0.5.0",
|
||||
"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": [
|
||||
"esm",
|
||||
"pdu",
|
||||
@@ -9,19 +9,19 @@
|
||||
"smpp",
|
||||
"typescript"
|
||||
],
|
||||
"homepage": "https://github.com/larvit/larvitsmpp",
|
||||
"homepage": "https://gitea.larvit.se/larvit/smpp-js",
|
||||
"bugs": {
|
||||
"url": "https://github.com/larvit/larvitsmpp/issues"
|
||||
"url": "https://github.com/larvit/smpp-js/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/larvit/larvitsmpp.git"
|
||||
"url": "git+https://gitea.larvit.se/larvit/smpp-js.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Mikael 'Lilleman' Göransson",
|
||||
"email": "lilleman@larvit.se",
|
||||
"url": "http://github.com/larvit/larvitsmpp"
|
||||
"url": "https://gitea.larvit.se/larvit/smpp-js"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -37,8 +37,7 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
|
||||
@@ -22,7 +22,6 @@ export const consts = {
|
||||
YEARS: 0x0E,
|
||||
},
|
||||
ENCODING: {
|
||||
ASCII: 0x01,
|
||||
BINARY: 0x04,
|
||||
CYRILLIC: 0x06,
|
||||
EXTENDED_KANJI_JIS: 0x0D,
|
||||
|
||||
@@ -121,6 +121,29 @@ export function detect(value: string): EncodingName {
|
||||
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
|
||||
* 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.
|
||||
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 { Result } from '../result.ts';
|
||||
import { tlv } from './types.ts';
|
||||
|
||||
export type TlvDefinition = {
|
||||
@@ -104,3 +105,60 @@ export type Tlv = {
|
||||
tagName: string | undefined;
|
||||
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. */
|
||||
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 parts = new Set<number>();
|
||||
|
||||
for (const smsId of smsIds) {
|
||||
const numbering = parseSegmentId(smsId);
|
||||
const numbering = smsId === undefined ? undefined : parseSegmentId(smsId);
|
||||
|
||||
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. */
|
||||
expect(smsIds: string[]): void {
|
||||
expect(smsIds: (string | undefined)[]): void {
|
||||
if (smsIds.length < 2) return;
|
||||
|
||||
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 { 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 { tlvs, tlvsById } from './defs/tlvs.ts';
|
||||
export { types } from './defs/types.ts';
|
||||
@@ -62,7 +62,7 @@ export type {
|
||||
} from './session.ts';
|
||||
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.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 { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
export type { PduHeader } from './pdu-refusal.ts';
|
||||
|
||||
+53
-16
@@ -1,6 +1,6 @@
|
||||
import type { Result } from './result.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 { udhLength } from './udh.ts';
|
||||
|
||||
@@ -27,6 +27,25 @@ export function encodeMessage(
|
||||
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(
|
||||
buffer: Buffer,
|
||||
dataCoding: number,
|
||||
@@ -113,39 +132,57 @@ export function smppDate(date: Date): string {
|
||||
+ 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 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. */
|
||||
export const smppTime = {
|
||||
/**
|
||||
* A Date becomes an absolute UTC time; a number is a relative period in seconds. Relative
|
||||
* periods are expressed in days and below, so anything past 99 days is clamped to that.
|
||||
* A Date becomes an absolute UTC time; a number is a relative period in seconds, expressed in
|
||||
* 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 {
|
||||
if (typeof value === 'string') return value;
|
||||
encode(value: Date | number | string): Result<{ text: string }> {
|
||||
if (typeof value === 'string') return { text: value };
|
||||
|
||||
if (typeof value === 'number') {
|
||||
const total = Math.max(0, Math.floor(value));
|
||||
const days = Math.min(99, Math.floor(total / 86400));
|
||||
const capped = days === 99 ? 99 * 86400 + 86399 : total;
|
||||
if (!Number.isFinite(value)) return { err: new Error(`Not an SMPP time: ${String(value)}`) };
|
||||
|
||||
return '0000'
|
||||
+ pad(Math.floor(capped / 86400), 2)
|
||||
+ pad(Math.floor(capped / 3600) % 24, 2)
|
||||
+ pad(Math.floor(capped / 60) % 60, 2)
|
||||
+ pad(capped % 60, 2)
|
||||
+ '000R';
|
||||
const seconds = Math.floor(value);
|
||||
|
||||
if (seconds < 0) {
|
||||
return { err: new Error(`A relative period cannot be negative, got ${String(value)}`) };
|
||||
}
|
||||
|
||||
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.getUTCDate(), 2)
|
||||
+ pad(value.getUTCHours(), 2)
|
||||
+ pad(value.getUTCMinutes(), 2)
|
||||
+ pad(value.getUTCSeconds(), 2)
|
||||
+ pad(Math.floor(value.getUTCMilliseconds() / 100), 1)
|
||||
+ '00+';
|
||||
+ '00+',
|
||||
};
|
||||
},
|
||||
|
||||
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 { PduHeader } from './pdu-refusal.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 { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
||||
import { consts, hasUdh } from './defs/constants.ts';
|
||||
import { decodeMessage, encodeMessage } from './message.ts';
|
||||
import { detect, encodingByDataCoding } from './defs/encodings.ts';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { decodeMessage, encodeBody } from './message.ts';
|
||||
import { errorNameById, errors, isErrorName } from './defs/errors.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. */
|
||||
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. */
|
||||
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> = {
|
||||
cmdName: C;
|
||||
cmdStatus?: ErrorName;
|
||||
@@ -53,6 +46,8 @@ export type PduObject = {
|
||||
tlvs: Record<string, Tlv>;
|
||||
};
|
||||
|
||||
export type { TlvInput };
|
||||
|
||||
const respBit = 0x80000000;
|
||||
|
||||
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
|
||||
@@ -71,44 +66,98 @@ export function isCommand<C extends CommandName>(
|
||||
return pduObj.cmdName === cmdName;
|
||||
}
|
||||
|
||||
function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
||||
const tagId = input.tagId ?? tlvs[name]?.id;
|
||||
type ResolvedBody = {
|
||||
params: Record<string, ParamValue | undefined>;
|
||||
tlvs: Record<string, TlvInput> | undefined;
|
||||
};
|
||||
|
||||
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 };
|
||||
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
|
||||
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
|
||||
}
|
||||
|
||||
/** Encoding a string short_message settles data_coding and sm_length; a buffer settles sm_length. */
|
||||
function resolveShortMessage(
|
||||
params: Record<string, ParamValue | undefined>,
|
||||
): Record<string, ParamValue | undefined> {
|
||||
const message = params.short_message;
|
||||
type CarriedBody = { name: string; text: string; tlv: TlvInput };
|
||||
|
||||
if (Buffer.isBuffer(message)) {
|
||||
return params.sm_length === undefined
|
||||
? { ...params, sm_length: message.length }
|
||||
: { ...params };
|
||||
/** Every entry carrying body text, under whatever names their tagIds are keyed to. */
|
||||
function carriedBodies(input: Record<string, TlvInput> | undefined): CarriedBody[] {
|
||||
const carried: CarriedBody[] = [];
|
||||
|
||||
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;
|
||||
const encoding = typeof dataCoding === 'number' ? encodingByDataCoding(dataCoding) : detect(message);
|
||||
const encoded = encodeMessage(message, encoding);
|
||||
/** messageOctets() reads short_message wherever it holds an octet, and the TLV only where it does not. */
|
||||
function carriesOctets(value: ParamValue | undefined): boolean {
|
||||
return Buffer.isBuffer(value) && value.length > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
data_coding: typeof dataCoding === 'number' ? dataCoding : consts.ENCODING[encoded.encoding],
|
||||
short_message: encoded.buffer,
|
||||
sm_length: encoded.buffer.length,
|
||||
};
|
||||
/** The short_message the command's own table will write, since writeParams() ignores any other. */
|
||||
function writtenBody(definition: CommandDefinition, value: ParamValue | undefined): ParamValue | undefined {
|
||||
return definition.params?.short_message === undefined ? undefined : value;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
@@ -139,42 +188,6 @@ function writeParams(
|
||||
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
|
||||
* 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 }> {
|
||||
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 };
|
||||
|
||||
const writtenTlvs = writeTlvs(tlvs);
|
||||
const writtenTlvs = writeTlvs(body.tlvs);
|
||||
|
||||
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 { SubmitMessagingMode } from './defs/constants.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 { UnansweredError } from './unanswered-error.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 { normaliseSmsId } from './sms-id.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. */
|
||||
export type SendSmsInput = Omit<SendSmsOptions, 'encoding' | 'messagingMode'> & {
|
||||
export type SendSmsInput =
|
||||
Omit<SendSmsOptions, 'encoding' | 'messagingMode' | 'scheduleDeliveryTime' | 'validityPeriod'> & {
|
||||
encoding?: unknown;
|
||||
messagingMode?: unknown;
|
||||
};
|
||||
scheduleDeliveryTime?: unknown;
|
||||
validityPeriod?: unknown;
|
||||
};
|
||||
|
||||
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
||||
export type SendSmsResult = {
|
||||
err?: Error;
|
||||
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. */
|
||||
unanswered: number;
|
||||
};
|
||||
@@ -60,16 +64,24 @@ export type SendSmsDeps = {
|
||||
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. */
|
||||
type CheckedOptions = {
|
||||
encoding: EncodingName;
|
||||
messagingMode: SubmitMessagingMode;
|
||||
times: SendTimes;
|
||||
};
|
||||
|
||||
type SegmentOptions = {
|
||||
encoding: EncodingName;
|
||||
messagingMode?: SubmitMessagingMode;
|
||||
multipart: boolean;
|
||||
times?: SendTimes;
|
||||
};
|
||||
|
||||
/** Alphanumeric senders must be TON 5. */
|
||||
@@ -78,7 +90,7 @@ function addressTon(address: string): 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.
|
||||
return encoding === 'UCS2' ? 0x18 : 0x10;
|
||||
@@ -92,7 +104,7 @@ function esmClassFor(mode: SubmitMessagingMode | undefined, multipart: boolean):
|
||||
}
|
||||
|
||||
export function submitSmParams(
|
||||
sms: SendSmsInput,
|
||||
sms: Omit<SendSmsInput, 'scheduleDeliveryTime' | 'validityPeriod'>,
|
||||
segment: Buffer,
|
||||
options: SegmentOptions,
|
||||
): Record<string, ParamValue> {
|
||||
@@ -108,13 +120,12 @@ export function submitSmParams(
|
||||
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.scheduleDeliveryTime !== undefined) {
|
||||
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
|
||||
}
|
||||
if (sms.validityPeriod !== undefined) {
|
||||
params.validity_period = smppTime.encode(sms.validityPeriod);
|
||||
}
|
||||
if (schedule !== undefined) params.schedule_delivery_time = schedule;
|
||||
if (validity !== undefined) params.validity_period = validity;
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -157,11 +168,40 @@ function refusedEncoding(encoding: unknown): Error {
|
||||
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 }> {
|
||||
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. */
|
||||
@@ -185,7 +225,11 @@ function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
||||
|
||||
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. */
|
||||
@@ -205,12 +249,19 @@ function checkSegments(allowed: number, segments: number): Error | 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(
|
||||
sent: Result<{ pduObj: PduObject }>[],
|
||||
notation: SmsIdNotation | undefined,
|
||||
): SendSmsResult {
|
||||
const pduObjs: PduObject[] = [];
|
||||
const smsIds: string[] = [];
|
||||
const smsIds: (string | undefined)[] = [];
|
||||
let failure: Error | undefined;
|
||||
let unanswered = 0;
|
||||
|
||||
@@ -221,7 +272,7 @@ function collectSent(
|
||||
failure ??= one.err;
|
||||
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
|
||||
pduObjs.push(one.pduObj);
|
||||
smsIds.push(normaliseSmsId(paramText(one.pduObj.params.message_id), notation));
|
||||
smsIds.push(acceptedId(one.pduObj, notation));
|
||||
} else {
|
||||
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.
|
||||
const sent = await Promise.all(segments.map(segment => deps.send({
|
||||
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);
|
||||
|
||||
@@ -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 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)', () => {
|
||||
const samples: [string, number[]][] = [
|
||||
@@ -105,6 +105,51 @@ describe('detect()', () => {
|
||||
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()', () => {
|
||||
@@ -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', () => {
|
||||
assert.equal(encodingByDataCoding(0x05), '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.
|
||||
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 () => {
|
||||
|
||||
+61
-4
@@ -234,25 +234,82 @@ describe('smppDate()', () => {
|
||||
|
||||
describe('smppTime', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
assert.equal(smppTime.encode('260825143000000+'), '260825143000000+');
|
||||
assert.equal(smppTime.encode('260825143000000+').text, '260825143000000+');
|
||||
});
|
||||
|
||||
test('decodes an absolute time back to the same instant', () => {
|
||||
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(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', () => {
|
||||
assert.ok(smppTime.decode('nonsense').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);
|
||||
|
||||
/** 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. */
|
||||
const threeSegmentOctets = [
|
||||
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000001009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
|
||||
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000001009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
|
||||
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000100710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
|
||||
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000000009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
|
||||
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000000009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
|
||||
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000000710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
|
||||
];
|
||||
|
||||
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(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', () => {
|
||||
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({
|
||||
cmdName: 'submit_sm',
|
||||
cmdStatus: 'ESME_ROK',
|
||||
@@ -144,7 +145,7 @@ describe('encoding submit_sm', () => {
|
||||
|
||||
assert.equal(
|
||||
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)}`));
|
||||
|
||||
for (const smsId of sent.smsIds) {
|
||||
assert.ok(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', () => {
|
||||
const dlrMerger = merger();
|
||||
|
||||
dlrMerger.expect(['5cb0ea53b5d61093529174ca44e23871', '', '']);
|
||||
dlrMerger.expect(['5cb0ea53b5d61093529174ca44e23871', undefined, undefined]);
|
||||
assert.equal(dlrMerger.size, 0, 'an id the peer never named numbers nothing');
|
||||
|
||||
dlrMerger.expect(['bf53ad8b-1', '40ccdce2-2']);
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Remaining work for the `@larvit/smpp` 1.0.0 rewrite. Read [AGENTS.md](AGENTS.md) first — the goals
|
||||
and hard rules there constrain every item below.
|
||||
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules
|
||||
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
|
||||
rules. The documentation conventions in AGENTS.md do not govern it, and nothing here is a source
|
||||
anything else may cite.
|
||||
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
|
||||
govern it, and nothing here is a source anything else may cite.
|
||||
|
||||
## Status
|
||||
|
||||
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.
|
||||
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
|
||||
to 26. What is left is release work and a few things worth adding before or after 0.5.0.
|
||||
|
||||
## The agreed API
|
||||
|
||||
@@ -68,112 +67,139 @@ 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 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` |
|
||||
| CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` |
|
||||
| CI on Node 18 to 26, Renovate, tag-triggered publish | `.gitea/workflows/` |
|
||||
|
||||
Every defect listed in the AGENTS.md table has a regression test naming the behaviour.
|
||||
|
||||
## The GitHub backlog, once this branch is `main`
|
||||
## Move the repository to Gitea
|
||||
|
||||
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.
|
||||
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` becomes a push mirror
|
||||
of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
|
||||
|
||||
**Close as fixed by 1.0.0**, naming the replacement in the comment:
|
||||
- [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
|
||||
and the `renovate/*` branches stayed behind.
|
||||
- [x] Fast-forward is the only merge style. `main` takes no pushes, requires `Test / lint
|
||||
(pull_request)` and `Test / test (*) (pull_request)`, blocks an outdated branch, and gives
|
||||
admins no override. Every other branch takes force pushes.
|
||||
- [x] The workflows are in `.gitea/workflows/`. Tests run on pull requests only, the event the gate
|
||||
reads; Renovate runs as a scheduled workflow, as on adf-codec.
|
||||
- [x] The release publishes without provenance, which npm generates only on GitHub Actions and
|
||||
GitLab CI/CD.
|
||||
- [x] `package.json` names Gitea, and the GitHub mirror's issues as `bugs`. The README links
|
||||
absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on
|
||||
GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no
|
||||
workflow runs on `main`.
|
||||
- `RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as
|
||||
adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and
|
||||
cannot stand in for it. Add a github.com token only if lookups hit the rate limit.
|
||||
|
||||
| | 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. |
|
||||
## Before publishing 0.5.0
|
||||
|
||||
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
|
||||
- [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret.
|
||||
- [ ] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the
|
||||
token can publish under `@larvit`.
|
||||
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
||||
something CI should do.
|
||||
|
||||
## Retire the GitHub repository
|
||||
|
||||
Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order:
|
||||
the push mirror replaces GitHub's branches with Gitea's, which closes every pull request based on
|
||||
`master` without a reply, and GitHub refuses to delete the default branch it syncs over.
|
||||
|
||||
- [ ] Close the backlog below.
|
||||
- [ ] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
|
||||
- [ ] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
|
||||
URL in `package.json` resolves from then on.
|
||||
- [ ] Push `main` and make it GitHub's default branch.
|
||||
- [ ] Remove Renovate and CodeRabbit from the GitHub repository.
|
||||
- [ ] Add it to Gitea as a push mirror, with a GitHub credential that can write to it.
|
||||
|
||||
## Close the GitHub backlog
|
||||
|
||||
**Answer and close as fixed by 0.5.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), [#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`.
|
||||
- [ ] [#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 — close it with
|
||||
Renovate's removal from GitHub.
|
||||
|
||||
**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
|
||||
|
||||
- [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which
|
||||
`.github/workflows/release.yaml` needs.
|
||||
- [ ] Decide whether `smsIds` should be `(string | undefined)[]`. An SMSC that names an id for the
|
||||
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
|
||||
something CI should do.
|
||||
- [ ] **Rename the branches, once everything above is done.** Maintainer's call, 2026-09-04:
|
||||
`master` becomes `v0.4.0`, `typescript` becomes `main`, and `main` is the repository's default
|
||||
branch. Renaming rather than merging is what the orphan commit leaves available — the two
|
||||
histories share no ancestor, so a merge refuses them outright.
|
||||
- [ ] Rename `master` to `v0.4.0`.
|
||||
- [ ] Rename `typescript` to `main`, and make it the default branch.
|
||||
- [ ] Retarget what still points at an old name: [#71](https://github.com/larvit/larvitsmpp/pull/71),
|
||||
and the open PRs based on `master`, which a rename carries over rather than closes.
|
||||
- [ ] Delete `rewrite-base` once [#71](https://github.com/larvit/larvitsmpp/pull/71) is
|
||||
resolved; it exists only to give that PR a reviewable diff.
|
||||
|
||||
## Worth doing, not blocking
|
||||
|
||||
- [ ] **A named alphabet that cannot hold the message corrupts it instead of refusing it.**
|
||||
`encodeMessage('あいう', 'LATIN1')` returns `42 44 46` — `"BDF"` — and `sendSms()` puts that on
|
||||
the wire: `checkOptions()` refuses an unknown name, `FLASH` and flash-beside-Latin-1, but never
|
||||
asks whether the alphabet the caller named can carry the text. GSM 7-bit does the same, mapping
|
||||
anything outside 03.38 to a space. `Encoding.match()` cannot be the guard — `latin1.match()` is
|
||||
hardcoded `false` because it doubles as the auto-selection policy `detect()` reads, so "can hold
|
||||
this" and "should be picked for this" would have to be separated first. Send-side, so the tag
|
||||
is not blocked on it. Raised by the architecture review of
|
||||
[#96](https://github.com/larvit/larvitsmpp/pull/96), 2026-09-09.
|
||||
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
|
||||
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
|
||||
that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
|
||||
every attempt is the same case, and #98 made it a common one. On a down link the caller waits
|
||||
`responseTimeout` and is told the link failed rather than that the body could not be built —
|
||||
goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is
|
||||
the open half. Raised by the architecture review of
|
||||
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
||||
|
||||
- [ ] **A time nobody can read goes out as `NaN` rather than being refused.**
|
||||
`smppTime.encode(new Date('nope'))` returns `NaNNaNNaNNaNNaNNaNNaN00+` and
|
||||
`smppTime.encode(NaN)` returns `0000NaNNaNNaNNaN000R`; `sendSms({ validityPeriod })` and
|
||||
`scheduleDeliveryTime` write either straight into the PDU with no check at
|
||||
`submitSmParams()`. Goal 2, since the peer reads a field that means nothing. `Date` is not the
|
||||
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.
|
||||
- [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`,
|
||||
`smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four
|
||||
concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is
|
||||
the split. Raised by the architecture review of
|
||||
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
||||
|
||||
- [ ] **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`
|
||||
pinning thread, which stays open until this lands. Pinning every action and runner by hand is
|
||||
what the ask followed; the gate is what keeps them pinned. It has to cover workflow `uses:`
|
||||
and `runs-on:`, compose `image:`, and Dockerfile `FROM`, and the conventions differ per kind —
|
||||
actions take a semver tag, images the full patch version — so one grep for `latest` is not it.
|
||||
pinning thread. Pinning every action and runner by hand is what the ask followed; the gate is
|
||||
what keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
|
||||
Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the
|
||||
full patch version — so one grep for `latest` is not it.
|
||||
|
||||
- [ ] **A gate that fails when the test matrix misses the current Node.** Maintainer's ask on
|
||||
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread, which
|
||||
stays open until this lands. Node 26 was added by hand; nothing notices when 27 ships. Needs a
|
||||
source for what Current is — the Node release schedule is published as JSON — and a decision
|
||||
on whether a new Current fails the build or opens a PR, which is what Renovate already does
|
||||
for everything else here.
|
||||
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node
|
||||
26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the
|
||||
Node release schedule is published as JSON — and a decision on whether a new Current fails the
|
||||
build or opens a PR, which is what Renovate already does for everything else here.
|
||||
|
||||
- [ ] **CodeRabbit reviews through the GitHub mirror.** CodeRabbit does not support Gitea, so mirror
|
||||
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
|
||||
started until asked.
|
||||
|
||||
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports
|
||||
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
|
||||
@@ -225,7 +251,7 @@ session message is a change to every call site.
|
||||
already had answered, so the loss is real — but surviving it means handing the application the merge
|
||||
state to persist, which the scope floor covers as squarely as holding the state here would, and
|
||||
which publishes the shape of `DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam
|
||||
can still be added after 1.0.0 as a minor.
|
||||
can still be added after 0.5.0 as a minor.
|
||||
|
||||
- **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md
|
||||
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns
|
||||
|
||||
Reference in New Issue
Block a user