rtm-socket

RTM WebSocket (Centrifuge)

src/prime/AsyncPrimeWs.ts — real-time канал від партнерського сайту (TalkyTimes) по кожній ТУ: повідомлення, листи, зміни лімітів, статуси. На основі Centrifugo/centrifuge-js.

UI: тригерить створення тасків оператора (жовті/червоні/сині). Партнерська дока: RTM Documentation (від розробників TU).

Підключення

  • URL: wss://talkytimes.com/rtm, транспорт centrifuge-json поверх ws.
  • Авторизація: JWT tld-token з cookie ТУ (беремо з getCookie), плюс заголовок X-Requested-With.
  • Один інстанс AsyncPrimeWs на одну ТУ (на пару lady+operator). Реконект — внутрішній у centrifuge.

Три джерела подій (важливо)

При конекті сервер сам підписує з’єднання на персональний серверний канал personal:#<ladyId> — саме туди йдуть УСІ події чату/листів/лімітів. Ми також вручну підписуємось на канали broadcast і online. centrifuge маршрутизує кожну публікацію рівно в одне місце за каналом:

ДжерелоХендлерЩо реально носить
personal:#<ladyId> (серверна підписка)centrifuge.on('publication')все — чат, листи, ліміти, статуси
broadcast (клієнтська підписка)broadcastSubscription.on('publication')у спостереженні — порожньо
online (клієнтська підписка)onlineSubscription.on('publication')у спостереженні — порожньо (presence)

Одна подія не дублюється між джерелами (перевірено: кожен eventId в лог-каналі process рівно раз). broadcast/online за час спостереження не дали жодної публікації — кандидати на видалення.

Формат конверта

Сирий push: { push: { channel, pub: { data, offset } } }. centrifuge віддає нам ctx.data = вміст pub.data:

{ "data": { /* корисне навантаження */ }, "dateExpired": null, "id": "<uuid події>", "type": "<partner type>" }

Виняток — лист: у нього немає data, натомість поле email на верхньому рівні (див. приклад нижче).

Події

Партнерський type парситься в наш локальний тип (src/prime/primeSocketParser/PrimeSocketParser.ts), далі обробляється в src/prime/components/tasks/socketTaskPrime.ts. Легенда: ✅ бачили наживо · ⬜ лише в типізації, наживо не бачили · ⚠️ парситься, але дія вимкнена/відсутня.

Партнерський typeНаш локальний типДіяБачили
MessageSentmessage→ таск «unanswered» + на фронт ttMessage
chat_DialogCreateddialogCreatedinitSocketOpenLimitTask (активна)
chat_DialogLimitChangedlimitsOpenedпарситься, але виклик таску закоментований✅ ⚠️
chat_DialogTypingdialogTypingнічого
email (поле email)emailна фронт ttEmail; виклик таску закоментований✅ ⚠️
chat_MessageReadmessageReadна фронт ttMessageRead
BlockedBlocked→ видалити всі активні таски пари — зараз падає, див. баги✅ 🐞
platform_CorrespondenceLimitChanged— (падає в type:null)не обробляється (ліміт пошти)✅ ⚠️
chat_InterlocutorStatusChanged— (нема в enum)не обробляється (online/offline чоловіка)✅ ⚠️
chat_DialogHighlightingChangedне обробляється — а саме тут limit_reload (див. нюанси)✅ ⚠️
chat_MessageVideoPurchasedне обробляється (платна дія: купівля відео)✅ ⚠️
gallery_PhotoProcessedне обробляється (модерація фото ТЮ)✅ ⚠️
chat_RequestLimitChanged— (нема в enum)не обробляється (добовий ліміт chat-request)✅ ⚠️
chat_MessageDisplayAttributesApplied— (нема в enum)не обробляється (модерація: blur на повідомленні)✅ ⚠️
news-feed_PostModerated— (нема в enum)не обробляється (модерація поста стрічки)✅ ⚠️
correspondence_LetterUpdated— (нема в enum)не обробляється (доїхав переклад листа)✅ ⚠️
Unblockedігнор
virtual-gift_GiftLimitUpdatedігнор
streaming_StreamStartedігнор
video-call_SessionStarted / video-call_SessionStoppedігнор

Підтипи MessageSent (message.type)

Enum PrimeSocketChatMessageType. Парсер пропускає повідомлення лише якщо підтип є в enum (if (chatMessageType in PrimeSocketChatMessageType)) — усе інше мовчки відкидається.

ПідтипcontentБачили
message{ message }
photo{ id, key, url, text }text (підпис) не був описаний
sticker{ id, url }
wink{} (порожній)
likephoto{ idPhoto, url }
like_newsfeed_post{ idFeedPost, photos[], photosTotalCount, text }
reply_newsfeed_post{ idFeedPost, photos[], photosTotalCount, text, replyText }✅ ⚠️ (в enum закоментований — відкидається)
virtual_gift{ id, message, imageSrc, animationSrc }

Кожен message також стабільно несе isReported, isIcebreaker, hasTranslation, isTranslation — раніше не були в типізації.

Офіційний перелік подій (партнерська дока)

Джерело: https://tawnylb.com/platform/api-doc/schema (RTM endpoint). Повний перелік типів, які декларує партнер:

  • gallery: gallery_PhotoProcessed, gallery_VideoProcessed
  • chat: chat_DialogCreated, chat_DialogLimitChanged, chat_DialogHighlightingChanged, chat_DialogTyping, chat_MessageRead, chat_MessageVideoPurchased
  • platform: platform_AudioPurchased, platform_CorrespondenceLimitChanged, platform_RevenueAccrued (нарахування для менеджерів)
  • social: Blocked, Unblocked
  • gift: virtual-gift_GiftLimitUpdated
  • інше: MessageSent, adglare (зони блокування банерів), mail (вхідний лист)

Розбіжності з нашим кодом / спостереженням

  • Бачили наживо, але немає в офіційній доці партнера (прод-реєстр 20.07.2026): chat_InterlocutorStatusChanged, chat_MessageDisplayAttributesApplied, chat_RequestLimitChanged, news-feed_PostModerated, correspondence_LetterUpdated. Тобто офіційна дока суттєво неповна → прод-реєстр унікальних типів виправданий, і це прямий аргумент у запиті партнеру задокументувати повний перелік подій.
  • mail vs email — дока називає подію листа mail, а в живому трафіку прийшло type: "email" (з полем email). Звірити.
  • Schema drift у межах одного типуchat_DialogHighlightingChanged приходить у двох формах (з highlightExpireDate і без). Ключ реєстру (type|shape) це ловить; жорстко покладатись на набір полів не можна.
  • Неконсистентний неймингemail і correspondence_LetterUpdated у snake_case (id_user_from), решта подій у camelCase (idUserFrom).
  • Є в доці, нема в нашій типізації: gallery_VideoProcessed, platform_AudioPurchased, platform_RevenueAccrued, adglare.
  • Є в нашому enum, нема в доці: streaming_StreamStarted, video-call_SessionStarted, video-call_SessionStopped — імовірно застарілі/перейменовані на боці партнера.

Партнерський gRPC/HTTP API (не сокет) — https://tawnylb.com/platform/api-doc/proto, 24 сервіси (Dialog, Email, Icebreaker, Gift, TrustedUser, Media, Summarizer…). Окрема поверхня, не події RTM.

Живі приклади (personal:#18567229, 07.07.2026)

MessageSent — вінк (content порожній, type: "wink"):

{
  "data": { "message": {
    "id": 47819361272, "type": "wink", "content": {},
    "idUserFrom": 83366724, "idUserTo": 18567229,
    "dateCreated": "2026-07-07T12:33:29+00:00",
    "isIcebreaker": false, "isReported": false,
    "hasTranslation": false, "isTranslation": false,
    "displayAttributes": []
  }},
  "dateExpired": null, "id": "47ac87ff-8c2f-4ac3-ae8b-d638e9b3fe2e", "type": "MessageSent"
}

MessageSent — текстове (type: "message", content.message):

{
  "data": { "message": {
    "id": 47819363913, "type": "message", "content": { "message": "фівіф" },
    "idUserFrom": 83366724, "idUserTo": 18567229,
    "dateCreated": "2026-07-07T12:33:41+00:00", "isIcebreaker": false
  }},
  "dateExpired": null, "id": "cf2a7395-31f7-40d5-8055-de94fb967178", "type": "MessageSent"
}

chat_DialogCreated — система створила діалог (несе idTrustedUser=lady, idRegularUser=man):

{
  "data": { "idDialog": 8371546107, "idRegularUser": 83366724, "idTrustedUser": 18567229 },
  "dateExpired": null, "id": "38a48c96-62e3-4fb0-9e6e-db79cc3206ad", "type": "chat_DialogCreated"
}

chat_DialogLimitChanged — зміна ліміту чату (несе поточний limitLeft, без дельти):

{
  "data": { "idInterlocutor": 83366724, "idUser": 18567229, "limitLeft": 10 },
  "dateExpired": null, "id": "42252d03-35d2-4b5a-828d-045506324258", "type": "chat_DialogLimitChanged"
}

platform_CorrespondenceLimitChanged — зміна ліміту пошти (та сама форма):

{
  "data": { "idInterlocutor": 83366724, "idUser": 18567229, "limitLeft": 2 },
  "dateExpired": null, "id": "b69b4669-8343-4943-87aa-2bbfd9857264", "type": "platform_CorrespondenceLimitChanged"
}

chat_DialogTyping — чоловік друкує:

{
  "data": { "idInterlocutor": 83366724 },
  "dateExpired": null, "id": "71be2541-69f2-4b13-81f3-47d7667ea7db", "type": "chat_DialogTyping"
}

chat_InterlocutorStatusChanged — статус чоловіка (несе status, isBookmarked):

{
  "data": { "idInterlocutor": 162874321, "status": "online", "isBookmarked": false, "changedAt": "2026-07-07T12:34:27.051315203Z" },
  "dateExpired": null, "id": "f760dec4-71d4-4c09-bd66-693e4554b85f", "type": "chat_InterlocutorStatusChanged"
}

email — вхідний лист (конверт БЕЗ data, з полем email і dateExpired):

{
  "email": {
    "id": 9950743543, "id_correspondence": 4103352319,
    "id_user_from": 83366724, "id_user_to": 18567229,
    "title": "test test ...", "content": "<p>test test test</p>",
    "countPhotos": 0, "countVideos": 0,
    "dateCreated": "2026-07-07T12:34:03+00:00"
  },
  "dateExpired": "2026-07-07T12:36:03Z", "id": "927752b1-119b-43ed-938c-1a951e9d1773", "type": "email"
}

Живі приклади — прод-реєстр (усі ТЮ, 20.07.2026)

Зібрано src/prime/socketTypeRegistry.ts на живому проді (лише унікальні type|shape), тому вибірка ширша за спостереження з однієї ТЮ вище.

chat_DialogHighlightingChanged — підсвітка діалогу. Форма плаває: при highlightType: "none" поля highlightExpireDate немає.

{ "data": { "idInterlocutor": 178957333, "highlightType": "limit_reload", "highlightExpireDate": "2026-07-20T10:28:10.811330274Z" }, "type": "chat_DialogHighlightingChanged" }
{ "data": { "idInterlocutor": 178957333, "highlightType": "none" }, "type": "chat_DialogHighlightingChanged" }

Blocked — payload плоский, без вкладеного message:

{ "data": { "idUserFrom": 173893126, "idUserTo": 34550873 }, "type": "Blocked" }

chat_MessageRead, chat_MessageVideoPurchased (платна дія — купівля відео):

{ "data": { "idInterlocutor": 167887968, "idMessage": 48067005602 }, "type": "chat_MessageRead" }
{ "data": { "idInterlocutor": 174351164, "idVideo": 9313430 }, "type": "chat_MessageVideoPurchased" }

chat_RequestLimitChanged — добовий ліміт chat-request’ів (не привʼязаний до діалогу):

{ "data": { "type": "chat_request", "limitsAvailable": 1, "dateAvailable": "2026-07-21T00:00:00+03:00" }, "type": "chat_RequestLimitChanged" }

chat_MessageDisplayAttributesApplied — модерація вже надісланого повідомлення (блюр):

{ "data": { "idMessage": 48067027424, "idUserFrom": 89519208, "idUserTo": 179358206,
  "displayAttributes": { "effect": "inappropriateBlur" }, "dateCreated": "2026-07-20T09:56:27.419072157Z" },
  "type": "chat_MessageDisplayAttributesApplied" }

gallery_PhotoProcessed / news-feed_PostModerated — модерація контенту ТЮ:

{ "data": { "idPhoto": 80058033, "idUser": 95847828, "key": "shpzkl61lkp8oa89u",
  "status": { "code": "approved", "description": "Approved" },
  "tags": [{ "code": "special_plus", "description": "Special+" }],
  "declineReasons": [], "comment": "", "dateProcessed": "2026-07-20T10:10:06+00:00" }, "type": "gallery_PhotoProcessed" }
{ "data": { "idPost": 21312680, "result": "approved_by_ai" }, "type": "news-feed_PostModerated" }

correspondence_LetterUpdated — доїхав переклад уже доставленого листа. Поля в snake_case (як в email), решта подій — camelCase. Має dateExpired (+2 хв):

{ "data": { "id": 9992185802, "id_correspondence": 3576311657,
  "id_user_from": 145125994, "id_user_to": 131954164,
  "title": "My darling, finally a beautiful smile...", "content": "<p>…</p>",
  "has_translation": true, "is_translation": true },
  "dateExpired": "2026-07-20T11:15:11Z", "type": "correspondence_LetterUpdated" }

MessageSent / reply_newsfeed_post — чоловік відповів на пост стрічки (підтипу не було в enum):

{ "data": { "message": { "id": 48068106836, "type": "reply_newsfeed_post",
  "content": { "idFeedPost": 829648457, "photos": [{ "id": 77630539, "key": "shpzklfolvc8khbv5", "url": "…" }],
    "photosTotalCount": 1, "text": "We desperately defend our ego from criticism…",
    "replyText": "Voilà une belle photo de toi…" },
  "idUserFrom": 120527807, "idUserTo": 6760244, "dateCreated": "2026-07-20T11:37:18+00:00",
  "isIcebreaker": false, "isReported": false, "hasTranslation": false, "isTranslation": false } },
  "type": "MessageSent" }

Знайдені баги (за прод-реєстром 20.07.2026)

  1. Blocked ніколи не знімає таски. Парсер бере data.data.message.idUserFrom, але payload Blocked плоский ({ idUserFrom, idUserTo }) — вкладеного message немає. data.data.messageundefined, звернення до .idUserFrom кидає TypeError, який ловиться локальним try/catch і лише пише в лог Error in remove tasks on blocked dialog. Тобто removeTaskOnBlockFromMan не виконується жодного разу. Правильно — data.data.idUserFrom (інтерфейс IPSPM_Blocked уже описує форму коректно). Побічно: manId_api на початку парсера рахується як data.data?.idInterlocutor || data.data?.message?.idUserFrom → для Blocked обидва undefined, тож пара не визначається.
  2. reply_newsfeed_post мовчки відкидається. Підтипу не було в PrimeSocketChatMessageType, а парсер пропускає лише if (chatMessageType in PrimeSocketChatMessageType) — такі MessageSent не дають ні таску, ні відправки на фронт. Відповідь чоловіка на пост стрічки для нас зараз не існує, хоча парний like_newsfeed_post обробляється. Підтип додано в enum закоментованим — свідомо, бо цей enum є рантайм-гардом і розкоментування одразу вмикає обробку: initSocketUnansweredTask (нові таски операторам) + відправка на фронт. На profit/фаворитів не впливає — checkNeedToBookmark пропускає лише message/photo/sticker/virtual_gift. Перед вмиканням оцінити добовий обсяг таких подій за socket-types.log, щоб не завалити операторів тасками.

Нюанси

  • chat_DialogLimitChanged шумний. Прилітає на кожну зміну ліміту — і коли чоловік пише (0→N), і коли пишемо ми (10→9→8…). У спостереженні 4 наші відповіді дали 4 події поспіль. Payload не містить дельти чи причини — з самої події не відрізнити «система відкрила ліміти» від «витратили ліміт». Тому >90% цих подій — шум переписки.
  • Чому socket-openLimit вимкнено. Коли він був увімкнений, кожен chat_DialogLimitChanged тригерив initSocketOpenLimitTask, а той робить getDialogs (партнерський API) до перевірки типу — тобто зайвий запит на кожну зміну ліміту × усі активні діалоги × усі ТУ. Історія комітів (14.03.2025) — серія вмик/вимк, залишили вимкненим.
  • Червоний OpenLimit ключиться на system-повідомлення. І поллінг openLimitTaskPrime, і initSocketOpenLimitTask створюють червоний таск лише коли dialog.lastMessage.type === system && messagesLeft. Надійний маркер «система відкрила ліміти» — це стан діалогу (REST getDialogs): lastMessage.type: "system" + highlightType: "limit_reload" + highlightExpireDate, а не сама сокет-подія.
  • highlightType: "limit_reload" приходить і по сокету — подією chat_DialogHighlightingChanged (прод-реєстр 20.07.2026), з тим самим highlightExpireDate, що й у REST. Тобто маркер відкриття лімітів не обовʼязково тягнути поллінгом getDialogs: chat_DialogHighlightingChanged з highlightType: "limit_reload" — це точковий, нешумний сигнал (на відміну від chat_DialogLimitChanged, де >90% — шум переписки), а highlightType: "none" закриває вікно. Подія зараз не обробляється (немає в enum-гілці парсера). Кандидат №1 замінити поллінг лімітів; треба перевірити на живому трафіку, що вона приходить у 100% випадків відкриття (і чи не приходить на інші види підсвітки).
  • Дедуп тасків — по eventTriggerTask.id (= lastMessage.id; для system id=0 нормалізується в =<idUser><idInterlocutor>), тож поллінг і сокет не дублюють один одного для однакового стану. Плюс in-memory createdVipTaskKeys проти дублю з VIP-таском (не переживає рестарт).
  • Лист по сокету бачимо, але таск не робимо — email-гілка кличе (закоментований) initSocketOpenLimitTask; mail-таск реально створює лише поллінг mailTaskPrime (~40 сек). Дірка.

Зв’язки

  • Парсинг: src/prime/primeSocketParser/PrimeSocketParser.ts + типи IPrimeSocketParser.interface.ts (PrimeSocketParsedMessageType, PrimeLocalSocketMessageType).
  • Обробка в таски: src/prime/components/tasks/socketTaskPrime.ts.
  • Поллінг-аналог для лімітів: src/prime/components/tasks/openLimitTaskPrime.ts.
  • Задокументовано за живими логами: src/prime/rtmDebugLogger.ts (важкий дамп кожної події, лише поза продом — RTM_DEBUG = NODE_ENV !== 'production') та src/prime/socketTypeRegistry.ts (прод-безпечний реєстр унікальних type|shapelogs/socket-types.log).

Заповнити

  • Чи прилітає chat_DialogLimitChanged у момент, коли система (айсбрейкер) відкриває ліміти новому чоловіку (0→N з system-повідомленням)? — не спостерігали наживо.
  • Чи chat_DialogHighlightingChanged з limit_reload покриває 100% випадків відкриття лімітів (див. нюанси) — від цього залежить, чи можна прибрати поллінг getDialogs.
  • Форма payload для все ще не бачених типів: Unblocked, virtual-gift_GiftLimitUpdated, streaming_StreamStarted, video-call_SessionStarted / video-call_SessionStopped, а також підтипу virtual_gift у MessageSent.
  • Що саме носять broadcast / online (чи вони взагалі використовуються).
  • Повний перелік значень: highlightType, status (chat_InterlocutorStatusChanged), result (news-feed_PostModerated), status.code/tags[].code (gallery_PhotoProcessed), effect (displayAttributes) — бачили лише по 1-2 значення кожного.

Вибірка 20.07.2026 обрізана на середині рядка (останній запис — тип на Un…, найімовірніше Unblocked). Реєстр продовжує писати лише нові type|shape, тож повторний знімок за кілька діб добере рідкісні події.