SHA256
578 lines
18 KiB
Markdown
578 lines
18 KiB
Markdown
# 06. Channels Read API
|
||
|
||
## Человеко-читаемое объяснение
|
||
Эти функции — это **чтение данных каналов** для UI:
|
||
|
||
1. `ListSubscriptionsFeed` — отдает данные для экрана списка каналов:
|
||
- ваши каналы (личный + созданные вами),
|
||
- каналы пользователей, на кого вы подписаны,
|
||
- отдельные каналы, на которые вы подписаны напрямую.
|
||
|
||
2. `GetChannelMessages` — отдает полную ленту одного канала (пока без курсоров, загружается сразу целиком),
|
||
включая версии сообщений, лайки и ответы.
|
||
|
||
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
||
предки, фокус-сообщение, потомки.
|
||
|
||
4. `GetMessageLikes` — отдает списки пользователей, поставивших лайк сообщению,
|
||
сгруппированные для UI по статусу профиля.
|
||
|
||
5. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
||
|
||
6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||
|
||
7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик.
|
||
|
||
8. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||
|
||
9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||
|
||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||
>
|
||
> `unreadCount` для канала считается по подписанному состоянию чтения канала.
|
||
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||
|
||
---
|
||
|
||
## 1) ListSubscriptionsFeed
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "ListSubscriptionsFeed",
|
||
"requestId": "req-1",
|
||
"payload": {
|
||
"login": "Alice",
|
||
"limit": 200
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "ListSubscriptionsFeed",
|
||
"requestId": "req-1",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"login": "Alice",
|
||
"ownedChannels": [
|
||
{
|
||
"channel": {
|
||
"ownerLogin": "Alice",
|
||
"ownerBlockchainName": "alice-001",
|
||
"channelName": "0",
|
||
"displayName": "Мой канал",
|
||
"channelDescription": "Короткое описание",
|
||
"avaAr": "ArweaveTxId...",
|
||
"avaSha256": "0123...",
|
||
"avaSize": 248193,
|
||
"metaUpdatedAtMs": 1760000000000,
|
||
"personal": true,
|
||
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||
},
|
||
"messagesCount": 120,
|
||
"lastMessage": {
|
||
"messageRef": { "blockNumber": 921, "blockHash": "..." },
|
||
"text": "последняя версия текста",
|
||
"createdAtMs": 1760000000000,
|
||
"authorLogin": "Alice",
|
||
"authorBlockchainName": "alice-001"
|
||
}
|
||
}
|
||
],
|
||
"followedUsersChannels": [
|
||
{
|
||
"channel": {
|
||
"ownerLogin": "Bob",
|
||
"ownerBlockchainName": "bob-001",
|
||
"channelName": "0",
|
||
"personal": true,
|
||
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||
},
|
||
"messagesCount": 540,
|
||
"lastMessage": {
|
||
"messageRef": { "blockNumber": 922, "blockHash": "..." },
|
||
"text": "последняя версия текста",
|
||
"createdAtMs": 1760000100000,
|
||
"authorLogin": "Bob",
|
||
"authorBlockchainName": "bob-001"
|
||
}
|
||
}
|
||
],
|
||
"followedChannels": [
|
||
{
|
||
"channel": {
|
||
"ownerLogin": "Carl",
|
||
"ownerBlockchainName": "carl-001",
|
||
"channelName": "market",
|
||
"personal": false,
|
||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||
},
|
||
"messagesCount": 90,
|
||
"readCount": 0,
|
||
"unreadCount": 0,
|
||
"readStateInitialized": false,
|
||
"lastMessage": {
|
||
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||
"text": "актуальный текст",
|
||
"createdAtMs": 1760001000000,
|
||
"authorLogin": "Carl",
|
||
"authorBlockchainName": "carl-001"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2) GetChannelMessages
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "GetChannelMessages",
|
||
"requestId": "req-2",
|
||
"payload": {
|
||
"channel": {
|
||
"ownerBlockchainName": "bob-001",
|
||
"channelRootBlockNumber": 123,
|
||
"channelRootBlockHash": "..."
|
||
},
|
||
"limit": 200,
|
||
"sort": "asc"
|
||
}
|
||
}
|
||
```
|
||
|
||
`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов.
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "GetChannelMessages",
|
||
"requestId": "req-2",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"channel": {
|
||
"ownerLogin": "Bob",
|
||
"ownerBlockchainName": "bob-001",
|
||
"channelName": "news",
|
||
"displayName": "Новости Bob",
|
||
"channelDescription": "Канал о новостях",
|
||
"avaAr": "ArweaveTxId...",
|
||
"avaSha256": "0123...",
|
||
"avaSize": 248193,
|
||
"metaUpdatedAtMs": 1760000000000,
|
||
"subscribersCount": 128,
|
||
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
||
},
|
||
"metaEvents": [
|
||
{
|
||
"kind": "created",
|
||
"messageRef": { "blockNumber": 123, "blockHash": "..." },
|
||
"authorLogin": "Bob",
|
||
"authorBlockchainName": "bob-001",
|
||
"lineStep": 1,
|
||
"createdAtMs": 1760000000000,
|
||
"title": "news",
|
||
"description": "",
|
||
"avaAr": "",
|
||
"avaSha256": "",
|
||
"avaSize": 0
|
||
},
|
||
{
|
||
"kind": "profile_updated",
|
||
"messageRef": { "blockNumber": 130, "blockHash": "..." },
|
||
"authorLogin": "Bob",
|
||
"authorBlockchainName": "bob-001",
|
||
"lineStep": 2,
|
||
"createdAtMs": 1760000500000,
|
||
"title": "Новости Bob",
|
||
"description": "Канал о новостях",
|
||
"avaAr": "ArweaveTxId...",
|
||
"avaSha256": "0123...",
|
||
"avaSize": 248193
|
||
}
|
||
],
|
||
"messages": [
|
||
{
|
||
"messageRef": { "blockNumber": 140, "blockHash": "..." },
|
||
"authorLogin": "Bob",
|
||
"authorBlockchainName": "bob-001",
|
||
"createdAtMs": 1760000000000,
|
||
"text": "текущая версия",
|
||
"likesCount": 12,
|
||
"repliesCount": 3,
|
||
"ratingsCount": 2,
|
||
"versionsTotal": 4,
|
||
"versions": [
|
||
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
|
||
{ "versionIndex": 2, "blockNumber": 155, "blockHash": "...", "text": "v2", "createdAtMs": 1760001000000 },
|
||
{ "versionIndex": 3, "blockNumber": 170, "blockHash": "...", "text": "v3", "createdAtMs": 1760002000000 },
|
||
{ "versionIndex": 4, "blockNumber": 199, "blockHash": "...", "text": "v4", "createdAtMs": 1760003000000 }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3) GetMessageThread
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "GetMessageThread",
|
||
"requestId": "req-3",
|
||
"payload": {
|
||
"message": {
|
||
"blockchainName": "bob-001",
|
||
"blockNumber": 333,
|
||
"blockHash": "..."
|
||
},
|
||
"depthUp": 20,
|
||
"depthDown": 2,
|
||
"limitChildrenPerNode": 50
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "GetMessageThread",
|
||
"requestId": "req-3",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"ancestors": [MessageNode],
|
||
"focus": MessageNode,
|
||
"descendants": [MessageNodeTree]
|
||
}
|
||
}
|
||
```
|
||
|
||
### MessageNode (дополнение)
|
||
- `MessageNode` расширяет формат сообщения из `GetChannelMessages` и дополнительно содержит:
|
||
- `channelInfo` — мета-информация о канале (если применимо);
|
||
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
|
||
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
|
||
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
|
||
- И в `GetChannelMessages`, и в `GetMessageThread` каждое сообщение теперь содержит:
|
||
- `repliesCount` — число дочерних сообщений типа `TEXT_REPLY`;
|
||
- `ratingsCount` — число дочерних сообщений типа `TEXT_RATING`.
|
||
- В `descendants[]` операции `GetMessageThread` возвращаются оба типа дочерних текстовых сообщений:
|
||
- `TEXT_REPLY`;
|
||
- `TEXT_RATING`.
|
||
Они идут в одной общей ветке обсуждения и сортируются по времени создания.
|
||
|
||
---
|
||
|
||
## 4) GetMessageLikes
|
||
|
||
Возвращает пользователей, которые поставили лайк конкретному сообщению канала.
|
||
|
||
- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение.
|
||
- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков.
|
||
- Пользователи группируются по состоянию профиля:
|
||
- `shining` — `account_role=primary` и `shine_status=shining`;
|
||
- `official` — `account_role=primary`, но без `shine_status=shining`;
|
||
- `others` — остальные пользователи.
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "GetMessageLikes",
|
||
"requestId": "req-4",
|
||
"payload": {
|
||
"message": {
|
||
"blockchainName": "bob-001",
|
||
"blockNumber": 140,
|
||
"blockHash": "..."
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "GetMessageLikes",
|
||
"requestId": "req-4",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"shining": [
|
||
{ "login": "Alice", "firstName": "Alice", "lastName": "", "avatarAr": "ArweaveTxId..." }
|
||
],
|
||
"official": [],
|
||
"others": [
|
||
{ "login": "Carl", "firstName": "", "lastName": "", "avatarAr": "" }
|
||
],
|
||
"total": 2,
|
||
"truncated": false
|
||
}
|
||
}
|
||
```
|
||
|
||
### Ошибки
|
||
- `bad_fields` — не передан `message` или обязательные поля ссылки на сообщение.
|
||
- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока.
|
||
- `internal_error` — внутренняя ошибка чтения.
|
||
|
||
`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией.
|
||
|
||
---
|
||
|
||
## 5) GetPersonalDiary
|
||
|
||
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
||
|
||
- Вызов доступен только владельцу дневника.
|
||
- Сообщения в ответе строятся из блоков `STATUS_ACTION`.
|
||
- Поля `targetMsgSubType`, `targetText`, `targetAuthorLogin`, `targetAuthorBlockchainName`, `targetCreatedAtMs` описывают исходный материал, к которому относится действие.
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "GetPersonalDiary",
|
||
"requestId": "req-4",
|
||
"payload": {
|
||
"login": "Alice",
|
||
"limit": 200,
|
||
"sort": "asc"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6) GetChannelsCounters
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "GetChannelsCounters",
|
||
"requestId": "req-4",
|
||
"payload": {
|
||
"login": "Alice"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "GetChannelsCounters",
|
||
"requestId": "req-4",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"login": "Alice",
|
||
"feedCount": 12,
|
||
"dialogs100Count": 3,
|
||
"groupChats200Count": 4,
|
||
"myChannelsCount": 2
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7) SetChannelReadState
|
||
|
||
Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан.
|
||
|
||
- Требует авторизованное WebSocket-соединение.
|
||
- `login` должен совпадать с текущим авторизованным пользователем.
|
||
- Подпись строится client key пользователя по строке:
|
||
|
||
```text
|
||
SHiNe/ChannelReadState:<login>|<owner_bch_name>|<channel_name>|<time_ms>|<read_count>
|
||
```
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "SetChannelReadState",
|
||
"requestId": "req-7",
|
||
"payload": {
|
||
"login": "Alice",
|
||
"owner_bch_name": "bob-001",
|
||
"channel_name": "news",
|
||
"read_count": 90,
|
||
"time_ms": 1760000000000,
|
||
"client_key": "<base64-client-public-key>",
|
||
"signature": "<base64-ed25519-signature>"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "SetChannelReadState",
|
||
"requestId": "req-7",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"login": "Alice",
|
||
"owner_bch_name": "bob-001",
|
||
"channel_name": "news",
|
||
"read_count": 90,
|
||
"unread_count": 0,
|
||
"time_ms": 1760000000000,
|
||
"applied": true
|
||
}
|
||
}
|
||
```
|
||
|
||
### Ошибки
|
||
- `NOT_AUTHENTICATED` — нет авторизованной сессии.
|
||
- `BAD_FIELDS` — не переданы обязательные поля.
|
||
- `LOGIN_MISMATCH` — `login` не совпадает с текущей сессией.
|
||
- `BAD_TIME` — время подписи слишком далеко в будущем.
|
||
- `BAD_BASE64` — `client_key` или `signature` не являются корректным Base64.
|
||
- `DEVICE_KEY_MISMATCH` — `client_key` не совпадает с текущим client key пользователя.
|
||
- `INVALID_SIGNATURE` — подпись watermark не прошла проверку.
|
||
- `CHANNEL_NOT_FOLLOWED` — пользователь не подписан на канал.
|
||
- `CHANNEL_NOT_FOUND` — канал не найден.
|
||
- `INTERNAL_ERROR` — внутренняя ошибка записи.
|
||
|
||
---
|
||
|
||
## 8) ListGroupChats200
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "ListGroupChats200",
|
||
"requestId": "req-5",
|
||
"payload": {
|
||
"login": "Alice"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "ListGroupChats200",
|
||
"requestId": "req-5",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"login": "Alice",
|
||
"chats": [
|
||
{
|
||
"ownerLogin": "Alice",
|
||
"ownerBlockchainName": "alice-001",
|
||
"channelRootBlockNumber": 123,
|
||
"channelRootBlockHash": "...",
|
||
"channelName": "team",
|
||
"chatTitle": "Team chat",
|
||
"membersCount": 3,
|
||
"updatedAtMs": 1760000000000
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9) GetGroupDialog
|
||
|
||
### Request
|
||
```json
|
||
{
|
||
"op": "GetGroupDialog",
|
||
"requestId": "req-6",
|
||
"payload": {
|
||
"login": "Alice",
|
||
"group": {
|
||
"ownerBlockchainName": "alice-001",
|
||
"channelRootBlockNumber": 123
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response (success)
|
||
```json
|
||
{
|
||
"op": "GetGroupDialog",
|
||
"requestId": "req-6",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"group": {
|
||
"ownerLogin": "Alice",
|
||
"ownerBlockchainName": "alice-001",
|
||
"channelRootBlockNumber": 123,
|
||
"channelName": "team",
|
||
"chatTitle": "Team chat"
|
||
},
|
||
"messages": [
|
||
{
|
||
"authorLogin": "Bob",
|
||
"authorBlockchainName": "bob-001",
|
||
"blockNumber": 140,
|
||
"blockHash": "...",
|
||
"createdAtMs": 1760000000000,
|
||
"text": "Привет"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Reason codes
|
||
- `bad_fields`
|
||
- `user_not_found`
|
||
- `channel_not_found`
|
||
- `message_not_found`
|
||
- `bad_limit`
|
||
- `channel_name_already_exists`
|
||
- `CHANNEL_NOT_FOLLOWED`
|
||
- `CHANNEL_NOT_FOUND`
|
||
- `internal_error`
|
||
|
||
---
|
||
|
||
## Profile public-channel lists (v18)
|
||
|
||
### `ListUserProfileChannels`
|
||
|
||
Returns lightweight public-channel cards for profile counters. `mode` is `owned` or `following`; only `channel_type_code=1` is returned.
|
||
|
||
```json
|
||
{
|
||
"op": "ListUserProfileChannels",
|
||
"requestId": "profile-ch-1",
|
||
"payload": { "login": "Alice", "mode": "owned", "limit": 100, "offset": 0 }
|
||
}
|
||
```
|
||
|
||
Channel items contain `ownerLogin`, `slug`, `displayName`, `avatarAr`, `ownerBlockchainName`, `rootBlockNumber`, and `rootBlockHashHex`.
|
||
|
||
## Qualified likes (v18)
|
||
|
||
Channel/message responses expose three counters:
|
||
|
||
- `likesCount` — all active likes;
|
||
- `primaryLikesCount` — likes from users whose current `account_role=primary`;
|
||
- `shiningLikesCount` — likes from users who are currently both `primary` and `shining`.
|
||
|
||
Changing `account_role` or `shine` does not alter the raw reaction. The two qualified counters are recalculated from `reactions_state`; therefore old likes automatically enter or leave qualified counters when the actor's current status changes.
|