diff --git a/AGENTS.md b/AGENTS.md index 7277e6b..27de277 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | @@ -670,6 +671,38 @@ Grouped by what each one constrains. 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, diff --git a/README.md b/README.md index b8ee462..a4d62df 100644 --- a/README.md +++ b/README.md @@ -599,9 +599,11 @@ how binary payloads, hand-built user data headers and deliberately malformed bod Composing a message by hand takes the send-side pair: `unencodable(message, encoding)` gives `{ char, index }` for the first character an alphabet cannot carry and `undefined` where it carries -them all, which is the check `sendSms()` makes before it encodes anything; `smppTime.encode(value)` -returns `{ err, text }` for a `validity_period` or `schedule_delivery_time`, as `smppTime.decode()` -returns `{ err, date }` for one that arrived. +them all, which is the check `sendSms()` makes before it encodes anything; +`dataCodingByEncoding[encoding]` is the `data_coding` this library writes each alphabet under, which +is what to put beside octets `encodeMessage()` handed back; `smppTime.encode(value)` returns +`{ err, text }` for a `validity_period` or `schedule_delivery_time`, as `smppTime.decode()` returns +`{ err, date }` for one that arrived. The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, and the matching `*ById` maps) and grouped as `defs`. @@ -640,6 +642,9 @@ promises and the rough edges taken off. - **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. See [Logging](#logging). +- **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP + 3.4 5.2.19 gives 0x01. Reach for `dataCodingByEncoding` where you meant the alphabet `sendSms()` + writes — that is 0x00, not this. ### Behaviour that changed on the wire @@ -659,6 +664,10 @@ have worked around any of these, remove the workaround: 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 rather than + the alphabet those octets are in, 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 are unmoved 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 actually encrypted. - Alphanumeric senders were sent with TON 1 (international) instead of TON 5. diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts index 81628b3..04bb07c 100644 --- a/interop-tests/smppsim.test.ts +++ b/interop-tests/smppsim.test.ts @@ -276,7 +276,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'), diff --git a/src/defs/constants.ts b/src/defs/constants.ts index ff89833..0ed155e 100644 --- a/src/defs/constants.ts +++ b/src/defs/constants.ts @@ -22,7 +22,6 @@ export const consts = { YEARS: 0x0E, }, ENCODING: { - ASCII: 0x01, BINARY: 0x04, CYRILLIC: 0x06, EXTENDED_KANJI_JIS: 0x0D, diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts index 166a1af..c454b02 100644 --- a/src/defs/encodings.ts +++ b/src/defs/encodings.ts @@ -188,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> = { + ASCII: 0x00, + LATIN1: 0x03, + UCS2: 0x08, +}; diff --git a/src/index.ts b/src/index.ts index 33f9a9e..eea6081 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, unencodable } 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'; diff --git a/src/message.ts b/src/message.ts index d4c5719..0665019 100644 --- a/src/message.ts +++ b/src/message.ts @@ -1,7 +1,7 @@ import type { Result } from './result.ts'; import type { EncodingName } from './defs/encodings.ts'; -import { detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts'; -import { consts, hasUdh } from './defs/constants.ts'; +import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts'; +import { hasUdh } from './defs/constants.ts'; import { udhLength } from './udh.ts'; /** A single SMS carries 1120 bits, whatever the alphabet. */ @@ -35,7 +35,7 @@ export function encodeBody( if (dataCoding === undefined) { const detected = encodeMessage(text); - return { buffer: detected.buffer, dataCoding: consts.ENCODING[detected.encoding] }; + return { buffer: detected.buffer, dataCoding: dataCodingByEncoding[detected.encoding] }; } const encoding = encodingByDataCoding(dataCoding); diff --git a/src/send-sms.ts b/src/send-sms.ts index 723cf62..43089b2 100644 --- a/src/send-sms.ts +++ b/src/send-sms.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, unencodable, unencodableText } 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'; @@ -89,7 +89,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; diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts new file mode 100644 index 0000000..8132883 --- /dev/null +++ b/test/declared-alphabet.test.ts @@ -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 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(register: (resolve: (value: T) => void) => void): Promise { + return new Promise((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(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)); + } + }); +}); diff --git a/test/encodings.test.ts b/test/encodings.test.ts index 18d531f..67fc68c 100644 --- a/test/encodings.test.ts +++ b/test/encodings.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import { detect, encodingByDataCoding, encodings, unencodable } 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[]][] = [ @@ -188,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'); diff --git a/test/message-class.test.ts b/test/message-class.test.ts index 24f099c..e613139 100644 --- a/test/message-class.test.ts +++ b/test/message-class.test.ts @@ -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 () => { diff --git a/test/messaging-mode.test.ts b/test/messaging-mode.test.ts index 4e00d76..4ac57ee 100644 --- a/test/messaging-mode.test.ts +++ b/test/messaging-mode.test.ts @@ -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 = { diff --git a/test/pdu.test.ts b/test/pdu.test.ts index 8b60776..999337b 100644 --- a/test/pdu.test.ts +++ b/test/pdu.test.ts @@ -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', ); }); diff --git a/test/unsendable.test.ts b/test/unsendable.test.ts index 1177b3a..cdcda44 100644 --- a/test/unsendable.test.ts +++ b/test/unsendable.test.ts @@ -109,7 +109,7 @@ describe('an alphabet the caller named that cannot carry the message', () => { assert.equal((await session.sendSms({ from, message, to })).err, undefined, message); } - assert.deepEqual(sentAs(smsc.octets), [[0x01, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]); + assert.deepEqual(sentAs(smsc.octets), [[0x00, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]); }); }); diff --git a/todo.md b/todo.md index 5b865ca..e639383 100644 --- a/todo.md +++ b/todo.md @@ -120,19 +120,6 @@ session message is a change to every call site. 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. -- [ ] **A default GSM send declares IA5, not GSM 03.38.** `dataCodingFor()` resolves `encoding` - through `consts.ENCODING`, so `'ASCII'` goes out as `data_coding` 0x01 — SMPP 3.4 5.2.19's - *IA5 (CCITT T.50)/ASCII* — while the codec writes the GSM 03.38 table, where `@` is 0x00 and - `$` is 0x02 against IA5's NUL and STX. `encodingByDataCoding()` reads 0x00 back as GSM 03.38, - so this library's writer and reader disagree, and every peer in `interop-tests/` submits GSM - text at `data_coding` 0. Nothing asserts what our own default send declares, and the echo - tests use characters where the two tables agree, which is why the suite is green. - `test/message-class.test.ts` pins the current value. Goal 1 owns it. The fix is a three-entry - map in `send-sms.ts` (`ASCII` → 0x00, `LATIN1` → 0x03, `UCS2` → 0x08) rather than any move of - a published constant — `consts.ENCODING.ASCII` = 0x01 is a correct name for SMPP's flat-table - IA5 entry, and the defect is using that group as the option's vocabulary. **Which value an - operator should see is the maintainer's call**, and it wants an `interop-tests/` run. Raised - by the architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. - [ ] Tag `v1.0.0` to publish. - [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not something CI should do.