IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime16/69-dars19 daqiqa
Mundarija (26)

SMS yuborish Node'da: Eskiz va PlayMobile, segmentlar va token

Qisqacha: SMS provayderi (Eskiz, PlayMobile) — HTTP API: login qilib token olasiz, raqam va matnni yuborasiz, yetkazilganini esa provayder callback bilan aytadi. Narx segment bo'yicha: lotin matni GSM-7 da 160 belgigacha bitta SMS, lekin bitta ʻ, «» yoki — belgisi butun xabarni UCS-2 ga o'tkazadi va chegara 70 ga tushadi. Kodda provayderni interfeys ortiga yashiring, tokenni bitta login bilan bo'lishing, 401 da qayta login qiling va faqat vaqtinchalik xatoda zaxira provayderga o'ting.

Bu darsda

  • SMS segmentini hisoblaysiz va o'zbekcha matn nega qimmatlashishini ko'rsatasiz.
  • Telefon raqamini bitta formatga keltirasiz.
  • Eskiz API oqimini (login, yuborish, callback) va uning tuzoqlarini bilasiz.
  • Parallel so'rovlar uchun bitta login (single-flight) naqshini yozasiz.
  • Kanon sms.ts modulini va uning 7 testini lokal soxta server bilan ishga tushirasiz.

Oldin bilishingiz kerak: Unicode va emoji: matn ichki tuzilishi, Built-in fetch va undici (FormData, AbortSignal.timeout), Promise kombinatorlari, Email yuborish: SMTP va Nodemailer.

Email — bepul va sekin o'qiladi. SMS esa pullik, lekin deyarli darhol ko'riladi va internetsiz ham keladi. Shuning uchun u tasdiqlash kodlari (28-qismda OTP orqali login) va eng muhim eslatmalar uchun.

Diqqat: Bu darsdagi hech bir haqiqiy SMS provayder sinalmagan (2026-10-27): hisob va pul kerak. Eskiz API'si uning rasmiy Postman hujjati bo'yicha (2026-10-27 da o'qildi), sinov esa lokal soxta server (simulyator) bilan. PlayMobile'ning ochiq hujjati topilmadi.

1. Nega bu kerak?

vazifalar foydalanuvchisi Jasur muhim vazifa uchun SMS eslatmani yoqdi. Kunlik 1 000 ta eslatma. Bir SMS narxi — provayder tarifida. Agar har xabar bitta emas, ikkita SMS bo'lib ketsa — xarajat ikki barobar, va buni hech kim sezmaydi, faqat oy oxiridagi hisob.

SMS ishlari backend dasturchining uchta g'amida:

  1. Narx — segmentlar va belgi to'plami.
  2. Ishonchlilik — token eskiradi, provayder band bo'ladi, zaxira kerak.
  3. Suiiste'mol — hujumchi sizning hisobingizdan minglab SMS yubortiradi (SMS pumping).

O'xshatish: SMS — telegramma. So'z soniga qarab haq olinadi, va telegrafchi faqat o'z alifbosidagi harflarni biladi. Begona harf bitta bo'lsa ham, butun telegramma qimmat tarif bilan ketadi.

2. Segmentlar: bitta belgi narxni ikki barobar qiladi

2.1 GSM-7 va UCS-2

SMS ichida 140 bayt joy bor. Matn ikki xil kodlanadi:

Kodlash Belgilar Bitta SMS Bo'lingan xabarda
GSM-7 lotin, raqam, oddiy belgilar (128 ta) 160 har qism 153
UCS-2 istalgan Unicode 70 har qism 67

GSM-7 da har belgi 7 bit — 140 baytga 160 belgi sig'adi. Jadvalda yo'q belgi bitta bo'lsa ham, butun xabar UCS-2 ga o'tadi: har belgi 2 bayt, 70 belgi. Uzun xabar bo'linganda har qism boshida qismlarni birlashtirish uchun sarlavha (UDH) turadi — shuning uchun 153 va 67.

Muhim nozik joy: o'zbek lotin alifbosidagi rasmiy ʻ (U+02BB) GSM jadvalida yo'q. Oddiy apostrof ' esa bor. Kirill — umuman yo'q.

2.2 Hisoblash

Kanon segmentlar funksiyasining JavaScript nusxasi (brauzerda ham ishlaydi):

js
// GSM 03.38 asosiy jadvali (kengaytma belgilari 2 o'rin egallaydi)
const GSM =
  "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ " +
  "!\"#¤%&'()*+,-./0123456789:;<=>" +
  "?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿" +
  "abcdefghijklmnopqrstuvwxyzäöñüà";
const GSM_KENG = "^{}\\[~]|€";

function segmentlar(matn) {
  let gsm = true;
  let uz = 0;
  for (const ch of matn) {
    if (GSM.includes(ch)) uz += 1;
    else if (GSM_KENG.includes(ch)) uz += 2;
    else { gsm = false; break; }
  }
  if (gsm) {
    const segment = uz <= 160 ? 1 : Math.ceil(uz / 153);
    return { kodlash: "GSM-7", uzunlik: uz, segment };
  }
  const u = Array.from(matn)
    .reduce((a, c) => a + (c.codePointAt(0) > 0xffff ? 2 : 1), 0);
  const segment = u <= 70 ? 1 : Math.ceil(u / 67);
  return { kodlash: "UCS-2", uzunlik: u, segment };
}

console.log(segmentlar("Kod: 482913. O'zingiz kiriting."));
console.log(segmentlar("Kod: 482913. Oʻzingiz kiriting."));
console.log(segmentlar("Код: 482913"));
console.log(segmentlar("[kod] 482913"));
console.log(segmentlar("a".repeat(161)));
console.log(segmentlar("Oʻ" + "a".repeat(70)));

Konsolda:

text
{ kodlash: 'GSM-7', uzunlik: 31, segment: 1 }
{ kodlash: 'UCS-2', uzunlik: 31, segment: 1 }
{ kodlash: 'UCS-2', uzunlik: 11, segment: 1 }
{ kodlash: 'GSM-7', uzunlik: 14, segment: 1 }
{ kodlash: 'GSM-7', uzunlik: 161, segment: 2 }
{ kodlash: 'UCS-2', uzunlik: 72, segment: 2 }
  • Birinchi va ikkinchi qator bir xil ko'rinadi — farqi faqat ' va ʻ. Ikkinchisi UCS-2.
  • [kod] — 5 belgi, lekin 6 o'rin: [ va ] kengaytma jadvalida, har biri 2 o'rin (maxsus "escape" belgisi bilan).
  • 161 lotin harfi — 2 SMS; ʻ bilan esa 72 belgi allaqachon 2 SMS.
  • Emoji (U+FFFF dan katta) UCS-2 da 2 o'rin egallaydi (Unicode va emoji — surrogat juftlar).

2.3 Kanon o'lchovi: o'zbekcha eslatmalar

Kanon bir xil ma'noli xabarlarni turli yozuvda sinadi. Har qatorning boshiga — kodlash va SMS soniga qarang, matn esa | dan keyin:

text
GSM-7  44 belgi → 1 SMS | Tasdiqlash kodi: 482913. Hech kimga aytmang.
GSM-7  67 belgi → 1 SMS | Tasdiqlash kodi: 482913. Uni hech kimga bermang, o'zingiz kiriting.
UCS-2  67 belgi → 1 SMS | Tasdiqlash kodi: 482913. Uni hech kimga bermang, oʻzingiz kiriting.
UCS-2  42 belgi → 1 SMS | Тасдиқлаш коди: 482913. Ҳеч кимга айтманг.
UCS-2 126 belgi → 2 SMS | Eslatma: "Non olish" vazifasi muddati bugun 18:00 da tugaydi. Ilovada 
UCS-2 126 belgi → 2 SMS | Eslatma: «Non olish» vazifasi muddati bugun 18:00 da tugaydi. Ilovada 
Xulosa: bitta `ʻ`, `«»` yoki `—` (GSM'da yo'q) butun xabarni UCS-2 (70 belgi) ga o'tkazadi; narx segment soniga ko'paytiriladi.

(Matn 70 belgida kesib ko'rsatilgan.) Uchinchi qator — 67 belgi, chegaraga uch belgi qoldi: bitta so'z qo'shilsa, ikki SMS. Beshinchi qatorga qarang: oddiy qo'shtirnoq ishlatilgan, lekin xabar baribir UCS-2. Sababi xabar oxirida, kesilgan qismda: … /v/12345 — vazifalar. Bitta — 126 belgilik eslatmani 2 SMS ga aylantirdi. GSM-7 da u bitta bo'lardi.

Tekshirib ko'ring: Dizayner OTP xabarini chiroyli qilish uchun boshiga 🔐 qo'shmoqchi. Xabar Kod: 482913 edi. Narx o'zgaradimi?

Javob

Xabar UCS-2 ga o'tadi. Uzunligi 13 o'rin (emoji 2 o'rin), 70 dan kam — hali bitta SMS, narx o'zgarmaydi. Lekin endi matnga 57 o'rin qoldi, GSM-7 da esa 147 edi. Keyin kimdir "Kodni hech kimga aytmang, u 5 daqiqa amal qiladi…" qo'shsa — ikki SMS. Qoida: SMS matnida faqat GSM belgilari, va matnni o'zgartirganda segmentlar testini qo'shing.

3. Telefon raqami

Foydalanuvchi raqamni har xil yozadi: +998 90 123-45-67, (90) 1234567, 901234567. Provayder esa bitta formatni kutadi — Eskiz uchun 998XXXXXXXXX (12 raqam). Kanon funksiyasi:

js
function raqamNormallash(kirish) {
  const r = kirish.replace(/[\s()+-]/g, "");
  const toliq = /^\d{9}$/.test(r) ? `998${r}` : r;
  return /^998\d{9}$/.test(toliq) ? toliq : undefined;
}

console.log(raqamNormallash("+998 90 123-45-67"));
console.log(raqamNormallash("(90) 123 45 67"));
console.log(raqamNormallash("8 90 123 45 67"));
console.log(raqamNormallash("+7 900 123 45 67"));

Konsolda:

text
998901234567
998901234567
undefined
undefined
  • 9 raqam — mamlakat kodi qo'shiladi.
  • Eski 8 90… formati qabul qilinmaydi: taxmin qilish xato raqamga pul to'lashga olib keladi. Foydalanuvchiga to'g'ri formatni so'rash afzal.
  • Xorijiy raqam — undefined. Xalqaro SMS — boshqa narx va boshqa sozlama.

Kanon operator kodlari ro'yxatini (90, 91, 33, 77 …) ataylab yozmadi: yangi kodlar qo'shiladi va qat'iy ro'yxat bir kuni haqiqiy mijozni rad etadi. Raqam haqiqatan foydalanuvchiniki ekanini format emas, tasdiqlash kodi isbotlaydi.

4. Eskiz API

4.1 Oqim

Eskiz (notify.eskiz.uz) rasmiy Postman hujjatiga ko'ra (2026-10-27 da o'qildi):

Qadam So'rov Javob
Login POST /api/auth/login (form-data: email, password) data.token — 30 kun amal qiladi
Yangilash PATCH /api/auth/refresh yangi token
Yuborish POST /api/message/sms/send (mobile_phone, message, from, callback_url) { id, message: "Waiting for SMS provider", status: "waiting" }
Callback provayder → sizning callback_url { request_id, message_id, phone_number, status: "DELIVRD", … }
sequenceDiagram
  participant S as Server
  participant E as Eskiz
  participant O as Operator
  S->>E: login (email, parol)
  E-->>S: token (30 kun)
  S->>E: sms/send + Bearer token
  E-->>S: id, status waiting
  E->>O: SMS
  O-->>E: yetkazildi
  E->>S: callback: DELIVRD

Diagrammaga qarang: sms/send javobi "yetkazildi" emas, "navbatga oldim" (waiting). Haqiqiy natija keyin, callback bilan keladi. Bu oldingi darsdagi email bilan bir xil: topshirish va yetkazish — ikki xil voqea.

Hujjatdagi qo'shimcha qoidalar:

  • from — yuboruvchi nomi (alfa-nom, "nick"); sukutdagi qisqa raqam 4546. O'z nomingiz (VAZIFALAR) provayder orqali alohida tasdiqlanadi.
  • Test rejimi: hisob tasdiqlanmaguncha faqat uchta tayyor matn yuboriladi (Bu Eskiz dan test va ruscha, inglizcha varianti).
  • Haqiqiy matnlar provayder kabinetida moderatsiyadan o'tadi.
  • Callback holatlari: DELIVRD, UNDELIV, EXPIRED, REJECTD, DELETED.

Alfa-nom va matn moderatsiyasining aniq tartibi va muddati kanonda sinalmagan — provayder bilan shartnoma va kabinet kerak.

4.2 Token: bitta login hammaga

Eskiz tokeni 30 kun yashaydi. Har SMS oldidan login qilish — ikki barobar so'rov va provayder limitiga tez urilish. Token xotirada saqlanadi. Lekin tuzoq bor: server endigina ishga tushdi va bir vaqtda 10 ta SMS keldi. Har biri "token yo'q" deb o'z login'ini boshlaydi — 10 login.

Yechim — single-flight (bitta parvoz): birinchi chaqiruv login'ni boshlaydi va uning Promise'ini saqlaydi, qolganlari o'sha Promise'ni kutadi:

js
let loginSoni = 0;
const soxtaLogin = async () => {
  loginSoni++;
  await new Promise((r) => setTimeout(r, 50));
  return `token-${loginSoni}`;
};

let token;
let olinmoqda;
function tokenOl() {
  if (token) return Promise.resolve(token);
  olinmoqda ??= soxtaLogin()
    .then((t) => (token = t))
    .finally(() => (olinmoqda = undefined));
  return olinmoqda;
}

const natija = await Promise.all(Array.from({ length: 10 }, tokenOl));
console.log(new Set(natija), "login:", loginSoni);

Konsolda:

text
Set(1) { 'token-1' } login: 1

O'nta chaqiruv — bitta login, bitta token. ??= — "faqat bo'sh bo'lsa yoz". finally — login xato bilan tugasa ham, keyingi urinish yangi login boshlay olsin. Kanon testi buni lokal soxta Eskiz serverida tasdiqladi: 10 parallel SMS — 1 login.

4.3 Token eskirsa: 401

30 kundan keyin yoki provayder tokenni bekor qilsa, sms/send 401 qaytaradi. Kanon modul qoidasi: bir marta qayta login va takror. Ikkinchi 401 — xato (parol o'zgargan bo'lishi mumkin; cheksiz login sikli provayderni qiynaydi).

5. Xatolar va zaxira provayder

5.1 Qaysi xatoda qayta urinish

SmsXato — kanon modulidagi xato klassi, unda qaytaUrinish bayrog'i bor:

Holat qaytaUrinish Nega
429 (juda ko'p so'rov) true biroz kutib — o'tadi
5xx (provayder xatosi) true vaqtinchalik
401 login'da (parol xato) false sozlamani tuzatish kerak
422 (raqam yoki matn noto'g'ri) false qayta yuborsa ham xuddi shu
noto'g'ri raqam (bizning tekshiruv) false so'rov umuman ketmaydi

Bu oldingi darsdagi SMTP 4xx/5xx qoidasining HTTP varianti.

5.2 PlayMobile va zaxira

PlayMobile — yana bir mahalliy SMS shlyuzi (gateway — sizning HTTP so'rovingizni operatorlar tarmog'idagi SMS'ga aylantiradigan xizmat). Uning ochiq hujjati topilmadi (hamkorlarga beriladi). Kanon modul shaklini uchinchi tomon klientlaridan oldi: Basic auth va JSON messages[] (recipient, message-id, sms.originator, content.text). Bu taxminiy shakl: haqiqiy shartnomada hujjatni tekshiring.

Ikki provayder bo'lsa, birinchisi ishlamay qolganda ikkinchisiga o'tish mumkin — failover (zaxiraga o'tish). Lekin faqat vaqtinchalik xatoda. Matn noto'g'ri bo'lsa (422), ikkinchi provayder ham rad etadi yoki, yomoni, yuborib yuboradi va pul ketadi. Kanon ZaxiraliSms aynan shunday qiladi: kanon testi "Eskiz 503 → PlayMobile; 4xx — zaxiraga o'tmaydi".

5.3 Tezlik

Kanon soxta serverga 200 ta SMS yubordi:

text
ketma-ket: mediana 1.62 ms; parallel 200: 193 ms; login soni 1 (lokal mock — tarmoq kechikishi yo'q)
Eslatma: haqiqiy provayder tezligi/limiti — SINALMAGAN; navbat + rate limit (#45–#47) bilan yuboriladi.

Bu raqamlar faqat bizning kodimiz narxi — tarmoq yo'q. Haqiqiy provayderning soniyadagi limiti shartnomada. SMS'ni HTTP so'rov ichida emas, navbat orqali va tezlik chegarasi bilan yuborish kerak — BullMQ asoslari darsida.

6. Callback: yetkazildimi

Eskiz har SMS natijasini sizning callback_url ga POST qiladi. Kanon modul callback manziliga o'z ID'imizni qo'shadi: …/sms/callback?id=otp-3. Shunda kelgan javobni bazadagi yozuv bilan bog'lash oson. Provayder holati bizning uch holatga aylanadi:

Provayder Bizda
DELIVRD yetkazildi
UNDELIV, EXPIRED, REJECTD, DELETED yetkazilmadi
boshqasi kutilmoqda

Callback — tashqaridan keladigan so'rov, ya'ni webhook. Hujjatda unga imzo yoki maxfiy kalit tasvirlanmagan. Demak manzilni bilgan har kim soxta DELIVRD yubora oladi. Himoya — callback URL'ga taxmin qilib bo'lmaydigan token qo'shish va holatni faqat bazadagi mavjud yozuv uchun yangilash. Webhook xavfsizligi Kiruvchi webhook'larni qabul qilish darsida.

7. Hujumchi nigohi

Hujum Natijasi Himoya
SMS pumping: bot OTP so'rovini qimmat yo'nalishdagi raqamlarga minglab yuboradi hisobingizdan pul ketadi faqat 998, IP va raqam bo'yicha Rate limiting, CAPTCHA (SMS pumping)
soxta callback "yetkazildi" yolg'on URL'da maxfiy token, faqat mavjud yozuv
token logga tushdi begona sizning nomingizdan SMS yuboradi tokenni loglamang, .env da parol
foydalanuvchi matni SMS'da fishing havola sizning nomingizdan SMS matni — faqat shablon + qiymat

Birinchi qator — haqiqiy biznes xavfi. Hujumchining maqsadi ma'lumot emas, sizning pulingiz: u qimmat tarifli raqamlarga SMS yubortiradi va operator bilan foydani bo'lishadi. Kanon raqamNormallash xorijiy raqamni umuman o'tkazmaydi — bu birinchi qatlam.

8. Ko'p uchraydigan xatolar

Xato Natija To'g'risi
SMS matnida ʻ, «», — 70 belgi chegarasi, narx 2× ', ", -
har SMS'da login ikki barobar so'rov token xotirada, single-flight
401 da cheksiz qayta login sikl bir marta
sms/send javobini "yetkazildi" deyish yolg'on holat callback'ni kuting
har xatoda zaxiraga o'tish ikki marta pul faqat 429/5xx/tarmoq
raqamni taxmin qilish (8 90…) begona raqamga SMS aniq format yoki rad

9. Mashqlar

Mashqlar kurs/mashqlar/29/16-sms/ da.

1-mashq (oson): bo'shliqlarni to'ldiring

  • GSM-7 da bitta SMS'ga sig'adigan belgilar —
  • UCS-2 da —
  • Eskiz sms/send javobidagi holat —
  • Yetkazilganini bildiruvchi callback holati —
  • Token eskirganda keladigan HTTP status —
Yechim
  • 160 — bo'lingan xabarda 153.
  • 70 — bo'lingan xabarda 67.
  • waiting — "provayderga topshirildi", yetkazilmagan.
  • DELIVRD — D va R orasida E yo'q, hujjatdagidek.
  • 401 — bir marta qayta login.

2-mashq (o'rta): matnni GSM'ga moslash

gsmgaMoslash(matn) yozing: ʻ ʼ ‘ ’ va teskari tirnoq → ', « » “ ” → ", — – → -, … → .... Natijani va matn faqat ASCII ekanini (/^[\x20-\x7E]*$/) asl va tozalangan matn uchun chiqaring. Ishora: almashtirishlarni [regex, belgi] juftliklari massivida saqlang va for…of bilan aylaning (Almashtirish chuqur).

Yechim
js
const ALMASH = [
  [/[ʻʼ‘’`]/g, "'"],
  [/[«»“”]/g, '"'],
  [/[—–]/g, "-"],
  [/…/g, "..."],
];

function gsmgaMoslash(matn) {
  let natija = matn;
  for (const [re, belgi] of ALMASH) {
    natija = natija.replace(re, belgi);
  }
  return natija;
}

const asl =
  "Eslatma: «Non olish» — bugun 18:00 da. Oʻtkazib yubormang…";
const toza = gsmgaMoslash(asl);
console.log(toza);
console.log(/^[\x20-\x7E]*$/.test(asl), /^[\x20-\x7E]*$/.test(toza));

Konsolda:

text
Eslatma: "Non olish" - bugun 18:00 da. O'tkazib yubormang...
false true

ASCII tekshiruvi GSM'dan qat'iyroq: GSM'da é bor, ASCII'da yo'q; ASCII'dagi teskari tirnoq esa GSM'da yo'q — shuning uchun uni ham ' ga almashtirdik. Haqiqiy loyihada tozalangandan keyin segmentlar bilan tekshirish ishonchliroq. Bu almashtirish faqat SMS uchun: ilovaning o'zida va email'da rasmiy ʻ qolsin.

3-mashq (qiyin): 401 da bir marta qayta login

Soxta api obyekti berilgan: login() joriy tokenni qaytaradi va loginlarni sanaydi, yubor(token, matn) token eskirgan bo'lsa { status: 401 }, aks holda { status: 200, id }. yubor(matn) funksiyasini yozing: token bo'lmasa — login; 401 bo'lsa — bir marta yangi login va takror; boshqa status — Error("HTTP <status>"). To'rt holatni sinang: oddiy, yana bir xat (login takrorlanmasin), token almashgan, doim 401. Ishora: ??= (??, ?. va mantiqiy tayinlash).

Yechim
js
let joriy = "t1";
let loginlar = 0;
const api = {
  async login() {
    loginlar++;
    return joriy;
  },
  async yubor(token, matn) {
    if (token !== joriy) return { status: 401 };
    return { status: 200, id: `sms-${matn.length}` };
  },
};

let token;
async function yubor(matn) {
  token ??= await api.login();
  let r = await api.yubor(token, matn);
  if (r.status === 401) {
    token = await api.login();
    r = await api.yubor(token, matn);
  }
  if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
  return r.id;
}

console.log(await yubor("salom"), loginlar);
console.log(await yubor("salom!"), loginlar);
joriy = "t2";
console.log(await yubor("yangi token"), loginlar);
api.yubor = async () => ({ status: 401 });
try {
  await yubor("x");
} catch (e) {
  console.log(e.message, loginlar);
}

Konsolda:

text
sms-5 1
sms-6 1
sms-11 2
HTTP 401 3

Oxirgi holatda login uchinchi marta bo'ldi va to'xtadi — sikl yo'q. Bu yechimda single-flight yo'q: parallel 10 ta yubor token bo'lmaganda 10 ta login qiladi. Kanon modulda ikkalasi birga: #olToken single-flight va 401 da #olToken(true).

10. Loyiha qadami: sms.ts moduli

Kanon SMS'ni modullar/src/sms/sms.ts ga yig'di: raqamNormallash, segmentlar (darsdagi nusxalari), SmsProvayder interfeysi, SmsXato, EskizProvayder, PlayMobileProvayder va ZaxiraliSms. Interfeys — butun modulning o'qi:

ts
export type SmsNatija = {
  provayderId: string;
  holat: "kutilmoqda" | "yuborildi";
};

export interface SmsProvayder {
  readonly nom: string;
  yubor(
    telefon: string, matn: string, bizningId: string,
  ): Promise<SmsNatija>;
}

bizningId — bizning bazadagi yozuv ID'si. U callback va PlayMobile'ning message-id siga ketadi: javobni yozuvga bog'lash va takrorni aniqlash uchun.

Eskiz provayderi (uzun qatorlarni telefon uchun bo'ldik, ma'nosi kanondagidek):

ts
type EskizSozlama = {
  baseUrl: string;
  email: string;
  parol: string;
  from: string;
  callbackUrl?: string;
  fetchFn?: FetchFn;
  kutishMs?: number;
};

/** Eskiz: login → Bearer token (hujjat: 30 kun). 401 bo'lsa — bir
 * marta qayta login va takror. Token xotirada. */
export class EskizProvayder implements SmsProvayder {
  readonly nom = "eskiz";
  #token: string | undefined;
  #tokenOlish: Promise<string> | undefined;
  readonly #s: EskizSozlama;

  constructor(s: EskizSozlama) {
    this.#s = s;
  }

  get #f(): FetchFn {
    return this.#s.fetchFn ?? globalThis.fetch;
  }

  get #signal(): AbortSignal {
    return AbortSignal.timeout(this.#s.kutishMs ?? 10_000);
  }

  /** Parallel so'rovlar bitta login'ni kutadi
   * (aks holda 10 SMS = 10 login). */
  async #olToken(yangi = false): Promise<string> {
    if (!yangi && this.#token) return this.#token;
    this.#tokenOlish ??= (async () => {
      try {
        const fd = new FormData();
        fd.append("email", this.#s.email);
        fd.append("password", this.#s.parol);
        const r = await this.#f(`${this.#s.baseUrl}/api/auth/login`, {
          method: "POST", body: fd, signal: this.#signal,
        });
        if (!r.ok) {
          throw new SmsXato(
            `Eskiz login: HTTP ${String(r.status)}`,
            r.status >= 500,
            r.status,
          );
        }
        const j = (await r.json()) as { data?: { token?: string } };
        if (!j.data?.token) {
          throw new SmsXato("Eskiz login: token yo'q", false);
        }
        this.#token = j.data.token;
        return j.data.token;
      } finally {
        this.#tokenOlish = undefined;
      }
    })();
    return this.#tokenOlish;
  }

  async yubor(
    telefon: string, matn: string, bizningId: string,
  ): Promise<SmsNatija> {
    const tel = raqamNormallash(telefon);
    if (!tel) throw new SmsXato(`noto'g'ri raqam: ${telefon}`, false);
    const sorov = async (token: string) => {
      const fd = new FormData();
      fd.append("mobile_phone", tel);
      fd.append("message", matn);
      fd.append("from", this.#s.from);
      if (this.#s.callbackUrl) {
        const id = encodeURIComponent(bizningId);
        fd.append("callback_url", `${this.#s.callbackUrl}?id=${id}`);
      }
      return this.#f(`${this.#s.baseUrl}/api/message/sms/send`, {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
        body: fd,
        signal: this.#signal,
      });
    };
    let r = await sorov(await this.#olToken());
    if (r.status === 401) r = await sorov(await this.#olToken(true));
    if (!r.ok) {
      const matnXato = (await r.text()).slice(0, 200);
      const qayta = r.status === 429 || r.status >= 500;
      throw new SmsXato(
        `Eskiz yuborish: HTTP ${String(r.status)} ${matnXato}`,
        qayta, r.status,
      );
    }
    const j = (await r.json()) as { id?: string; status?: string };
    if (!j.id) throw new SmsXato("Eskiz javobida id yo'q", false);
    return { provayderId: j.id, holat: "kutilmoqda" };
  }
}
  • fetchFn — testda soxta fetch berish uchun; sukutda global fetch.
  • # — xususiy maydonlar (Private # maydonlar): token klassdan tashqariga chiqmaydi.
  • Har so'rovda AbortSignal.timeout — provayder javob bermasa, 10 soniyada uziladi (Built-in fetch).
  • Muvaffaqiyat — kutilmoqda, yuborildi emas: yetkazishni callback aytadi.

Zaxira (failover) klassi:

ts
export class ZaxiraliSms implements SmsProvayder {
  readonly nom = "zaxirali";
  readonly #lar: SmsProvayder[];
  constructor(...provayderlar: SmsProvayder[]) {
    this.#lar = provayderlar;
  }
  async yubor(
    telefon: string, matn: string, bizningId: string,
  ): Promise<SmsNatija & { provayder: string }> {
    let oxirgi: unknown;
    for (const p of this.#lar) {
      try {
        const n = await p.yubor(telefon, matn, bizningId);
        return { ...n, provayder: p.nom };
      } catch (e) {
        oxirgi = e;
        if (e instanceof SmsXato && !e.qaytaUrinish) throw e;
      }
    }
    if (oxirgi instanceof Error) throw oxirgi;
    throw new Error(String(oxirgi));
  }
}

ZaxiraliSms o'zi ham SmsProvayder — chaqiruvchi bitta provayder bilanmi yoki zanjir bilanmi ishlayotganini bilmaydi.

Testlar — modullar/test/sms.test.ts, yettita. Ular lokal soxta Eskiz serveri (test/soxta/eskiz.ts) bilan ishlaydi: u hujjatdagi login, token, sms/send, test rejimi va callback'ni taqlid qiladi, xato javoblarni (500, 429, 503, 422) buyruq bilan qaytaradi. Soxta serverning xato tanalari taxminiy — haqiqiy API bilan solishtirilmagan. Docker kerak emas, shuning uchun hammasini biz ishga tushirdik (formatlangan modul nusxasi, tsc va eslint 0, Node 24.21.0):

bash
node --test test/sms.test.ts
text
✔ raqamNormallash (1.1014ms)
✔ segmentlar: ASCII apostrof GSM-7, ʻ va kirill — UCS-2 (1.0977ms)
▶ Eskiz (soxta server)
  ✔ bir login, parallel 10 SMS; callback DELIVRD → yetkazildi (229.2094ms)
  ✔ token eskirdi (401) — avtomatik qayta login va takror (29.4544ms)
  ✔ xatolar: noto'g'ri parol (qayta urinmaydi), 500 va 429 (qayta urinish mumkin), noto'g'ri raqam (14.5322ms)
  ✔ test rejimi: faqat hujjatdagi test matnlari (15.0635ms)
  ✔ zaxira: Eskiz 503 → PlayMobile; 4xx — zaxiraga o'tmaydi (14.2487ms)
✔ Eskiz (soxta server) (337.1856ms)
ℹ tests 7
ℹ suites 1
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 566.0282

Yetti test, hammasi o'tdi (✔). Millisekundlar har ishga tushishda boshqacha. Commit:

bash
git commit -m "feat(sms): Eskiz va PlayMobile provayderlari, zaxira"

vazifalar-api ga ulash 28-qism tugagach: OTP kodlari shu interfeys orqali ketadi, callback marshruti esa yetkazish holatini yozadi.

11. Real ishda

  • O'zbekistonda Eskiz va PlayMobile — keng tarqalgan SMS shlyuzlari; ko'p kompaniya ikkalasini zaxira sifatida ulaydi.
  • Xalqaro loyihalarda Twilio, Vonage, AWS SNS. Ular uchun ham bir xil interfeys — yangi klass yetarli.
  • OTP uchun SMS'ga muqobillar ham bor: Telegram orqali kod (Telegram Gateway), ilova ichidagi push, email. SMS — eng qimmat va eng ko'p hujum qilinadigan kanal.
  • Intervyuda: "SMS narxini qanday kamaytirasiz?", "SMS pumping nima?", "Provayder ishlamay qolsa nima qilasiz?"

Xulosa

  • GSM-7 — 160 belgi, UCS-2 — 70; bitta ʻ, «» yoki — xabarni UCS-2 ga o'tkazadi.
  • Kanon: bitta — 126 belgilik eslatmani 2 SMS qildi.
  • Raqam — 998XXXXXXXXX, taxmin yo'q; operator kodlari ro'yxati yozilmaydi.
  • Eskiz: login → token (30 kun), sms/send → waiting, natija — callback; 401 da bir marta qayta login.
  • Single-flight: 10 parallel SMS — 1 login.
  • Zaxiraga faqat vaqtinchalik xatoda (429, 5xx); SMS pumping'ga qarshi — rate limit va faqat 998.

Keyingi dars: Push bildirishnomalar: FCM va Web Push — brauzerga xabarni server tomonidan yuboramiz: VAPID imzosi, shifrlash va eskirgan obunalarni tozalash.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!