From b3f44188908f5dc1802a9b740641068af33262ea Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 26 Jul 2026 17:37:46 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20backend=20=D0=B1=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banners-informer.md | 154 ++++++++++++++ lib/backend/modules/account.dart | 12 ++ lib/backend/modules/animoji.dart | 19 ++ lib/backend/modules/banners.dart | 336 ++++++++++++++++++++++++++++++ lib/core/protocol/opcode_map.dart | 2 + lib/main.dart | 1 + lib/models/informer_banner.dart | 116 +++++++++++ 7 files changed, 640 insertions(+) create mode 100644 banners-informer.md create mode 100644 lib/backend/modules/banners.dart create mode 100644 lib/models/informer_banner.dart diff --git a/banners-informer.md b/banners-informer.md new file mode 100644 index 0000000..2e785c8 --- /dev/null +++ b/banners-informer.md @@ -0,0 +1,154 @@ +# Баннеры (informer) в web-версии MAX + +Баннеры вроде «Публичные каналы — для всех» внутри кода называются **informer**. +Они **полностью приходят с сервера** по WebSocket запросом с опкодом `302`. +В JS-бандлах текста баннеров нет — локально хранятся только счётчики показов. + +Исходники: `dump/web.max.ru/_app/immutable/chunks/BWjA24QQ.js` (логика), +`_app/immutable/nodes/9.Dqm5zlvE.js` (рендер тайла + аналитика). + +--- + +## Как это работает + +1. В общем sync-ответе сессии сервер выставляет бит `1` в bitmask-поле `updates`: + ```js + e.updates && new Pu(e.updates).get(1) && await J.banners.resync() + ``` +2. Клиент шлёт запрос с опкодом **302**: + ```js + Ah = Y(async ({bannersSync:e}, {send:t}) => await t(302, {bannersSync:e})) + // resync(): let e = await Ah({bannersSync:0}); await this.upsert(e); + ``` +3. Сервер отвечает `{showTime, updateTime, banners:[…]}`. +4. Какой баннер показать — считается на клиенте (`activeBanner`): фильтр `canShow`, + затем выбор с максимальным `priority`. +5. Весь механизм гейтится серверным конфигом `informer-enabled`. +6. Состояние показов хранится в `localStorage['__oneme_informer']`: + ```js + informerState = { lastShowedBannerId: null, banners: { /* id -> {showCounter, closedTime} */ } } + ``` + +--- + +## Пример ответа сервера + +```json +{ + "ver": 10, + "cmd": 1, + "seq": 14, + "opcode": 302, + "payload": { + "showTime": 86400000, + "updateTime": 1784123975588, + "banners": [ + { + "id": "public_channels_cid_test", + "title": "Публичные каналы — для всех", + "settings": 1, + "description": "Привлекайте новую аудиторию", + "priority": 16, + "repeat": 1, + "rerun": 5400000000000000, + "animojiId": 1446, + "url": "https://max.ru/max_news/AZ9kk1KhJAo", + "type": 1 + } + ] + } +} +``` + +--- + +## Конверт (обёртка WS-сообщения) + +| Поле | Значение | На что влияет | +|---|---|---| +| `ver` | 10 | Версия протокола WebSocket. | +| `cmd` | 1 | Тип пакета. `1` = ответ на запрос (response), не server-push. | +| `seq` | 14 | Номер запроса. Клиент послал `302 {bannersSync:0}` под этим seq — сервер отвечает тем же seq для матчинга. | +| `opcode` | 302 | Код операции = синхронизация баннеров/informer. | +| `payload` | {…} | Полезная нагрузка, попадает в `banners.upsert(e)`. | + +## payload — верхний уровень + +**`showTime`: 86400000** (24 ч в мс) +Сколько времени баннер считается «свежим» после первого показа. +```js +isShowTimeOver = ka.now - this.showAt > this.showTime +``` +Прошло больше `showTime` с момента первого показа → `canShow` = false, баннер пропадает. + +**`updateTime`: 1784123975588** (unix-мс) +Версия набора баннеров. Новые данные применяются только если пришло свежее: +```js +e.banners && e.updateTime > this.informer.updateTime && (…apply…) +``` + +## Объект баннера + +**`id`: `"public_channels_cid_test"`** +Уникальный ключ. По нему хранятся `showCounter` и `closedTime` в `localStorage['__oneme_informer']` +и запоминается `lastShowedBannerId` (чтобы не показать один баннер подряд дважды). +Суффикс `_test` = тестовый баннер. + +**`title`: «Публичные каналы — для всех»** — верхняя (цветная) строка тайла. + +**`description`: «Привлекайте новую аудиторию»** — вторая строка. Оба текста только с сервера. + +**`settings`: 1** — битовая маска флагов отображения (`new Pu(settings)`). Биты: +- `1` = TEXT_ANIMATION — анимация текста +- `2` = HIDE_CLOSE_BUTTON — скрыть крестик +- `4` = HIDE_ON_CLICK — закрывать по клику +- `8` = ICON_THEME_COLOR — красить иконку в цвет темы + +Здесь `1` → только анимация текста; крестик «×» есть, клик ведёт по ссылке. + +**`priority`: 16** +Приоритет при нескольких баннерах — показывается один с максимальным: +```js +e.reduce((a,b) => b.priority > a.priority ? b : a) +``` +При одном баннере роли не играет. + +**`repeat`: 1** +Максимум показов: +```js +hasRemainingShows = showCounter===0 ? true : showCounter < repeat +``` +`1` → показать ровно один раз. + +**`rerun`: 5400000000000000** +Кулдаун (мс) перед повторным показом после закрытия крестиком: +```js +isCloseCooldownOver = closedAt ? (rerun>0 ? Date.now()-closedAt > rerun : false) : true +``` +Значение огромное (~171 000 лет) → «закрыл — больше не увидишь». + +**`animojiId`: 1446** +ID аниможи-иконки слева в тайле: +```js +J.assets.animojisCache.getLazy(bu.resolveId(this.$.animojiId)) +``` + +**`url`: `https://max.ru/max_news/AZ9kk1KhJAo`** +Куда ведёт клик (вместе с `type:1`). + +**`type`: 1** — тип баннера: +- `0` TYPE_TEXT — просто текст +- `1` TYPE_LINK — кликабельный, открывает `url` ← этот случай +- `2` TYPE_UPDATE — баннер обновления приложения + +--- + +## Итог по этому баннеру + +Тестовый кликабельный баннер про публичные каналы: показывается один раз (`repeat:1`), +с анимацией текста (`settings:1`), «живёт» сутки после показа (`showTime`), по клику +открывает `max.ru/max_news/...`; если закрыть крестиком — не возвращается практически +никогда (`rerun`). Приоритет 16 нужен только при конкуренции нескольких баннеров. + +Убрать/подменить баннер локально можно, перехватив ответ на опкод `302` +либо отключив серверный флаг `informer-enabled`. diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 7863238..77fc950 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -11,6 +11,7 @@ import '../../core/storage/app_database.dart'; import '../../core/storage/spoofing_service.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; +import 'banners.dart'; import 'chats.dart'; import 'complaints.dart'; import 'contacts.dart'; @@ -41,6 +42,7 @@ class AccountModule { late final PrivacyModule _privacy = PrivacyModule(_api); late final ProfileModule _profile = ProfileModule(_api); late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile); + late final BannersModule banners = BannersModule(_api); final _loginStatusController = StreamController.broadcast(); final _noticeController = StreamController.broadcast(); bool _loggedIn = false; @@ -331,6 +333,7 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + banners.clear(); chats.resetForAccountSwitch(); logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); @@ -345,6 +348,7 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + banners.clear(); chats.resetForAccountSwitch(); await _api.connect(); @@ -376,6 +380,7 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + banners.clear(); chats.resetForAccountSwitch(); await ContactsModule.primeCacheFromDb(accountId); @@ -426,6 +431,7 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + banners.clear(); chats.resetForAccountSwitch(); } @@ -611,6 +617,12 @@ class AccountModule { } await chats.applyFavorites(profile.id); + try { + await banners.initFromLogin(profile.id, data); + } catch (e) { + logger.w('Баннеры: $e'); + } + try { await _saveLoginInfo(data, profile.id); } catch (e) { diff --git a/lib/backend/modules/animoji.dart b/lib/backend/modules/animoji.dart index ba541ab..13b74a8 100644 --- a/lib/backend/modules/animoji.dart +++ b/lib/backend/modules/animoji.dart @@ -70,6 +70,25 @@ class AnimojiModule { return list.length <= 6 ? list : list.sublist(0, 6); } + Animoji? cached(int id) => _byId[id]; + + Future fetchById(int id) async { + final known = _byId[id]; + if (known != null) return known; + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI', + 'ids': [id], + }); + final list = map?['animojis']; + if (list is! List) return null; + for (final e in list) { + if (e is! Map) continue; + final animoji = Animoji.fromMap(e); + if (animoji != null) _byId[animoji.id] = animoji; + } + return _byId[id]; + } + Future ensureLoaded() { return _loading ??= _load().catchError((Object e) { _loading = null; diff --git a/lib/backend/modules/banners.dart b/lib/backend/modules/banners.dart new file mode 100644 index 0000000..3aa5b1e --- /dev/null +++ b/lib/backend/modules/banners.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../models/informer_banner.dart'; + +class BannersModule { + static const _snapshotKey = 'informer_banners'; + static const _stateKey = 'informer_state'; + static const _enabledKey = 'informer_enabled'; + static const _serverFlag = 'informer-enabled'; + static const int _resyncMask = 1; + static const int _defaultShowTime = 86400000; + + final Api _api; + + BannersModule(this._api) { + _api.pushStream + .where((p) => p.opcode == Opcode.notifBanners) + .listen(_handlePush); + } + + final ValueNotifier activeBanner = ValueNotifier(null); + + int? _accountId; + bool _enabled = true; + int _updateTime = 0; + int _showTime = _defaultShowTime; + List _banners = const []; + final Map _showState = {}; + String? _lastShownId; + String? _pinnedId; + Future? _syncing; + + bool get isEnabled => _enabled; + int get showTime => _showTime; + List get banners => List.unmodifiable(_banners); + + BannerShowState stateOf(String bannerId) => + _showState[bannerId] ?? const BannerShowState(); + + Future initFromLogin( + int accountId, + Map loginData, + ) async { + await load(accountId); + + final config = loginData['config']; + final serverConfig = config is Map ? config['server'] : null; + if (serverConfig is Map && serverConfig.containsKey(_serverFlag)) { + await _setEnabled(accountId, _parseFlag(serverConfig[_serverFlag])); + } + if (!_enabled) return; + + var applied = false; + final inline = loginData['banners']; + if (inline is Map) { + applied = await applyPayload(accountId, inline.cast()); + } + + if (applied && !_needsResync(loginData['updates'])) return; + await syncFromServer(); + } + + Future load(int accountId) async { + _accountId = accountId; + _pinnedId = null; + await _restoreSnapshot(accountId); + await _restoreShowState(accountId); + final enabled = await AppDatabase.getSyncValue(accountId, _enabledKey); + _enabled = enabled != '0'; + _recompute(); + } + + Future syncFromServer() { + return _syncing ??= _sync().whenComplete(() => _syncing = null); + } + + Future applyPayload( + int accountId, + Map payload, + ) async { + final raw = payload['banners']; + if (raw is! List) return false; + + final updateTime = _int(payload['updateTime']); + if (_updateTime != 0 && updateTime != null && updateTime <= _updateTime) { + return true; + } + + final parsed = []; + for (final entry in raw) { + if (entry is! Map) continue; + final banner = InformerBanner.fromMap(entry); + if (banner != null) parsed.add(banner); + } + + _banners = parsed; + if (updateTime != null) _updateTime = updateTime; + final showTime = _int(payload['showTime']); + if (showTime != null && showTime > 0) _showTime = showTime; + + final known = parsed.map((b) => b.id).toSet(); + _showState.removeWhere((id, _) => !known.contains(id)); + if (_pinnedId != null && !known.contains(_pinnedId)) _pinnedId = null; + if (_lastShownId != null && !known.contains(_lastShownId)) { + _lastShownId = null; + } + + await _persistSnapshot(accountId); + await _persistShowState(accountId); + _recompute(); + return true; + } + + Future markShown(InformerBanner banner) async { + final now = DateTime.now().millisecondsSinceEpoch; + final current = stateOf(banner.id); + _showState[banner.id] = current.copyWith( + showCounter: current.showCounter + 1, + showAt: current.showAt ?? now, + ); + _lastShownId = banner.id; + _pinnedId = banner.id; + await _persistShowState(_accountId); + } + + Future close(InformerBanner banner) async { + final now = DateTime.now().millisecondsSinceEpoch; + _showState[banner.id] = stateOf(banner.id).copyWith(closedAt: now); + if (_pinnedId == banner.id) _pinnedId = null; + await _persistShowState(_accountId); + _recompute(); + } + + Future markClicked(InformerBanner banner) async { + if (!banner.closesOnClick) return; + await close(banner); + } + + void refresh() { + _pinnedId = null; + _recompute(); + } + + void clear() { + _accountId = null; + _enabled = true; + _updateTime = 0; + _showTime = _defaultShowTime; + _banners = const []; + _showState.clear(); + _lastShownId = null; + _pinnedId = null; + activeBanner.value = null; + } + + Future _sync() async { + final accountId = _accountId; + if (accountId == null || !_enabled) return; + try { + final packet = await _api.sendRequest(Opcode.bannersSync, { + 'bannersSync': 0, + }); + throwIfPacketError(packet); + final payload = packet.payload; + if (payload is Map) { + await applyPayload(accountId, payload.cast()); + } + } catch (e) { + logger.w('Баннеры: синхронизация не удалась: $e'); + } + } + + void _handlePush(Packet packet) { + final accountId = _accountId; + if (accountId == null || !_enabled) return; + final payload = packet.payload; + if (payload is Map && payload['banners'] is List) { + unawaited(applyPayload(accountId, payload.cast())); + return; + } + unawaited(syncFromServer()); + } + + void _recompute() { + if (!_enabled) { + activeBanner.value = null; + return; + } + + final pinned = _pinnedId; + if (pinned != null) { + for (final banner in _banners) { + if (banner.id == pinned) { + activeBanner.value = banner; + return; + } + } + _pinnedId = null; + } + + final now = DateTime.now().millisecondsSinceEpoch; + var candidates = _banners.where((b) => _canShow(b, now)).toList(); + if (candidates.length > 1 && _lastShownId != null) { + final others = candidates.where((b) => b.id != _lastShownId).toList(); + if (others.isNotEmpty) candidates = others; + } + if (candidates.isEmpty) { + activeBanner.value = null; + return; + } + activeBanner.value = candidates.reduce( + (a, b) => b.priority > a.priority ? b : a, + ); + } + + bool _canShow(InformerBanner banner, int now) { + final state = stateOf(banner.id); + if (state.showCounter > 0 && state.showCounter >= banner.repeat) { + return false; + } + final showAt = state.showAt; + if (showAt != null && now - showAt > _showTime) return false; + final closedAt = state.closedAt; + if (closedAt != null) { + if (banner.rerun <= 0) return false; + if (now - closedAt <= banner.rerun) return false; + } + return true; + } + + bool _needsResync(dynamic updates) { + final mask = _int(updates); + return mask != null && mask & _resyncMask != 0; + } + + Future _setEnabled(int accountId, bool value) async { + _enabled = value; + await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0'); + if (!value) activeBanner.value = null; + } + + Future _restoreSnapshot(int accountId) async { + _banners = const []; + _updateTime = 0; + _showTime = _defaultShowTime; + final raw = await AppDatabase.getSyncValue(accountId, _snapshotKey); + if (raw == null || raw.isEmpty) return; + try { + final map = jsonDecode(raw) as Map; + final list = map['banners']; + final parsed = []; + if (list is List) { + for (final entry in list) { + if (entry is! Map) continue; + final banner = InformerBanner.fromMap(entry); + if (banner != null) parsed.add(banner); + } + } + _banners = parsed; + _updateTime = _int(map['updateTime']) ?? 0; + final showTime = _int(map['showTime']); + if (showTime != null && showTime > 0) _showTime = showTime; + } catch (_) {} + } + + Future _restoreShowState(int accountId) async { + _showState.clear(); + _lastShownId = null; + final raw = await AppDatabase.getSyncValue(accountId, _stateKey); + if (raw == null || raw.isEmpty) return; + try { + final map = jsonDecode(raw) as Map; + _lastShownId = map['lastShowedBannerId']?.toString(); + final entries = map['banners']; + if (entries is Map) { + entries.forEach((key, value) { + if (value is Map) { + _showState[key.toString()] = BannerShowState.fromJson(value); + } + }); + } + } catch (_) {} + } + + Future _persistSnapshot(int accountId) async { + await AppDatabase.setSyncValue( + accountId, + _snapshotKey, + jsonEncode({ + 'updateTime': _updateTime, + 'showTime': _showTime, + 'banners': _banners.map((b) => b.toJson()).toList(), + }), + ); + } + + Future _persistShowState(int? accountId) async { + if (accountId == null) return; + await AppDatabase.setSyncValue( + accountId, + _stateKey, + jsonEncode({ + 'lastShowedBannerId': _lastShownId, + 'banners': { + for (final entry in _showState.entries) + entry.key: entry.value.toJson(), + }, + }), + ); + } + + static bool _parseFlag(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + final v = value.trim().toLowerCase(); + return v == 'true' || v == '1' || v == 'yes' || v == 'on'; + } + return true; + } + + static int? _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 19b882a..1f5cab1 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -196,6 +196,7 @@ abstract class Opcode { static const int profileDeleteTime = 200; // Таймер удаления профиля static const int authQrApprove = 290; // Подтверждение QR-входа static const int chatSuggest = 300; // Предложения чатов + static const int bannersSync = 302; // Синхронизация баннеров (informer) // ── Polls ────────────────────────────────────────────────────────── static const int sendVote = 304; // Голосование @@ -378,6 +379,7 @@ abstract class Opcode { profileDeleteTime: 'PROFILE_DELETE_TIME', authQrApprove: 'AUTH_QR_APPROVE', chatSuggest: 'CHAT_SUGGEST', + bannersSync: 'BANNERS_SYNC', sendVote: 'SEND_VOTE', votersListByAnswer: 'VOTERS_LIST_BY_ANSWER', getPollUpdates: 'GET_POLL_UPDATES', diff --git a/lib/main.dart b/lib/main.dart index aa69a9d..fceafe3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -100,6 +100,7 @@ final webAppModule = WebAppModule(api); final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); final storiesModule = StoriesModule(api); +final bannersModule = accountModule.banners; final RouteObserver> appRouteObserver = RouteObserver>(); diff --git a/lib/models/informer_banner.dart b/lib/models/informer_banner.dart new file mode 100644 index 0000000..86a76a2 --- /dev/null +++ b/lib/models/informer_banner.dart @@ -0,0 +1,116 @@ +abstract class BannerType { + static const int text = 0; + static const int link = 1; + static const int update = 2; +} + +abstract class BannerSettings { + static const int textAnimation = 1; + static const int hideCloseButton = 2; + static const int hideOnClick = 4; + static const int iconThemeColor = 8; +} + +class InformerBanner { + final String id; + final String title; + final String description; + final int settings; + final int priority; + final int repeat; + final int rerun; + final int? animojiId; + final String? url; + final int type; + + const InformerBanner({ + required this.id, + required this.title, + this.description = '', + this.settings = 0, + this.priority = 0, + this.repeat = 0, + this.rerun = 0, + this.animojiId, + this.url, + this.type = BannerType.text, + }); + + bool get animatesText => settings & BannerSettings.textAnimation != 0; + bool get hidesCloseButton => settings & BannerSettings.hideCloseButton != 0; + bool get closesOnClick => settings & BannerSettings.hideOnClick != 0; + bool get tintsIconWithTheme => + settings & BannerSettings.iconThemeColor != 0; + + bool get isLink => + type == BannerType.link && url != null && url!.trim().isNotEmpty; + bool get isUpdate => type == BannerType.update; + bool get isClickable => isLink || isUpdate; + + static InformerBanner? fromMap(Map map) { + final id = map['id']?.toString(); + if (id == null || id.isEmpty) return null; + final title = map['title']?.toString() ?? ''; + final description = map['description']?.toString() ?? ''; + if (title.isEmpty && description.isEmpty) return null; + return InformerBanner( + id: id, + title: title, + description: description, + settings: _int(map['settings']) ?? 0, + priority: _int(map['priority']) ?? 0, + repeat: _int(map['repeat']) ?? 0, + rerun: _int(map['rerun']) ?? 0, + animojiId: _int(map['animojiId']), + url: map['url']?.toString(), + type: _int(map['type']) ?? BannerType.text, + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'settings': settings, + 'priority': priority, + 'repeat': repeat, + 'rerun': rerun, + 'animojiId': animojiId, + 'url': url, + 'type': type, + }; + + static int? _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} + +class BannerShowState { + final int showCounter; + final int? showAt; + final int? closedAt; + + const BannerShowState({this.showCounter = 0, this.showAt, this.closedAt}); + + BannerShowState copyWith({int? showCounter, int? showAt, int? closedAt}) => + BannerShowState( + showCounter: showCounter ?? this.showCounter, + showAt: showAt ?? this.showAt, + closedAt: closedAt ?? this.closedAt, + ); + + static BannerShowState fromJson(Map map) => BannerShowState( + showCounter: InformerBanner._int(map['showCounter']) ?? 0, + showAt: InformerBanner._int(map['showAt']), + closedAt: InformerBanner._int(map['closedTime']), + ); + + Map toJson() => { + 'showCounter': showCounter, + 'showAt': showAt, + 'closedTime': closedAt, + }; +}