Добавить тестовые каналы и Arweave-синхронизацию
@@ -33,7 +33,10 @@ public final class ArweaveBlockSyncService {
|
||||
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
|
||||
@@ -52,11 +55,10 @@ public final class ArweaveBlockSyncService {
|
||||
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
|
||||
String cursor = null;
|
||||
long maxHeight = minHeight;
|
||||
Map<String, byte[]> rootCache = new HashMap<>();
|
||||
int discovered = 0;
|
||||
|
||||
do {
|
||||
JsonNode response = graphQlPage(minHeight, cursor);
|
||||
JsonNode response = graphQlPage(minHeight, cursor, ArweaveBlocksConfig.TEST_TAG_VALUE + "-batch");
|
||||
JsonNode txs = response.path("data").path("transactions");
|
||||
if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors"));
|
||||
JsonNode edges = txs.path("edges");
|
||||
@@ -66,27 +68,23 @@ public final class ArweaveBlockSyncService {
|
||||
for (JsonNode edge : edges) {
|
||||
nextCursor = edge.path("cursor").asText(null);
|
||||
JsonNode node = edge.path("node");
|
||||
String itemIdText = node.path("id").asText("").trim();
|
||||
String rootTx = node.path("bundledIn").path("id").asText("").trim();
|
||||
String rootTx = node.path("id").asText("").trim();
|
||||
long height = node.path("block").path("height").asLong(-1L);
|
||||
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue;
|
||||
if (rootTx.isBlank() || height < 0) continue;
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
byte[] id32;
|
||||
try { id32 = B64URL.decode(itemIdText); }
|
||||
catch (IllegalArgumentException bad) { continue; }
|
||||
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||
|
||||
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> {
|
||||
try { return downloadRootBundle(key); }
|
||||
catch (Exception e) { throw new RootDownloadRuntimeException(e); }
|
||||
});
|
||||
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000));
|
||||
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx);
|
||||
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
|
||||
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText);
|
||||
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
|
||||
discovered++;
|
||||
byte[] bundle = downloadRootBundle(rootTx);
|
||||
for (Ans104Bundle.Entry entry : Ans104Bundle.read(bundle, Math.max(cfg.publishMaxItems() * 4, 100_000))) {
|
||||
byte[] id32 = entry.id32();
|
||||
if (id32 == null || id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) {
|
||||
continue;
|
||||
}
|
||||
Ans104DataItem parsed = new Ans104DataItem(entry.rawDataItem());
|
||||
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
|
||||
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature in root bundle " + rootTx);
|
||||
importDAO.enqueueIfMissing(id32, rootTx, height, entry.rawDataItem(), System.currentTimeMillis());
|
||||
discovered++;
|
||||
}
|
||||
}
|
||||
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
|
||||
cursor = hasNext ? nextCursor : null;
|
||||
@@ -156,9 +154,9 @@ public final class ArweaveBlockSyncService {
|
||||
stateDAO.insertIfMissing(s);
|
||||
}
|
||||
|
||||
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
|
||||
private JsonNode graphQlPage(long minHeight, String cursor, String appTagValue) throws Exception {
|
||||
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
|
||||
String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }";
|
||||
String query = "query { transactions(tags:[{name:\"App\",values:[\"" + escapeGraphQl(appTagValue) + "\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }";
|
||||
String body = MAPPER.writeValueAsString(Map.of("query", query));
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
|
||||
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
|
||||
@@ -181,5 +179,4 @@ public final class ArweaveBlockSyncService {
|
||||
|
||||
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
|
||||
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
||||
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import shine.db.dao.SyncServersDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -46,6 +47,11 @@ public final class AddBlockSyncService {
|
||||
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
|
||||
|
||||
public void replicateAsync(String blockchainName, int blockNumber) {
|
||||
if (!isEnabled()) {
|
||||
log.debug("AddBlock sync skipped: blockchain.sync.enabled=false blockchainName={} blockNumber={}",
|
||||
blockchainName, blockNumber);
|
||||
return;
|
||||
}
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
replicate(blockchainName, blockNumber);
|
||||
@@ -56,6 +62,10 @@ public final class AddBlockSyncService {
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
|
||||
}
|
||||
|
||||
private void replicate(String blockchainName, int blockNumber) throws Exception {
|
||||
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
|
||||
if (ownerLogin == null) {
|
||||
|
||||
@@ -327,6 +327,25 @@ public final class SolanaUsersSyncService
|
||||
state.lastSeenSignature()
|
||||
);
|
||||
|
||||
if (state.lastSeenSignature() == null) {
|
||||
log.warn(
|
||||
"History checkpoint is empty. Running current-state full snapshot bootstrap instead of replaying all historical transactions."
|
||||
);
|
||||
|
||||
runFullSnapshotFallback(
|
||||
state,
|
||||
fetchResult,
|
||||
nowMs
|
||||
);
|
||||
|
||||
markReadyAfterSync(
|
||||
state,
|
||||
nowMs
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.lastSeenSignature() != null
|
||||
&& !fetchResult.anchorFound()) {
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import server.sync.BlockchainResyncGuard;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.List;
|
||||
@@ -56,6 +57,10 @@ public final class PeriodicBlockchainSyncService {
|
||||
private PeriodicBlockchainSyncService() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
if (!isEnabled()) {
|
||||
log.info("Periodic blockchain sync disabled by blockchain.sync.enabled=false");
|
||||
return;
|
||||
}
|
||||
if (!STARTED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
@@ -68,6 +73,10 @@ public final class PeriodicBlockchainSyncService {
|
||||
log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS);
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
|
||||
}
|
||||
|
||||
private static void runCycleSafe() {
|
||||
try {
|
||||
runCycle();
|
||||
|
||||
@@ -27,6 +27,13 @@ solana.users.sync.pollIntervalSeconds=300
|
||||
# ------------------------------------------------------------
|
||||
sync.importUserProfileFromPartner.enabled=false
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Прямая межсерверная синхронизация пользовательских блокчейнов
|
||||
# через sync_servers: ListBlockchainHeads/GetBlockchainBlock -> локальный AddBlock.
|
||||
# Для проверки режима Solana + Arweave без прямых связей серверов можно выключить.
|
||||
# ------------------------------------------------------------
|
||||
blockchain.sync.enabled=true
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Server public info
|
||||
# Эти поля используются JSON-операцией GetServerInfo.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.22
|
||||
server.version=1.10.8
|
||||
client.version=1.12.23
|
||||
server.version=1.10.9
|
||||
|
||||
@@ -43,6 +43,24 @@
|
||||
- `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service.
|
||||
- `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`.
|
||||
|
||||
## Временные статические сайты
|
||||
|
||||
Для дизайн-примеров, standalone-viewer'ов и временных тестовых страниц используется папка:
|
||||
|
||||
```text
|
||||
shine-UI/static-sites/
|
||||
```
|
||||
|
||||
Она деплоится обычным UI deploy вместе со всем `shine-UI`. Если Caddy root указывает на UI-каталог и используется стандартный fallback `try_files {path} /index.html`, отдельный Caddy-route для каждой новой подпапки не нужен.
|
||||
|
||||
Примеры URL после deploy:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/
|
||||
https://<host>/static-sites/design-examples/channels-v1/
|
||||
https://<host>/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Production wrappers:
|
||||
|
||||
- `scripts/production_shineupme_server.sh`
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 2026-09-23 — Тестовые каналы и Arweave-only синхронизация
|
||||
- Базовый коммит-ориентир: `3483a0a`.
|
||||
- Добавлены тестовые каналы и publisher для генерации пользовательских POST-блоков через обычный `AddBlock`.
|
||||
- Добавлена возможность отключать прямую межсерверную синхронизацию блоков настройкой `blockchain.sync.enabled=false`.
|
||||
- Arweave-импорт переведён на root ANS-104 bundle discovery: сервер находит batch-транзакции `App=test5590-batch`, разбирает вложенные signed DataItem и импортирует SHiNE-блоки с дедупликацией по `data_item_id`.
|
||||
- Проверен тестовый сценарий Arweave-only: t3 поднял блоки из Arweave без прямой межсерверной синхронизации с t2.
|
||||
|
||||
## 2026-08-25 19:45:18 +0400
|
||||
- Базовый коммит-ориентир: `3a58519`.
|
||||
- Добавлены runtime-агрегаты статистики:
|
||||
|
||||
@@ -22,6 +22,20 @@ PostgreSQL является единственным локальным хран
|
||||
4. последовательно проигрывает удалённую цепочку с блока 0;
|
||||
5. никаких файловых swap/recovery операций нет.
|
||||
|
||||
Этот прямой sync включается параметром:
|
||||
|
||||
```properties
|
||||
blockchain.sync.enabled=true
|
||||
```
|
||||
|
||||
Для проверки режима без прямых server-to-server связей его можно выключить в override-конфиге сервера:
|
||||
|
||||
```properties
|
||||
blockchain.sync.enabled=false
|
||||
```
|
||||
|
||||
При выключенном прямом sync сервер по-прежнему может синхронизировать пользователей из Solana и импортировать пользовательские блоки через Arweave, если включён `arweave.blocks.sync.enabled`.
|
||||
|
||||
## Arweave sync
|
||||
|
||||
Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Static sites
|
||||
|
||||
Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.
|
||||
|
||||
Все содержимое этой папки деплоится вместе с `shine-UI`, потому что общий UI deploy синхронизирует весь каталог `shine-UI/` в Caddy web root. На текущей схеме отдельная перенастройка Caddy для каждого нового сайта не нужна: Caddy сначала отдает существующие файлы из UI root, а уже потом делает fallback на основной `index.html`.
|
||||
|
||||
## URL
|
||||
|
||||
Если UI задеплоен на `https://t2.shineup.me`, то эта папка доступна как:
|
||||
|
||||
```text
|
||||
https://t2.shineup.me/static-sites/
|
||||
```
|
||||
|
||||
Текущие подпапки:
|
||||
|
||||
```text
|
||||
/static-sites/design-examples/channels-v1/
|
||||
/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Для production домен меняется на нужный хост, например:
|
||||
|
||||
```text
|
||||
https://shineup.me/static-sites/
|
||||
https://server2.shineup.me/static-sites/
|
||||
```
|
||||
|
||||
## Как добавлять новый временный сайт
|
||||
|
||||
1. Создать подпапку внутри `shine-UI/static-sites/`.
|
||||
2. Положить туда `index.html` и связанные файлы.
|
||||
3. Выполнить обычный UI deploy нужного контура через `deploy/scripts/*_ui.sh`.
|
||||
|
||||
После этого сайт будет доступен по URL:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/<folder>/
|
||||
```
|
||||
|
||||
## Arweave viewer
|
||||
|
||||
Постоянная папка для viewer:
|
||||
|
||||
```text
|
||||
shine-UI/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Когда появляется новая версия viewer, можно заменить содержимое этой папки новой версией и выполнить обычный UI deploy. URL останется прежним:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# SHiNE Arweave Viewer v2 — ANS-104 / Frame v1
|
||||
|
||||
Новая версия viewer для формата, где каждый пользовательский блок хранится в Arweave как отдельный ANS-104 DataItem, а `data` DataItem содержит один `SHiNE Frame v1`.
|
||||
|
||||
Основной файл: `index.html`.
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
Через логин (ключ берётся из Solana PDA):
|
||||
|
||||
```text
|
||||
index.html?login=aidarkc&channel=books
|
||||
```
|
||||
|
||||
Напрямую через blockchain public key, без Solana:
|
||||
|
||||
```text
|
||||
index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_KEY>&channel=books
|
||||
```
|
||||
|
||||
Дополнительные параметры:
|
||||
|
||||
```text
|
||||
&arweaveGateway=https://arweave.net
|
||||
&solanaRpc=https://api.mainnet-beta.solana.com
|
||||
```
|
||||
|
||||
`channel` можно не передавать. Тогда viewer ищет все DataItem пользователя с `App=test5590` и строит список каналов из найденных блоков.
|
||||
|
||||
## Что делает viewer
|
||||
|
||||
1. Если передан `login`, вычисляет PDA `user_login=<login>` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`.
|
||||
2. Если передан `key`, использует его напрямую и не обращается к Solana.
|
||||
3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`.
|
||||
4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=<channel>`.
|
||||
5. Загружает payload каждого DataItem через gateway `/<DataItemId>`.
|
||||
6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes).
|
||||
7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1.
|
||||
8. Восстанавливает канал/сообщения тем же body parser, который использовал старый viewer.
|
||||
|
||||
## Важно
|
||||
|
||||
При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально.
|
||||
|
||||
Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`.
|
||||
|
||||
|
||||
## Проверка данных (v3.3)
|
||||
Нижняя строка показывает краткий итог доступных проверок. Нажатие на неё или `⋮ → Проверка данных` открывает отдельный экран:
|
||||
- локально вычисленный SHA-256 каждого SHiNE Frame;
|
||||
- `prevHash` общей цепочки, когда предыдущий глобальный блок загружен;
|
||||
- `prevLineHash` канальной цепочки, когда указанный предыдущий блок загружен;
|
||||
- наличие DataItem в выборке Arweave owner соответствующего blockchain key;
|
||||
- разрывы «нет данных» отдельно от доказанных несовпадений;
|
||||
- честный статус ANS-104 signature: gateway payload/GraphQL не содержит сырой подписи, поэтому она не помечается как локально перепроверенная.
|
||||
|
||||
`○ нет данных` не является ошибкой. `⚠` появляется только при фактическом несовпадении доступных хэшей.
|
||||
@@ -0,0 +1,26 @@
|
||||
# SHiNE Arweave Viewer v3
|
||||
|
||||
Viewer нового per-action Arweave формата SHiNE.
|
||||
|
||||
## URL
|
||||
- `?login=<login>&channel=<channel>` — получает blockchain key через Solana PDA, затем читает Arweave.
|
||||
- `?key=<base58>&channel=<channel>` — Arweave запускается напрямую; Solana не является блокирующей зависимостью.
|
||||
- без параметров — стартовая форма (логин или blockchain key + необязательный канал).
|
||||
|
||||
## v3
|
||||
- переключатель `Канал / Блоки`;
|
||||
- рекурсивные комментарии скрыты до раскрытия, на любой глубине;
|
||||
- light/dark тема;
|
||||
- меню `⋮`: кэш, Solana RPC, Arweave gateway;
|
||||
- выключение кэша удаляет сохранённые данные каналов;
|
||||
- одна нижняя status bar, по нажатию — подробный журнал;
|
||||
- GraphQL читает от новых Arweave записей к старым (`HEIGHT_DESC`);
|
||||
- до 6 параллельных загрузок Frame;
|
||||
- отображение известных типов и raw-представление любых неизвестных блоков в режиме `Блоки`;
|
||||
- лайки намеренно не отображаются.
|
||||
|
||||
Кэш использует browser localStorage и сериализует BigInt-поля Frame явно. Тема/RPC/gateway являются настройками и не удаляются при отключении кэша.
|
||||
|
||||
## V3.1 — streaming reverse loading
|
||||
|
||||
Arweave history is now processed page-by-page in `HEIGHT_DESC` order. The first GraphQL page (up to 100 DataItems) is downloaded, parsed and rendered immediately. Older pages are then fetched and merged in the background. The bottom status line shows the currently available block range while history expands toward the beginning. Each completed page is also persisted when cache is enabled.
|
||||
@@ -0,0 +1,11 @@
|
||||
# SHiNE Solana Arweave Viewer
|
||||
|
||||
Основной рабочий файл:
|
||||
|
||||
- `index.html`
|
||||
|
||||
Подробное описание работы, параметров и готовых ссылок:
|
||||
|
||||
- `КАК_ЭТО_РАБОТАЕТ.md`
|
||||
|
||||
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SHiNE — четыре направления UI</title>
|
||||
<link rel="stylesheet" href="mockups.css">
|
||||
<script src="mockups.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="studio-header">
|
||||
<a class="brand" href="index.html" aria-label="SHiNE — все варианты"><span class="brand-spark">✳</span> SHiNE<span class="brand-caption">DESIGN STUDY / 01</span></a>
|
||||
<span class="draft-label">Концепции · сентябрь 2026</span>
|
||||
</header>
|
||||
<main>
|
||||
<section class="intro">
|
||||
<div><div class="eyebrow">КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ</div><h1>Ближе к разговору.</h1><p>Четыре характера. Один привычный путь от канала к ответу.<br>Выберите экран и сравните — или нажмите на элементы внутри макета.</p></div>
|
||||
<div class="intro-note"><span>01—04</span><p>Меньше визуального шума.<br>Больше места людям и мыслям.</p></div>
|
||||
</section>
|
||||
<section class="review-bar" aria-label="Управление просмотром">
|
||||
<div class="screen-switch" role="group" aria-label="Экран для всех вариантов">
|
||||
<button class="review-button selected" data-screen="list" aria-pressed="true">01 Каналы</button>
|
||||
<button class="review-button" data-screen="feed" aria-pressed="false">02 Сообщения</button>
|
||||
<button class="review-button" data-screen="thread" aria-pressed="false">03 Ветка</button>
|
||||
<button class="review-button" data-screen="reply" aria-pressed="false">04 Ответ</button>
|
||||
</div>
|
||||
<div class="review-options"><label>Роль <select id="role"><option value="reader">Читатель</option><option value="owner">Владелец</option></select></label><label class="keyboard-option"><input type="checkbox" id="keyboard"> Макет клавиатуры</label><a class="compare-link" href="index.html">Все варианты ↗</a></div>
|
||||
</section>
|
||||
<div id="gallery" class="gallery"></div>
|
||||
<section class="decision-note"><span class="eyebrow">С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ</span><h2>Сначала удобство. Затем характер.</h2><div class="decision-grid"><p><b>01 / Читаемость</b>Где комфортнее читать длинный пост и замечать новый ответ?</p><p><b>02 / Плотность</b>Нужны просторные сообщения или больше содержимого на одном экране?</p><p><b>03 / Окно ответа</b>Полный экран или лист поверх ветки? Проверьте с макетом клавиатуры.</p></div><p class="disclaimer">Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. <a href="../DESIGN.md">Дизайн-документ ↗</a></p></section>
|
||||
</main>
|
||||
<footer class="studio-footer"><span>SHiNE / пространство для общения</span><span>Рабочий материал · не финальный дизайн</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,212 @@
|
||||
* { box-sizing: border-box; }
|
||||
:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; }
|
||||
body { margin: 0; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button, a, select { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: default; opacity: .4; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; }
|
||||
a { color: inherit; }
|
||||
.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
|
||||
.brand-spark { font-size: 36px; font-weight: 400; }
|
||||
.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; }
|
||||
.draft-label { font-size: 12px; color: #63675f; }
|
||||
main { padding: 0 42px; max-width: 1900px; margin: auto; }
|
||||
.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; }
|
||||
.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; }
|
||||
h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; }
|
||||
.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; }
|
||||
.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; }
|
||||
.intro-note > span { font-size: 38px; letter-spacing: -2px; }
|
||||
.intro-note p { font-size: 12px; margin-top: 10px; }
|
||||
.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; }
|
||||
.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; }
|
||||
.review-button.selected { background: #292d27; color: white; }
|
||||
.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; }
|
||||
.review-options label { display: flex; align-items: center; gap: 6px; }
|
||||
.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; }
|
||||
.compare-link { display: none; }
|
||||
.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; }
|
||||
.concept { min-width: 0; }
|
||||
.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; }
|
||||
.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; }
|
||||
.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; }
|
||||
.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; }
|
||||
.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; }
|
||||
.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; }
|
||||
.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; }
|
||||
.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; }
|
||||
.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; }
|
||||
.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; }
|
||||
.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; }
|
||||
.status-icons .icon { height: 16px; width: 16px; }
|
||||
.phone button { color: inherit; }
|
||||
.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; }
|
||||
.icon-btn:hover { background: var(--soft); }
|
||||
.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); }
|
||||
.header-text { flex: 1; min-width: 0; }
|
||||
.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.header-text small { color: var(--muted); font-size: 10px; }
|
||||
.list-header { padding-left: 22px; }
|
||||
.list-header h3 { font-size: 25px; letter-spacing: -.9px; }
|
||||
.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; }
|
||||
.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; }
|
||||
.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); }
|
||||
.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; }
|
||||
.search-field input::placeholder { color: var(--muted); }
|
||||
.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; }
|
||||
.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); }
|
||||
.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); }
|
||||
.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); }
|
||||
.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; }
|
||||
.channel-row:hover { background: var(--soft); }
|
||||
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; }
|
||||
.avatar.city { background: #ded3bb; color: #635039; }
|
||||
.avatar.blue { background: #bac5d9; color: #354663; }
|
||||
.avatar.rose { background: #d9bcbb; color: #704444; }
|
||||
.avatar.green { background: #b9cdc0; color: #345747; }
|
||||
.avatar.violet { background: #cbc2d9; color: #56476c; }
|
||||
.avatar.self { background: var(--soft); color: var(--accent); }
|
||||
.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; }
|
||||
.channel-copy { min-width: 0; flex: 1; }
|
||||
.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; }
|
||||
.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; }
|
||||
.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); }
|
||||
.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; }
|
||||
.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); }
|
||||
.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); }
|
||||
.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); }
|
||||
.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; }
|
||||
.text-button .icon { width: 16px; height: 16px; }
|
||||
.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; }
|
||||
.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; }
|
||||
.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); }
|
||||
.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); }
|
||||
.post-header { display: flex; align-items: center; gap: 9px; }
|
||||
.post-header .avatar { width: 34px; height: 34px; font-size: 11px; }
|
||||
.author { min-width: 0; flex: 1; font-size: 12px; }
|
||||
.author strong { font-weight: 650; }
|
||||
.author small { color: var(--muted); font-size: 10px; display: block; }
|
||||
.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); }
|
||||
.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; }
|
||||
.post-title { font-weight: 600; }
|
||||
.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; }
|
||||
.landscape svg { width: 100%; height: 100%; display: block; }
|
||||
.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; }
|
||||
.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); }
|
||||
.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); }
|
||||
.action[aria-pressed="true"] { color: #dc7477; }
|
||||
.action[aria-pressed="true"] .icon { fill: currentColor; }
|
||||
.reply-link { margin-left: auto; font-size: 10px; }
|
||||
.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; }
|
||||
.attachment > .icon { color: var(--accent); }
|
||||
.attachment strong { font-size: 11px; display: block; }
|
||||
.attachment small { color: var(--muted); display: block; font-size: 10px; }
|
||||
.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; }
|
||||
.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; }
|
||||
.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; }
|
||||
.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); }
|
||||
.thread-heading strong { color: var(--text); }
|
||||
.reply { display: flex; padding: 16px 18px 0; gap: 10px; }
|
||||
.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); }
|
||||
.reply .avatar { width: 32px; height: 32px; font-size: 10px; }
|
||||
.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.reply small { color: var(--muted); font-size: 10px; }
|
||||
.reply strong { font-size: 12px; }
|
||||
.reply .post-actions { gap: 16px; }
|
||||
.reply-context { color: var(--accent); font-size: 10px; }
|
||||
.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||
.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; }
|
||||
.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; }
|
||||
.sheet.full { height: 100%; border-radius: 0; }
|
||||
.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; }
|
||||
.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; }
|
||||
.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; }
|
||||
.sheet-header .expand { color: var(--muted); }
|
||||
.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; }
|
||||
.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; }
|
||||
.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); }
|
||||
.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; }
|
||||
.source-preview strong { font-size: 12px; }
|
||||
.source-preview small { color: var(--muted); font-size: 10px; }
|
||||
.editor-row { display: flex; gap: 10px; }
|
||||
.editor-main { flex: 1; min-width: 0; }
|
||||
.editor-main strong { display: block; font-size: 12px; margin: 2px 0; }
|
||||
.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; }
|
||||
.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; }
|
||||
.editor-input::placeholder { color: var(--muted); }
|
||||
.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); }
|
||||
.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); }
|
||||
.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; }
|
||||
.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; }
|
||||
.phone .submit { color: var(--on-accent); }
|
||||
.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; }
|
||||
.editor-attachment span { flex: 1; overflow-wrap: anywhere; }
|
||||
.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); }
|
||||
.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; }
|
||||
.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; }
|
||||
.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; }
|
||||
.keys .space { max-width: 150px; flex: 5; font-size: 10px; }
|
||||
.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; }
|
||||
.menu-sheet { min-height: 0; padding-bottom: 24px; }
|
||||
.menu-content { padding: 10px 20px; }
|
||||
.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); }
|
||||
.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; }
|
||||
.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; }
|
||||
.concept-notes strong { color: #33392f; font-weight: 600; }
|
||||
.swatches { display: flex; gap: 5px; margin-bottom: 12px; }
|
||||
.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; }
|
||||
.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; }
|
||||
.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; }
|
||||
.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; }
|
||||
.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; }
|
||||
.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; }
|
||||
.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; }
|
||||
.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; }
|
||||
.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; }
|
||||
/* Варианты отличаются также плотностью, иерархией и формой редактора. */
|
||||
.phone[data-theme="mono"] .post { padding-left: 18px; }
|
||||
.phone[data-theme="mono"] .post-content { margin-left: 43px; }
|
||||
.phone[data-theme="mono"] .landscape { height: 128px; }
|
||||
.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); }
|
||||
.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; }
|
||||
.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
|
||||
.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; }
|
||||
.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; }
|
||||
.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; }
|
||||
.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; }
|
||||
.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; }
|
||||
.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); }
|
||||
.phone[data-theme="night"] .composer-trigger { border-radius: 13px; }
|
||||
.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; }
|
||||
.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; }
|
||||
.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; }
|
||||
.phone[data-theme="club"] .channel-copy small { display: none; }
|
||||
.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; }
|
||||
.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; }
|
||||
.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); }
|
||||
.phone[data-theme="club"] .post { padding: 12px 16px 5px; }
|
||||
.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; }
|
||||
.phone[data-theme="club"] .landscape { height: 108px; }
|
||||
.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; }
|
||||
.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; }
|
||||
.phone[data-theme="club"] .reply { padding-top: 12px; }
|
||||
.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; }
|
||||
.phone[data-theme="club"] .composer-trigger { border-radius: 10px; }
|
||||
.gallery.single { display: block; max-width: 410px; margin: auto; }
|
||||
.single .concept-description { min-height: 0; }
|
||||
body.focused .compare-link { display: inline; }
|
||||
@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } }
|
||||
@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } }
|
||||
@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } }
|
||||
@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } }
|
||||
@@ -0,0 +1,246 @@
|
||||
/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */
|
||||
'use strict';
|
||||
const icons = {
|
||||
back: '<path d="m14 5-7 7 7 7M7 12h14"/>',
|
||||
close: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
||||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
heart: '<path d="M20.8 4.8a5.5 5.5 0 0 0-7.8 0L12 6l-1.1-1.2a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.4a5.5 5.5 0 0 0 0-7.8Z"/>',
|
||||
chat: '<path d="M21 11.5a9 9 0 0 1-9 9 9.7 9.7 0 0 1-4-.9L3 21l1.4-4.7A9 9 0 1 1 21 11.5Z"/>',
|
||||
share: '<path d="m21 3-7 18-4-8-8-3 19-7ZM10 13 21 3"/>',
|
||||
channels: '<rect x="3" y="4" width="18" height="13" rx="3"/><path d="m8 21 4-4 4 4M7 8h10M7 12h6"/>',
|
||||
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 5"/>',
|
||||
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
|
||||
user: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
||||
book: '<path d="M12 5v16M3 4c4-1 6-1 9 1 3-2 5-2 9-1v15c-4-1-6-1-9 2-3-3-5-3-9-2Z"/>',
|
||||
file: '<path d="M14 2H5v20h14V7l-5-5ZM14 2v6h5M8 13h8M8 17h5"/>',
|
||||
attach: '<path d="m9 17 9-9a3 3 0 0 0-4-4L4 14a5 5 0 0 0 7 7L21 11M7 14l8-8"/>',
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="8" cy="8" r="1"/><path d="m3 17 6-6 4 4 3-3 5 5"/>',
|
||||
expand: '<path d="M14 3h7v7M21 3l-7 7M10 21H3v-7M3 21l7-7"/>',
|
||||
check: '<path d="m5 12 4 4L19 6"/>',
|
||||
edit: '<path d="m15 4 5 5M4 20l5-1L21 7a2 2 0 0 0-4-4L5 15l-1 5Z"/>',
|
||||
wifi: '<path d="M3 8a15 15 0 0 1 18 0M6 12a10 10 0 0 1 12 0M9 16a5 5 0 0 1 6 0M12 20h.01"/>',
|
||||
battery: '<rect x="2" y="6" width="18" height="12" rx="2"/><path d="M23 10v4M5 9h11v6H5Z"/>',
|
||||
};
|
||||
const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.chat}</svg>`;
|
||||
const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c]));
|
||||
const button = (action, label, name, extra = '') => `<button class="icon-btn" data-action="${action}" aria-label="${label}" title="${label}" ${extra}>${icon(name)}</button>`;
|
||||
const concepts = [
|
||||
{ id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. <strong>Самая нейтральная отправная точка.</strong>' },
|
||||
{ id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается <strong>округлым листом снизу.</strong>' },
|
||||
{ id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. <strong>Мой кандидат на развитие:</strong> узнаваемый, но сдержанный.' },
|
||||
{ id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор <strong>на весь экран</strong>, без потери контекста.' },
|
||||
];
|
||||
const channels = [
|
||||
{ title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true },
|
||||
{ title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true },
|
||||
{ title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true },
|
||||
{ title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true },
|
||||
{ title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false },
|
||||
{ title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false },
|
||||
];
|
||||
let role = 'reader';
|
||||
let showKeyboard = false;
|
||||
const states = new Map();
|
||||
const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?';
|
||||
const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!';
|
||||
const landscape = () => `<div class="landscape" role="img" aria-label="Иллюстрация: тихая река, холмы и солнце"><svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice"><rect width="400" height="180" fill="#cad8d0"/><circle cx="290" cy="46" r="24" fill="#f5e7b5"/><path d="M0 87Q65 35 136 79T270 70T400 61V180H0" fill="#8faaa0"/><path d="M0 125Q73 67 155 102T310 86T400 101V180H0" fill="#527b70"/><path d="M0 153Q98 104 184 130T400 110V180H0" fill="#31594f"/><path d="M216 100Q105 130 236 145T201 180H311Q347 151 239 137T242 101Z" fill="#c2d2ba"/><path d="M32 138V59m0 4-15 30h30ZM361 129V47m0 4-18 34h36Z" fill="#30534a" stroke="#30534a" stroke-width="4"/></svg><span class="media-caption">У реки / 18:42</span></div>`;
|
||||
function header(s) {
|
||||
if (s.screen === 'list') return `<header class="app-header list-header"><span class="mini-brand">✳</span><div class="header-text"><h3>Каналы</h3></div>${button('new-channel','Создать канал','plus')}</header>`;
|
||||
return `<header class="app-header">${button('back','Назад','back')}<div class="header-text"><h3>${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}</h3><small>${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}</small></div>${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}</header>`;
|
||||
}
|
||||
function nav() {
|
||||
return `<nav class="bottom-nav" aria-label="Разделы приложения">${[['chat','Личные'],['channels','Каналы'],['people','Связи'],['bell','Уведомления'],['user','Профиль']].map(([i,label])=>`<button class="nav-item" data-action="${i==='channels'?'list':'outside'}" ${i==='channels'?'aria-current="page"':''}>${icon(i)}<span>${label}</span></button>`).join('')}</nav>`;
|
||||
}
|
||||
function rows(s) {
|
||||
const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase()));
|
||||
return filtered.length ? filtered.map(c=>`<button class="channel-row" data-action="open-channel" data-index="${channels.indexOf(c)}"><span class="avatar ${c.color}">${c.symbol}</span><span class="channel-copy"><strong>${c.title}</strong><small>@${c.owner} / ${c.slug}</small><p>${c.preview}</p></span><span class="channel-tail"><span>${c.time}</span>${c.unread?`<span class="badge" aria-label="${c.unread} непрочитанных">${c.unread}</span>`:''}</span></button>`).join('') : '<p class="empty">Каналы не найдены.<br>Попробуйте другое название или логин.</p>';
|
||||
}
|
||||
function list(s) {
|
||||
return `<label class="search-field">${icon('search')}<input data-search aria-label="Поиск каналов" placeholder="Название или @автор" value="${escapeText(s.query)}"></label><div class="filter-tabs" role="group" aria-label="Фильтр каналов">${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>`<button class="filter" data-action="filter" data-value="${value}" aria-pressed="${s.filter===value}">${label}</button>`).join('')}</div><div class="section-label"><span>${s.filter==='mine'?'Ваши каналы':'Ваше пространство'}</span><span>SHiNE</span></div><div class="channel-rows">${rows(s)}</div><p class="list-tip">Хорошие разговоры начинаются<br>с общих интересов.</p>`;
|
||||
}
|
||||
function actions(s, id, count, replies) {
|
||||
const liked = s.likes.has(id);
|
||||
replies = 2 + s.replies.filter(reply => reply.post === id).length;
|
||||
return `<div class="post-actions"><button class="action" data-action="like" data-post="${id}" aria-label="Нравится" aria-pressed="${liked}">${icon('heart')}<span>${count+(liked?1:0)}</span></button><button class="action" data-action="thread" data-post="${id}" aria-label="Открыть ${replies} ответов">${icon('chat')}<span>${replies}</span></button><button class="action" data-action="share" aria-label="Поделиться">${icon('share')}</button><button class="action reply-link" data-action="reply" data-post="${id}">Ответить</button></div>`;
|
||||
}
|
||||
function post(s, second = false) {
|
||||
return `<article class="post"><div class="post-header"><span class="avatar city">АК</span><div class="author"><strong>${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}</strong><small>${second?'вчера, 19:10':'12 минут назад'}</small></div><button class="icon-btn more" data-action="post-menu" aria-label="Действия с сообщением">${icon('more')}</button></div><div class="post-content">${second?'<p>Собрала наши любимые места в один список. Добавляйте свои находки в ответах!</p><div class="attachment">'+icon('file')+'<div><strong>Места для прогулок.pdf</strong><small>PDF · 552 КБ</small></div></div>':`<p class="post-title">Маленькие открытия</p><p>${mainText}</p>${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}</div></article>`;
|
||||
}
|
||||
function feed(s) {
|
||||
return `<section class="channel-intro"><p>Замечаем красоту рядом. Делимся местами,<br>историями и маленькими открытиями.</p><div class="channel-tools"><button class="text-button" data-action="contents">${icon('book')} Оглавление</button><button class="pill" data-action="subscribe">${s.subscribed?'Вы подписаны':'Подписаться'}</button></div></section><div class="unread-line">Новые сообщения</div>${post(s)}${post(s,true)}`;
|
||||
}
|
||||
function replyRow(name, initials, color, text, target='') {
|
||||
return `<article class="reply"><span class="avatar ${color}">${initials}</span><div class="reply-body">${target?`<div class="reply-context">В ответ ${escapeText(target)}</div>`:''}<strong>${escapeText(name)}</strong> <small>· сейчас</small><p>${escapeText(text)}</p><div class="post-actions"><button class="action" data-action="reply-to" data-author="${escapeText(name)}" data-text="${escapeText(text)}">${icon('chat')} Ответить</button></div></div></article>`;
|
||||
}
|
||||
function thread(s) {
|
||||
const replies=s.replies.filter(r=>r.post===s.activePost);
|
||||
const second=s.activePost==='second';
|
||||
return `${post(s,second)}<div class="thread-heading"><strong>Ответы · ${2+replies.length}</strong><span>По порядку</span></div>${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`;
|
||||
}
|
||||
function composer(s) {
|
||||
if (s.screen==='list') return '';
|
||||
if (s.screen==='feed' && role==='reader') return '';
|
||||
return `<div class="composer-bar"><button class="composer-trigger ${s.screen==='feed'?'primary':''}" data-action="${s.screen==='feed'?'new-post':'reply'}"><span>${s.screen==='feed'?'Написать в канал':'Ответить '+s.channel.owner+'…'}</span>${icon(s.screen==='feed'?'plus':'edit')}</button></div>`;
|
||||
}
|
||||
function render(s) {
|
||||
const previousView = s.phone.querySelector('.view');
|
||||
s.scrolls ||= {};
|
||||
if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop;
|
||||
s.phone.innerHTML = `<div class="status-bar"><span>9:41</span><span class="status-icons">${icon('wifi')}${icon('battery')}</span></div>${header(s)}<div class="view">${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}</div>${composer(s)}${nav()}`;
|
||||
if(s.screen==='feed') for(const text of s.posts) {
|
||||
const item=document.createElement('article'); item.className='post';
|
||||
const author=document.createElement('strong'); author.textContent='Вы · сейчас';
|
||||
const body=document.createElement('p'); body.textContent=text; item.append(author,body);
|
||||
s.phone.querySelector('.view').append(item);
|
||||
}
|
||||
s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0;
|
||||
s.renderedScreen = s.screen;
|
||||
if (s.editor) mountEditor(s);
|
||||
}
|
||||
function keyboard() {
|
||||
return `<div class="keyboard" aria-hidden="true"><div class="keyboard-label">Макет клавиатуры · не интерактивный</div>${['йцукенгшщзх','фывапролджэ','ячсмитьбю'].map(row=>`<div class="keys">${[...row].map(c=>`<span>${c}</span>`).join('')}</div>`).join('')}<div class="keys"><span class="wide">123</span><span class="space">Русский</span><span class="wide">↵</span></div></div>`;
|
||||
}
|
||||
function mountEditor(s) {
|
||||
const mode = s.editor;
|
||||
const full = s.id==='mono'||s.id==='club'||s.expanded;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'overlay';
|
||||
overlay.innerHTML = `<section class="sheet ${full?'full':''}" role="dialog" aria-modal="true" aria-label="${mode==='post'?'Новое сообщение':'Ответ на сообщение'}" tabindex="-1">${full?'':'<div class="sheet-handle"></div>'}<div class="sheet-header">${button('close-editor','Закрыть и сохранить черновик','close')}<h4>${mode==='post'?'Новое сообщение':'Ответ'}</h4>${button('expand','Развернуть или свернуть редактор','expand','aria-pressed="'+full+'"')}</div><div class="editor-scroll">${mode==='post'?'':`<div class="source-preview"><span class="avatar city">${s.replyTo===s.channel.owner?'АК':escapeText(s.replyTo.slice(0,2).toUpperCase())}</span><div><strong>${escapeText(s.replyTo)}</strong><small> · ${escapeText(s.channel.title)}</small><p>${escapeText(s.replyText)}</p></div></div>`}<div class="editor-row"><span class="avatar self">ВЫ</span><div class="editor-main"><strong>Вы</strong><small>${mode==='post'?'Публикация в «'+escapeText(s.channel.title)+'»':'Ответ '+escapeText(s.replyTo)+' · виден в обсуждении'}</small><textarea class="editor-input" maxlength="2000" aria-label="Текст ${mode==='post'?'сообщения':'ответа'}" placeholder="${mode==='post'?'О чём хотите рассказать?':'Продолжите разговор…'}">${escapeText(s.drafts[mode])}</textarea><div class="selected-attachment">${attachmentMarkup(s)}</div></div></div></div><div class="editor-footer">${button('attach','Прикрепить файл','attach')}<input type="file" hidden data-file><span class="char-count">${s.drafts[mode].length} / 2000</span><button class="submit" data-action="send" ${!s.drafts[mode].trim()&&!s.attachments[mode]?'disabled':''}>${mode==='post'?'Опубликовать':'Ответить'}</button></div>${showKeyboard?keyboard():''}</section>`;
|
||||
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true);
|
||||
s.phone.append(overlay);
|
||||
overlay.querySelector('.sheet').focus({preventScroll:true});
|
||||
}
|
||||
function attachmentMarkup(s) {
|
||||
const name = s.attachments[s.editor];
|
||||
return name?`<div class="editor-attachment">${icon('file')}<span>${escapeText(name)}</span>${button('remove-attachment','Удалить вложение','close')}</div>`:'';
|
||||
}
|
||||
function removeOverlay(s) {
|
||||
s.phone.querySelector('.overlay')?.remove();
|
||||
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false);
|
||||
}
|
||||
function closeEditor(s) {
|
||||
s.editor = null;
|
||||
removeOverlay(s);
|
||||
if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true});
|
||||
else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true});
|
||||
}
|
||||
function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) {
|
||||
if (s.editor) closeEditor(s);
|
||||
s.editor=mode;
|
||||
s.replyTo=author;
|
||||
s.replyText=text;
|
||||
s.returnFocus=trigger;
|
||||
s.expanded=false;
|
||||
mountEditor(s);
|
||||
}
|
||||
function toast(s, message) {
|
||||
s.phone.querySelector('.toast')?.remove();
|
||||
clearTimeout(s.toastTimer);
|
||||
const el=document.createElement('div');
|
||||
el.className='toast'; el.setAttribute('role','status'); el.textContent=message;
|
||||
s.phone.append(el);
|
||||
s.toastTimer=setTimeout(()=>el.remove(),3500);
|
||||
}
|
||||
function menu(s, title, contents, trigger) {
|
||||
removeOverlay(s);
|
||||
s.returnFocus=trigger;
|
||||
const el=document.createElement('div'); el.className='overlay';
|
||||
el.innerHTML=`<section class="sheet menu-sheet" role="dialog" aria-modal="true" aria-label="${escapeText(title)}" tabindex="-1"><div class="sheet-handle"></div><div class="sheet-header">${button('close-menu','Закрыть','close')}<h4>${escapeText(title)}</h4><span style="width:44px"></span></div><div class="menu-content">${contents}</div></section>`;
|
||||
s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true);
|
||||
s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true});
|
||||
}
|
||||
const menuAction=(action,label,i='file')=>`<button class="menu-action" data-action="${action}">${icon(i)}${label}</button>`;
|
||||
function updateEditor(s) {
|
||||
const modal=s.phone.querySelector('.overlay');
|
||||
modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`;
|
||||
modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor];
|
||||
modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s);
|
||||
}
|
||||
function handle(s, event) {
|
||||
const el=event.target.closest('[data-action]');
|
||||
if (!el || el.disabled) return;
|
||||
const action=el.dataset.action;
|
||||
if (action==='filter') { s.filter=el.dataset.value; render(s); }
|
||||
else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); }
|
||||
else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); }
|
||||
else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); }
|
||||
else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); }
|
||||
else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el);
|
||||
else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); }
|
||||
else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); }
|
||||
else if (action==='attach') s.phone.querySelector('[data-file]').click();
|
||||
else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); }
|
||||
else if (action==='send') {
|
||||
const mode=s.editor;
|
||||
const text=s.drafts[mode].trim();
|
||||
const attachment=s.attachments[mode];
|
||||
if (!text&&!attachment) return;
|
||||
if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost});
|
||||
else s.posts.push(text+(attachment?'\n📎 '+attachment:''));
|
||||
s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s);
|
||||
const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight;
|
||||
s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.');
|
||||
}
|
||||
else if (action==='like') {
|
||||
const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key);
|
||||
el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1));
|
||||
}
|
||||
else if (action==='share') menu(s,'Поделиться', '<p>В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.</p>'+menuAction('close-menu','Понятно','check'),el);
|
||||
else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; }
|
||||
else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); }
|
||||
else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'<p>Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.</p>',el);
|
||||
else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el);
|
||||
else if (action==='contents') menu(s,'Оглавление','<p>С чего начать знакомство с каналом</p>'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el);
|
||||
else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); }
|
||||
else if (action==='reactions') menu(s,'Лайки','<p>Все: 24 · основные: 18 · сияющие: 6</p><p>Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.</p>',el);
|
||||
else if (action==='details') menu(s,'Данные записи','<p>Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.</p>',el);
|
||||
else if (action==='about') menu(s,'О канале','<p>Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.</p><p>Автор: @'+escapeText(s.channel.owner)+'</p>',el);
|
||||
else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
|
||||
else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования');
|
||||
else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап');
|
||||
else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.');
|
||||
}
|
||||
function attachEvents(s) {
|
||||
s.phone.addEventListener('click',event=>handle(s,event));
|
||||
s.phone.addEventListener('input',event=>{
|
||||
if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); }
|
||||
if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); }
|
||||
});
|
||||
s.phone.addEventListener('change',event=>{
|
||||
if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); }
|
||||
});
|
||||
s.phone.addEventListener('keydown',event=>{
|
||||
const dialog=s.phone.querySelector('[role="dialog"]');
|
||||
if(!dialog) return;
|
||||
if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
|
||||
if(event.key==='Tab') {
|
||||
const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')];
|
||||
const first=focusable[0], last=focusable.at(-1);
|
||||
if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); }
|
||||
else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
function init() {
|
||||
const query=new URLSearchParams(location.search);
|
||||
const chosen=concepts.find(c=>c.id===query.get('variant'));
|
||||
const gallery=document.getElementById('gallery');
|
||||
if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');}
|
||||
for(const c of chosen?[chosen]:concepts) {
|
||||
const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title);
|
||||
article.innerHTML=`<div class="concept-heading"><span class="concept-number">${c.number}</span><h2>${c.title}</h2><a class="open-concept" href="?variant=${c.id}" aria-label="Открыть отдельно: ${c.title}">↗</a></div><p class="concept-description">${c.description}</p><div class="phone" data-theme="${c.id}" aria-label="Макет: ${c.title}"></div><div class="concept-notes"><div class="swatches" aria-hidden="true">${c.colors.map(color=>`<span style="background:${color}"></span>`).join('')}</div><span class="concept-tag">${c.tag}</span><div>${c.note}</div></div>`;
|
||||
gallery.append(article);
|
||||
const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null};
|
||||
states.set(c.id,s);render(s);attachEvents(s);
|
||||
}
|
||||
document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{
|
||||
document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));});
|
||||
for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); }
|
||||
el.focus({preventScroll:true});
|
||||
}));
|
||||
document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);});
|
||||
document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});});
|
||||
}
|
||||
init();
|
||||
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SHiNE static sites</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f1ec;
|
||||
--panel: #fffaf3;
|
||||
--ink: #171512;
|
||||
--muted: #6f675e;
|
||||
--line: rgba(52, 44, 35, 0.14);
|
||||
--accent: #087a68;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(920px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 44px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(30px, 5vw, 52px);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
border-color: rgba(8, 122, 104, 0.45);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>SHiNE static sites</h1>
|
||||
<p>Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.</p>
|
||||
|
||||
<div class="list">
|
||||
<a href="./design-examples/channels-v1/">
|
||||
<strong>Channels design v1</strong>
|
||||
<span>Дизайн-пример экранов каналов.</span>
|
||||
</a>
|
||||
<a href="./arweave-viewer/">
|
||||
<strong>Arweave viewer</strong>
|
||||
<span>Постоянная папка для текущей версии SHiNE Arweave Viewer.</span>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
# SHiNE Arweave Viewer v2 — ANS-104 / Frame v1
|
||||
|
||||
Новая версия viewer для формата, где каждый пользовательский блок хранится в Arweave как отдельный ANS-104 DataItem, а `data` DataItem содержит один `SHiNE Frame v1`.
|
||||
|
||||
Основной файл: `index.html`.
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
Через логин (ключ берётся из Solana PDA):
|
||||
|
||||
```text
|
||||
index.html?login=aidarkc&channel=books
|
||||
```
|
||||
|
||||
Напрямую через blockchain public key, без Solana:
|
||||
|
||||
```text
|
||||
index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_KEY>&channel=books
|
||||
```
|
||||
|
||||
Дополнительные параметры:
|
||||
|
||||
```text
|
||||
&arweaveGateway=https://arweave.net
|
||||
&solanaRpc=https://api.mainnet-beta.solana.com
|
||||
```
|
||||
|
||||
`channel` можно не передавать. Тогда viewer ищет все DataItem пользователя с `App=test5590` и строит список каналов из найденных блоков.
|
||||
|
||||
## Что делает viewer
|
||||
|
||||
1. Если передан `login`, вычисляет PDA `user_login=<login>` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`.
|
||||
2. Если передан `key`, использует его напрямую и не обращается к Solana.
|
||||
3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`.
|
||||
4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=<channel>`.
|
||||
5. Загружает payload каждого DataItem через gateway `/<DataItemId>`.
|
||||
6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes).
|
||||
7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1.
|
||||
8. Восстанавливает канал/сообщения тем же body parser, который использовал старый viewer.
|
||||
|
||||
## Важно
|
||||
|
||||
При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально.
|
||||
|
||||
Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`.
|
||||
|
||||
|
||||
## Проверка данных (v3.3)
|
||||
Нижняя строка показывает краткий итог доступных проверок. Нажатие на неё или `⋮ → Проверка данных` открывает отдельный экран:
|
||||
- локально вычисленный SHA-256 каждого SHiNE Frame;
|
||||
- `prevHash` общей цепочки, когда предыдущий глобальный блок загружен;
|
||||
- `prevLineHash` канальной цепочки, когда указанный предыдущий блок загружен;
|
||||
- наличие DataItem в выборке Arweave owner соответствующего blockchain key;
|
||||
- разрывы «нет данных» отдельно от доказанных несовпадений;
|
||||
- честный статус ANS-104 signature: gateway payload/GraphQL не содержит сырой подписи, поэтому она не помечается как локально перепроверенная.
|
||||
|
||||
`○ нет данных` не является ошибкой. `⚠` появляется только при фактическом несовпадении доступных хэшей.
|
||||
@@ -0,0 +1,26 @@
|
||||
# SHiNE Arweave Viewer v3
|
||||
|
||||
Viewer нового per-action Arweave формата SHiNE.
|
||||
|
||||
## URL
|
||||
- `?login=<login>&channel=<channel>` — получает blockchain key через Solana PDA, затем читает Arweave.
|
||||
- `?key=<base58>&channel=<channel>` — Arweave запускается напрямую; Solana не является блокирующей зависимостью.
|
||||
- без параметров — стартовая форма (логин или blockchain key + необязательный канал).
|
||||
|
||||
## v3
|
||||
- переключатель `Канал / Блоки`;
|
||||
- рекурсивные комментарии скрыты до раскрытия, на любой глубине;
|
||||
- light/dark тема;
|
||||
- меню `⋮`: кэш, Solana RPC, Arweave gateway;
|
||||
- выключение кэша удаляет сохранённые данные каналов;
|
||||
- одна нижняя status bar, по нажатию — подробный журнал;
|
||||
- GraphQL читает от новых Arweave записей к старым (`HEIGHT_DESC`);
|
||||
- до 6 параллельных загрузок Frame;
|
||||
- отображение известных типов и raw-представление любых неизвестных блоков в режиме `Блоки`;
|
||||
- лайки намеренно не отображаются.
|
||||
|
||||
Кэш использует browser localStorage и сериализует BigInt-поля Frame явно. Тема/RPC/gateway являются настройками и не удаляются при отключении кэша.
|
||||
|
||||
## V3.1 — streaming reverse loading
|
||||
|
||||
Arweave history is now processed page-by-page in `HEIGHT_DESC` order. The first GraphQL page (up to 100 DataItems) is downloaded, parsed and rendered immediately. Older pages are then fetched and merged in the background. The bottom status line shows the currently available block range while history expands toward the beginning. Each completed page is also persisted when cache is enabled.
|
||||
@@ -0,0 +1,11 @@
|
||||
# SHiNE Solana Arweave Viewer
|
||||
|
||||
Основной рабочий файл:
|
||||
|
||||
- `index.html`
|
||||
|
||||
Подробное описание работы, параметров и готовых ссылок:
|
||||
|
||||
- `КАК_ЭТО_РАБОТАЕТ.md`
|
||||
|
||||
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
|
||||
@@ -0,0 +1,41 @@
|
||||
# AGENTS.md — SHiNE test channels
|
||||
|
||||
Эта папка — тестовый генератор реальной пользовательской активности SHiNE. Это не часть консенсуса и не отдельный протокол.
|
||||
|
||||
## Назначение
|
||||
Один тестовый пользователь публикует заранее подготовленные POST-блоки сразу в несколько своих каналов. Все POST должны идти через обычный SHiNE `AddBlock`, поэтому сервер проверяет их как обычные пользовательские блоки и затем использует штатную Arweave-публикацию.
|
||||
|
||||
## Почему publisher один
|
||||
У всех каналов одного пользователя одна глобальная blockchain-цепочка (`blockNumber + prevHash`). Нельзя запускать независимый publisher на каждый канал: они могут одновременно построить следующий глобальный блок. `publisher_multi.py` сериализует публикации всех каналов, но хранит отдельные `prevLineNumber/prevLineHash` и позиции очередей.
|
||||
|
||||
## Каналы в примере
|
||||
- `jokes` — короткие анекдоты; часть постов содержит 1–2 изображения.
|
||||
- `mindfulness` — короткие практики осознанности.
|
||||
- `positive` — позитивные и мотивирующие напоминания.
|
||||
- `friends` — советы об общении и дружбе, в духе классических принципов человеческого общения, без выдачи текста за дословные цитаты.
|
||||
- `wisdom` — короткие духовные/созерцательные мысли. Не придумывать цитаты и не приписывать человеку слова без источника.
|
||||
|
||||
## Изображения
|
||||
`prepare/prepare_all_channels.py` проходит все `channels[].queue_file`.
|
||||
Для каждого локального файла:
|
||||
1. вычисляет SHA-256;
|
||||
2. смотрит общий `upload-state.json`;
|
||||
3. если ID уже известен — проверяет `https://gateway/<id>`;
|
||||
4. если файл существует — повторно НЕ загружает;
|
||||
5. если отсутствует/ID неизвестен — загружает через Arweave JWK;
|
||||
6. пишет `.prepared.json`, добавляя постоянные Arweave URL в текст POST и сохраняя `images[]` как служебные метаданные.
|
||||
|
||||
JWK используется только подготовщиком. Не помещать приватный JWK или blockchain key в git/архив.
|
||||
|
||||
## State
|
||||
`multi-state.json` содержит один global head и отдельное состояние каждого канала. Pending DataItem записывается до `AddBlock`; при неопределённом состоянии глобальной цепочки publisher обязан остановиться, а не строить форк.
|
||||
|
||||
## Не менять
|
||||
- SHiNE Frame v1 и его SHA-256.
|
||||
- `prevHash` глобальной цепочки.
|
||||
- `prevLineNumber/prevLineHash` канальной линии.
|
||||
- ANS-104 signing/deepHash без сверки с серверной реализацией.
|
||||
- `App=test5590` и `c=<canonical channel>` без согласованного изменения импортера/Viewer.
|
||||
|
||||
## Viewer
|
||||
Viewer распознаёт Arweave URL в тексте POST и показывает изображения inline. Экран «Проверка данных» независимо считает SHA-256 Frame, проверяет доступные `prevHash` и `prevLineHash`, показывает отсутствие соседнего блока как «нет данных», а доказанное несовпадение — как ошибку. Подпись ANS-104 нельзя помечать локально проверенной, если gateway не предоставил сырую подпись DataItem.
|
||||
@@ -0,0 +1,21 @@
|
||||
# SHiNE multi-channel test publisher v3
|
||||
|
||||
В комплекте сохранены v1 и v2. Основной режим v3 — файлы `v2/publisher/publisher_multi.py` и `v2/prepare/prepare_all_channels.py`.
|
||||
|
||||
## Быстрый порядок
|
||||
1. Создать тестового пользователя и пять каналов на сервере.
|
||||
2. Заполнить реальные `channel_root_block_number` в `config.multi.json`.
|
||||
3. Положить blockchain key в `keys/blockchain.json`.
|
||||
4. Только для подготовки картинок положить JWK в `keys/arweave-wallet.json`.
|
||||
5. Скопировать `prepare.multi.example.json` → `prepare.multi.json`, затем:
|
||||
`python prepare/prepare_all_channels.py --config prepare.multi.json`
|
||||
6. Проверить созданные `channels/*.prepared.json`.
|
||||
7. Скопировать `config.multi.example.json` → `config.multi.json`.
|
||||
8. Проверка: `python publisher/publisher_multi.py status --config config.multi.json`
|
||||
9. Один тестовый пост: `python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json`
|
||||
10. Постоянный режим: `python publisher/publisher_multi.py run --config config.multi.json`
|
||||
|
||||
Повторный запуск подготовки безопасен: известные картинки сначала проверяются на gateway. Существующие не загружаются повторно.
|
||||
|
||||
## v3: media + расписание + VPS
|
||||
У всех пяти каналов теперь есть media-примеры: в обучающих каналах по 18 иллюстрированных постов (12×1, 4×2, 2×3 картинки). По умолчанию обучающие каналы публикуют раз в 3–7 активных часов, `jokes` — раз в 1–3 активных часа. Quiet hours 22:00–08:00 не расходуют интервал. Инструкция автономной установки: `v2/CODEX_VPS_SETUP.md`.
|
||||
@@ -0,0 +1,90 @@
|
||||
# SHiNE Test Publisher
|
||||
|
||||
Автономный publisher заранее подготовленных постов в существующий канал SHiNE.
|
||||
|
||||
## Что делает
|
||||
|
||||
- публикует следующий элемент `queue/jokes.json` через случайный интервал;
|
||||
- создаёт настоящий SHiNE Frame v1 + подписанный Ed25519 ANS-104 DataItem;
|
||||
- отправляет его обычной операцией `AddBlock` на SHiNE-server;
|
||||
- хранит прогресс в `state.json` и продолжает после рестарта;
|
||||
- перед отправкой сохраняет `pending`, поэтому падение между отправкой и записью state не должно съедать или дублировать элемент очереди;
|
||||
- при расхождении head цепочки с state останавливается вместо создания форка;
|
||||
- очередь по умолчанию НЕ зациклена.
|
||||
|
||||
В комплекте 60 заранее подготовленных нейтральных коротких анекдотов/шуток.
|
||||
|
||||
## Требования
|
||||
|
||||
Python 3.11+.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Настройка
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
Заполнить:
|
||||
|
||||
- `server_ws` — WebSocket SHiNE-server;
|
||||
- `blockchain_name` — активная цепочка тестового пользователя;
|
||||
- `channel_name` — имя существующего канала (его canonical slug = lowercase);
|
||||
- `channel_root_block_number` — номер CREATE_CHANNEL этого канала;
|
||||
- `key_file` — Solana JSON blockchain key пользователя;
|
||||
- интервалы публикации.
|
||||
|
||||
Ключ должен соответствовать owner активной blockchain. Реальный ключ не класть в git, права рекомендуется `chmod 600`.
|
||||
|
||||
При первом запуске `state.json` отсутствует: publisher читает head цепочки и последний POST канала с сервера и создаёт state автоматически.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
.venv/bin/python publisher/publisher.py status --config config.json
|
||||
```
|
||||
|
||||
`status` при первом запуске также инициализирует state.
|
||||
|
||||
Принудительно опубликовать следующий пост:
|
||||
|
||||
```bash
|
||||
.venv/bin/python publisher/publisher.py publish-now --config config.json
|
||||
```
|
||||
|
||||
Обычный режим:
|
||||
|
||||
```bash
|
||||
.venv/bin/python publisher/publisher.py run --config config.json
|
||||
```
|
||||
|
||||
## systemd
|
||||
|
||||
Скопировать каталог в `/opt/shine-test-publisher`, создать venv и настроить `config.json`, затем:
|
||||
|
||||
```bash
|
||||
sudo cp systemd/shine-test-publisher.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now shine-test-publisher
|
||||
journalctl -u shine-test-publisher -f
|
||||
```
|
||||
|
||||
Service предполагает пользователя `shine`; поменяйте `User=` при необходимости.
|
||||
|
||||
## state.json
|
||||
|
||||
Не редактировать при запущенном publisher. В нём сохраняются:
|
||||
|
||||
- следующий индекс очереди;
|
||||
- последний global block/hash;
|
||||
- последний line number/hash канала;
|
||||
- время следующей публикации;
|
||||
- `pending` для crash recovery;
|
||||
- общее число опубликованных элементов.
|
||||
|
||||
Если очередь закончилась и `loop_queue=false`, новые посты не создаются. Можно дописать новые элементы в JSON; `next_index` продолжит движение дальше.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"server_ws": "wss://t3.shineup.me/ws",
|
||||
"blockchain_name": "testjokes-001",
|
||||
"channel_name": "jokes",
|
||||
"channel_root_block_number": 1,
|
||||
"key_file": "keys/blockchain.json",
|
||||
"queue_file": "queue/jokes.json",
|
||||
"state_file": "state.json",
|
||||
"min_interval_seconds": 1800,
|
||||
"max_interval_seconds": 10800,
|
||||
"loop_queue": false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
Put the Solana-format blockchain key JSON here, for example blockchain.json.
|
||||
The file is an array of 64 bytes [private seed + public key].
|
||||
Recommended permissions: chmod 600 keys/blockchain.json
|
||||
Never commit the real key.
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
ZERO32 = bytes(32)
|
||||
FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1
|
||||
|
||||
def atomic_json(path, obj):
|
||||
path=Path(path); path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp=path.with_suffix(path.suffix+'.tmp')
|
||||
tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
os.replace(tmp,path)
|
||||
|
||||
def load_json(path, default=None):
|
||||
p=Path(path)
|
||||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default
|
||||
|
||||
def avro_long(n):
|
||||
n=(n<<1) ^ (n>>63); out=bytearray()
|
||||
while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7
|
||||
out.append(n); return bytes(out)
|
||||
|
||||
def avro_tags(tags):
|
||||
out=bytearray()
|
||||
if tags:
|
||||
out += avro_long(len(tags))
|
||||
for k,v in tags:
|
||||
kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb
|
||||
out += avro_long(0); return bytes(out)
|
||||
|
||||
def deep_hash(x):
|
||||
H=lambda b: hashlib.sha384(b).digest()
|
||||
if isinstance(x,(bytes,bytearray)):
|
||||
b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b))
|
||||
acc=H(f'list{len(x)}'.encode())
|
||||
for child in x: acc=H(acc+deep_hash(child))
|
||||
return acc
|
||||
|
||||
def signing_message(owner,tags,data):
|
||||
raw=avro_tags(tags)
|
||||
return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data])
|
||||
|
||||
def data_item(priv64,tags,data):
|
||||
if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes')
|
||||
seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
|
||||
sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data))
|
||||
rawtags=avro_tags(tags)
|
||||
return struct.pack('<H',2)+sig+owner+b'\0\0'+struct.pack('<QQ',len(tags),len(rawtags))+rawtags+data
|
||||
|
||||
def post_body(line_code, prev_line_num, prev_line_hash, this_line_num, text):
|
||||
tb=text.encode('utf-8')
|
||||
if len(tb)>65535: raise ValueError('post text too long')
|
||||
return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb
|
||||
|
||||
def frame(prev_hash, block_num, body, ts=None):
|
||||
ts=int(time.time()) if ts is None else int(ts); size=56+len(body)
|
||||
return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body
|
||||
|
||||
def h32(b): return hashlib.sha256(b).digest()
|
||||
def hx(b): return b.hex()
|
||||
|
||||
async def ws_call(url, op, payload, timeout=20):
|
||||
req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload}
|
||||
async with connect(url, open_timeout=timeout, close_timeout=5) as ws:
|
||||
await ws.send(json.dumps(req,separators=(',',':')))
|
||||
end=time.monotonic()+timeout
|
||||
while True:
|
||||
left=end-time.monotonic()
|
||||
if left<=0: raise TimeoutError(op)
|
||||
msg=await asyncio.wait_for(ws.recv(),left)
|
||||
obj=json.loads(msg)
|
||||
if obj.get('requestId')==req['requestId']: return obj
|
||||
|
||||
async def head(cfg):
|
||||
r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{})
|
||||
if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}')
|
||||
for x in r.get('payload',{}).get('blockchains',[]):
|
||||
if x.get('blockchainName')==cfg['blockchain_name']:
|
||||
return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','')
|
||||
raise RuntimeError('blockchain not found on server')
|
||||
|
||||
async def block(cfg,n):
|
||||
r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}')
|
||||
p=r.get('payload',r)
|
||||
return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64')
|
||||
|
||||
async def channel_tail(cfg, root_hash):
|
||||
r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}')
|
||||
items=r.get('payload',{}).get('messages',[])
|
||||
if not items: return 0, ZERO32
|
||||
m=items[0]; ref=m.get('messageRef') or {}
|
||||
return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32))
|
||||
|
||||
def read_key(path):
|
||||
a=load_json(path)
|
||||
if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array')
|
||||
return bytes(int(x)&255 for x in a)
|
||||
|
||||
def queue_items(cfg):
|
||||
q=load_json(cfg['queue_file'])
|
||||
if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array')
|
||||
for x in q:
|
||||
if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text')
|
||||
return q
|
||||
|
||||
def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds']))
|
||||
|
||||
async def init_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
root_hash,_=await block(cfg,int(cfg['channel_root_block_number']))
|
||||
line_num,line_hash=await channel_tail(cfg,root_hash)
|
||||
st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
async def reconcile(cfg,st):
|
||||
p=st.get('pending')
|
||||
bn,bhash=await head(cfg)
|
||||
if p:
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn)
|
||||
elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower():
|
||||
print('Pending block was not accepted; it will be retried')
|
||||
else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}')
|
||||
else:
|
||||
if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_one(cfg,st,items):
|
||||
if st.get('pending'):
|
||||
p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash']
|
||||
else:
|
||||
idx=int(st['next_index'])
|
||||
if idx>=len(items):
|
||||
if cfg.get('loop_queue',False): idx=0
|
||||
else: print('Queue exhausted. Nothing to publish.'); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1
|
||||
body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
nextidx=idx+1
|
||||
p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash']
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash')
|
||||
if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st)
|
||||
print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}")
|
||||
return True
|
||||
|
||||
async def main_async(args):
|
||||
cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds']
|
||||
for k in required:
|
||||
if k not in cfg: raise ValueError('missing config: '+k)
|
||||
base=Path(args.config).resolve().parent
|
||||
for k in ('key_file','queue_file','state_file'):
|
||||
p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p)
|
||||
items=queue_items(cfg)
|
||||
st=load_json(cfg['state_file'])
|
||||
if st is None: st=await init_state(cfg); print('Initialized state from server/channel')
|
||||
st=await reconcile(cfg,st)
|
||||
if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return
|
||||
if args.command=='publish-now': await publish_one(cfg,st,items); return
|
||||
while True:
|
||||
st=await reconcile(cfg,load_json(cfg['state_file']))
|
||||
wait=max(0,int(st.get('next_publish_at',0))-int(time.time()))
|
||||
if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait)
|
||||
ok=await publish_one(cfg,st,items)
|
||||
if not ok: await asyncio.sleep(3600)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='SHiNE test channel publisher')
|
||||
ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json')
|
||||
a=ap.parse_args()
|
||||
try: asyncio.run(main_async(a))
|
||||
except KeyboardInterrupt: pass
|
||||
except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1)
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,182 @@
|
||||
[
|
||||
{
|
||||
"text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java."
|
||||
},
|
||||
{
|
||||
"text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой."
|
||||
},
|
||||
{
|
||||
"text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра."
|
||||
},
|
||||
{
|
||||
"text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы."
|
||||
},
|
||||
{
|
||||
"text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья."
|
||||
},
|
||||
{
|
||||
"text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен."
|
||||
},
|
||||
{
|
||||
"text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается."
|
||||
},
|
||||
{
|
||||
"text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать."
|
||||
},
|
||||
{
|
||||
"text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный»."
|
||||
},
|
||||
{
|
||||
"text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть."
|
||||
},
|
||||
{
|
||||
"text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать."
|
||||
},
|
||||
{
|
||||
"text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя."
|
||||
},
|
||||
{
|
||||
"text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее."
|
||||
},
|
||||
{
|
||||
"text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой."
|
||||
},
|
||||
{
|
||||
"text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно."
|
||||
},
|
||||
{
|
||||
"text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд."
|
||||
},
|
||||
{
|
||||
"text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно."
|
||||
},
|
||||
{
|
||||
"text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать."
|
||||
},
|
||||
{
|
||||
"text": "— Что делаешь? — Ничего. — А вчера? — Не закончил."
|
||||
},
|
||||
{
|
||||
"text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке."
|
||||
},
|
||||
{
|
||||
"text": "План на день был прекрасен. Жаль, что день о нём ничего не знал."
|
||||
},
|
||||
{
|
||||
"text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее."
|
||||
},
|
||||
{
|
||||
"text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты."
|
||||
},
|
||||
{
|
||||
"text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник."
|
||||
},
|
||||
{
|
||||
"text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше."
|
||||
},
|
||||
{
|
||||
"text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди."
|
||||
},
|
||||
{
|
||||
"text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу."
|
||||
},
|
||||
{
|
||||
"text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления."
|
||||
},
|
||||
{
|
||||
"text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука."
|
||||
},
|
||||
{
|
||||
"text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры."
|
||||
},
|
||||
{
|
||||
"text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось."
|
||||
},
|
||||
{
|
||||
"text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта."
|
||||
},
|
||||
{
|
||||
"text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь."
|
||||
},
|
||||
{
|
||||
"text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес."
|
||||
},
|
||||
{
|
||||
"text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду."
|
||||
},
|
||||
{
|
||||
"text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа."
|
||||
},
|
||||
{
|
||||
"text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил."
|
||||
},
|
||||
{
|
||||
"text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%."
|
||||
},
|
||||
{
|
||||
"text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник."
|
||||
},
|
||||
{
|
||||
"text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном."
|
||||
},
|
||||
{
|
||||
"text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут."
|
||||
},
|
||||
{
|
||||
"text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя."
|
||||
},
|
||||
{
|
||||
"text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся."
|
||||
},
|
||||
{
|
||||
"text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием."
|
||||
},
|
||||
{
|
||||
"text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать."
|
||||
},
|
||||
{
|
||||
"text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается."
|
||||
},
|
||||
{
|
||||
"text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы."
|
||||
},
|
||||
{
|
||||
"text": "— Ты выключил утюг? — Теперь это главная интрига поездки."
|
||||
},
|
||||
{
|
||||
"text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает."
|
||||
},
|
||||
{
|
||||
"text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления."
|
||||
},
|
||||
{
|
||||
"text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю."
|
||||
},
|
||||
{
|
||||
"text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены."
|
||||
},
|
||||
{
|
||||
"text": "Микроволновка пищит так, будто лично приготовила ужин с нуля."
|
||||
},
|
||||
{
|
||||
"text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях."
|
||||
},
|
||||
{
|
||||
"text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать»."
|
||||
},
|
||||
{
|
||||
"text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона."
|
||||
},
|
||||
{
|
||||
"text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду."
|
||||
},
|
||||
{
|
||||
"text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым."
|
||||
},
|
||||
{
|
||||
"text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча."
|
||||
},
|
||||
{
|
||||
"text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
cryptography>=42
|
||||
websockets>=14
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=SHiNE test channel publisher
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=shine
|
||||
WorkingDirectory=/opt/shine-test-publisher
|
||||
ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
keys/*.json
|
||||
!keys/README.txt
|
||||
state.json
|
||||
multi-state.json
|
||||
upload-state.json
|
||||
channels/*.prepared.json
|
||||
config.json
|
||||
config.multi.json
|
||||
prepare.config.json
|
||||
prepare.multi.json
|
||||
__pycache__/
|
||||
.venv/
|
||||
@@ -0,0 +1,87 @@
|
||||
# CODEX_VPS_SETUP.md — развернуть автономный SHiNE test publisher
|
||||
|
||||
Цель: установить test publisher на Ubuntu VPS так, чтобы он постоянно работал через systemd, переживал reboot/сбой сети, публиковал в несколько каналов одного тестового пользователя и не требовал ручного запуска.
|
||||
|
||||
## Что агент должен получить от оператора
|
||||
Никогда не печатай приватные ключи в лог.
|
||||
1. Реальный `blockchain_name` тестового пользователя.
|
||||
2. Solana-format JSON blockchain key → `keys/blockchain.json`.
|
||||
3. Arweave JWK → `keys/arweave-wallet.json` (нужен только для prepare).
|
||||
4. Для каждого канала реальный `channel_root_block_number` CREATE_CHANNEL.
|
||||
5. Подтверждение WebSocket URL сервера (example: `wss://t3.shineup.me/ws`).
|
||||
|
||||
## Установка
|
||||
Предполагаемый каталог: `/opt/shine-test-publisher/v2`, Linux user: `shine`.
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /opt/shine-test-publisher --shell /usr/sbin/nologin shine 2>/dev/null || true
|
||||
sudo mkdir -p /opt/shine-test-publisher
|
||||
sudo chown -R shine:shine /opt/shine-test-publisher
|
||||
# скопировать содержимое tools/test-publisher/v2 в /opt/shine-test-publisher/v2
|
||||
|
||||
cd /opt/shine-test-publisher
|
||||
sudo -u shine python3 -m venv .venv
|
||||
sudo -u shine .venv/bin/pip install -r v2/requirements.txt
|
||||
sudo chmod 700 v2/keys
|
||||
sudo chmod 600 v2/keys/*.json
|
||||
```
|
||||
|
||||
`requirements.txt` должен содержать зависимости publisher и prepare (`cryptography`, `websockets`, `requests`, `arweave-python-client`).
|
||||
|
||||
## Конфиги
|
||||
Скопировать:
|
||||
- `config.multi.example.json` → `config.multi.json`
|
||||
- `prepare.multi.example.json` → `prepare.multi.json`
|
||||
|
||||
Заменить placeholders. Не угадывать CREATE_CHANNEL block numbers.
|
||||
|
||||
Расписание по умолчанию:
|
||||
- jokes: случайно 1–3 активных часа;
|
||||
- остальные: случайно 3–7 активных часов;
|
||||
- timezone: Europe/Warsaw;
|
||||
- quiet: 22:00–08:00.
|
||||
|
||||
Quiet hours НЕ расходуют интервал. Если из 5 часов до ночи прошло 2 часа, после 08:00 должно остаться 3 часа.
|
||||
|
||||
## Подготовка media
|
||||
Один раз и затем после добавления новых картинок:
|
||||
```bash
|
||||
cd /opt/shine-test-publisher/v2
|
||||
../.venv/bin/python prepare/prepare_all_channels.py --config prepare.multi.json
|
||||
```
|
||||
Скрипт обязан пройти ВСЕ channels. Известный Arweave ID сначала проверяется через gateway. Существующий файл повторно не загружается. Отсутствующий — загружается и state обновляется атомарно.
|
||||
|
||||
После успешной подготовки JWK не нужен постоянному publisher. Для минимизации риска можно перенести JWK в защищённое offline-хранилище; тогда повторный prepare потребует вернуть его.
|
||||
|
||||
## Проверка до systemd
|
||||
```bash
|
||||
../.venv/bin/python publisher/publisher_multi.py status --config config.multi.json
|
||||
../.venv/bin/python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json
|
||||
```
|
||||
Проверить появление блока на SHiNE server и затем в Viewer. Не запускать два экземпляра publisher для одного blockchain key.
|
||||
|
||||
## systemd
|
||||
```bash
|
||||
sudo cp systemd/shine-test-publisher-multi.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now shine-test-publisher-multi
|
||||
sudo systemctl status shine-test-publisher-multi
|
||||
journalctl -u shine-test-publisher-multi -f
|
||||
```
|
||||
|
||||
Unit использует `Restart=on-failure`; publisher хранит pending/state на диске и после рестарта сверяет global head перед продолжением. При конфликте global head он обязан остановиться, а не создавать форк.
|
||||
|
||||
## После настройки
|
||||
Проверить:
|
||||
- `systemctl is-enabled` = enabled;
|
||||
- `systemctl is-active` = active;
|
||||
- `multi-state.json` создаётся и меняется;
|
||||
- в quiet hours нет публикаций;
|
||||
- reboot VPS → service возвращается в active;
|
||||
- приватные JSON имеют mode 600;
|
||||
- prepared queues существуют для всех пяти каналов.
|
||||
|
||||
## Запреты
|
||||
Не коммитить/архивировать реальные keys, JWK, `multi-state.json`, `upload-state.json`.
|
||||
Не запускать параллельно v1/v2 single publisher и multi publisher с одним ключом.
|
||||
Не сбрасывать state ради устранения ошибки chain-head: сначала выяснить расхождение.
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "friend-001",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-002",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-003",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-004",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-005",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-006",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-007",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-008",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-009",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?»",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-010",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-011",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-012",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-013",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-014",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-015",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-016",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-017",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-018",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-019",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-020",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-021",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-022",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-023",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-024",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-025",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp",
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-026",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-027",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp",
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-028",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-029",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp",
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-030",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-031",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp",
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-032",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-033",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp",
|
||||
"../assets/channel-art/friends-hello.webp",
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-034",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-035",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp",
|
||||
"../assets/channel-art/friends-listen.webp",
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-036",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-037",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-038",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-039",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-040",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-041",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-042",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-043",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-044",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-045",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,345 @@
|
||||
[
|
||||
{
|
||||
"id": "001",
|
||||
"text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "002",
|
||||
"text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.",
|
||||
"images": [
|
||||
"../queue/images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "003",
|
||||
"text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "004",
|
||||
"text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "005",
|
||||
"text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.",
|
||||
"images": [
|
||||
"../queue/images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "006",
|
||||
"text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.",
|
||||
"images": [
|
||||
"../queue/images/joke_06.webp",
|
||||
"../queue/images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "007",
|
||||
"text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "008",
|
||||
"text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.",
|
||||
"images": [
|
||||
"../queue/images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "009",
|
||||
"text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "010",
|
||||
"text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "011",
|
||||
"text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.",
|
||||
"images": [
|
||||
"../queue/images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "012",
|
||||
"text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "013",
|
||||
"text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "014",
|
||||
"text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.",
|
||||
"images": [
|
||||
"../queue/images/joke_04.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "015",
|
||||
"text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "016",
|
||||
"text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "017",
|
||||
"text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.",
|
||||
"images": [
|
||||
"../queue/images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "018",
|
||||
"text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.",
|
||||
"images": [
|
||||
"../queue/images/joke_08.webp",
|
||||
"../queue/images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019",
|
||||
"text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "020",
|
||||
"text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.",
|
||||
"images": [
|
||||
"../queue/images/joke_10.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "021",
|
||||
"text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "022",
|
||||
"text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "023",
|
||||
"text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.",
|
||||
"images": [
|
||||
"../queue/images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "024",
|
||||
"text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "025",
|
||||
"text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "026",
|
||||
"text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.",
|
||||
"images": [
|
||||
"../queue/images/joke_06.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "027",
|
||||
"text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "028",
|
||||
"text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "029",
|
||||
"text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.",
|
||||
"images": [
|
||||
"../queue/images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "030",
|
||||
"text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.",
|
||||
"images": [
|
||||
"../queue/images/joke_10.webp",
|
||||
"../queue/images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "031",
|
||||
"text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "032",
|
||||
"text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.",
|
||||
"images": [
|
||||
"../queue/images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "033",
|
||||
"text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "034",
|
||||
"text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "035",
|
||||
"text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.",
|
||||
"images": [
|
||||
"../queue/images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "036",
|
||||
"text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "037",
|
||||
"text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "038",
|
||||
"text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.",
|
||||
"images": [
|
||||
"../queue/images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "039",
|
||||
"text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "040",
|
||||
"text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "041",
|
||||
"text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.",
|
||||
"images": [
|
||||
"../queue/images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "042",
|
||||
"text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.",
|
||||
"images": [
|
||||
"../queue/images/joke_02.webp",
|
||||
"../queue/images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "043",
|
||||
"text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "044",
|
||||
"text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "045",
|
||||
"text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "046",
|
||||
"text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "047",
|
||||
"text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "048",
|
||||
"text": "— Ты выключил утюг? — Теперь это главная интрига поездки.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "049",
|
||||
"text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "050",
|
||||
"text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "051",
|
||||
"text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "052",
|
||||
"text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "053",
|
||||
"text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "054",
|
||||
"text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.",
|
||||
"images": [
|
||||
"../queue/images/joke_04.webp",
|
||||
"../queue/images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "055",
|
||||
"text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "056",
|
||||
"text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "057",
|
||||
"text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "058",
|
||||
"text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "059",
|
||||
"text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "060",
|
||||
"text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "mind-001",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-002",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-003",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-004",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-005",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-006",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-007",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-008",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-009",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-010",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-011",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-012",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-013",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-014",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-015",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-016",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-017",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-018",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-019",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-020",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-021",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-022",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-023",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-024",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-025",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp",
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-026",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-027",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp",
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-028",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-029",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp",
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-030",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-031",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp",
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-032",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-033",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp",
|
||||
"../assets/channel-art/mindfulness-tea.webp",
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-034",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-035",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp",
|
||||
"../assets/channel-art/mindfulness-walk.webp",
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-036",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-037",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-038",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-039",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-040",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-041",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-042",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-043",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-044",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-045",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "pos-001",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут.",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-002",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-003",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас?",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-004",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-005",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой.",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-006",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-007",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление.",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-008",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-009",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?»",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-010",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-011",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-012",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-013",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-014",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-015",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-016",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-017",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-018",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-019",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-020",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-021",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-022",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-023",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-024",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-025",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp",
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-026",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-027",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp",
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-028",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-029",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp",
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-030",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-031",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp",
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-032",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-033",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp",
|
||||
"../assets/channel-art/positive-spark.webp",
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-034",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-035",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp",
|
||||
"../assets/channel-art/positive-step.webp",
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-036",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-037",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-038",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-039",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-040",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-041",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-042",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-043",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-044",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-045",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "wise-001",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-002",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-003",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-004",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-005",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-006",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-007",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-008",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее?",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-009",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-010",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-011",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-012",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-013",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-014",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-015",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-016",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-017",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-018",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-019",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-020",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-021",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-022",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-023",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-024",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-025",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp",
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-026",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-027",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp",
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-028",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-029",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp",
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-030",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-031",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp",
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-032",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-033",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp",
|
||||
"../assets/channel-art/wisdom-moon.webp",
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-034",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-035",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp",
|
||||
"../assets/channel-art/wisdom-path.webp",
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-036",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-037",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-038",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-039",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-040",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-041",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-042",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-043",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-044",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-045",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"server_ws": "ws://127.0.0.1:8080/ws",
|
||||
"blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE",
|
||||
"channel_name": "jokes",
|
||||
"channel_root_block_number": 123,
|
||||
"key_file": "keys/blockchain.json",
|
||||
"queue_file": "queue/jokes.prepared.json",
|
||||
"state_file": "state.json",
|
||||
"min_interval_seconds": 1800,
|
||||
"max_interval_seconds": 10800,
|
||||
"loop_queue": false
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"server_ws": "wss://t3.shineup.me/ws",
|
||||
"blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE",
|
||||
"key_file": "keys/blockchain.json",
|
||||
"state_file": "multi-state.json",
|
||||
"min_interval_seconds": 1800,
|
||||
"max_interval_seconds": 10800,
|
||||
"loop_queue": false,
|
||||
"channels": [
|
||||
{
|
||||
"channel_name": "jokes",
|
||||
"channel_root_block_number": 101,
|
||||
"queue_file": "channels/jokes.prepared.json",
|
||||
"min_interval_seconds": 3600,
|
||||
"max_interval_seconds": 10800
|
||||
},
|
||||
{
|
||||
"channel_name": "mindfulness",
|
||||
"channel_root_block_number": 102,
|
||||
"queue_file": "channels/mindfulness.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
},
|
||||
{
|
||||
"channel_name": "positive",
|
||||
"channel_root_block_number": 103,
|
||||
"queue_file": "channels/positive.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
},
|
||||
{
|
||||
"channel_name": "friends",
|
||||
"channel_root_block_number": 104,
|
||||
"queue_file": "channels/friends.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
},
|
||||
{
|
||||
"channel_name": "wisdom",
|
||||
"channel_root_block_number": 105,
|
||||
"queue_file": "channels/wisdom.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
}
|
||||
],
|
||||
"timezone": "Europe/Warsaw",
|
||||
"quiet_hours": {
|
||||
"enabled": true,
|
||||
"start": "22:00",
|
||||
"end": "08:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
Put secrets here locally only; never commit them.
|
||||
blockchain.json - SHiNE/Solana 64-byte JSON key array used by publisher
|
||||
arweave-wallet.json - Arweave JWK used ONLY by prepare_queue.py to upload images
|
||||
Recommended: chmod 600 keys/*.json
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"queue_file": "queue/jokes.raw.json",
|
||||
"prepared_queue_file": "queue/jokes.prepared.json",
|
||||
"upload_state_file": "upload-state.json",
|
||||
"arweave": {
|
||||
"wallet_file": "keys/arweave-wallet.json",
|
||||
"gateway": "https://arweave.net"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"upload_state_file": "upload-state.json",
|
||||
"arweave": {
|
||||
"wallet_file": "keys/arweave-wallet.json",
|
||||
"gateway": "https://arweave.net"
|
||||
},
|
||||
"channels": [
|
||||
{
|
||||
"name": "jokes",
|
||||
"queue_file": "channels/jokes.raw.json",
|
||||
"prepared_queue_file": "channels/jokes.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "mindfulness",
|
||||
"queue_file": "channels/mindfulness.raw.json",
|
||||
"prepared_queue_file": "channels/mindfulness.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"queue_file": "channels/positive.raw.json",
|
||||
"prepared_queue_file": "channels/positive.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "friends",
|
||||
"queue_file": "channels/friends.raw.json",
|
||||
"prepared_queue_file": "channels/friends.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "wisdom",
|
||||
"queue_file": "channels/wisdom.raw.json",
|
||||
"prepared_queue_file": "channels/wisdom.prepared.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse,json,hashlib,mimetypes,time
|
||||
from pathlib import Path
|
||||
import requests
|
||||
try: import arweave
|
||||
except ImportError: arweave=None
|
||||
|
||||
def atomic(path,obj):
|
||||
p=Path(path); p.parent.mkdir(parents=True,exist_ok=True)
|
||||
t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p)
|
||||
|
||||
def exists(gateway,txid,timeout=15):
|
||||
url=f"{gateway.rstrip('/')}/{txid}"
|
||||
try:
|
||||
r=requests.head(url,allow_redirects=True,timeout=timeout)
|
||||
if r.status_code in (200,204): return True
|
||||
if r.status_code in (405,501):
|
||||
r=requests.get(url,headers={'Range':'bytes=0-0'},timeout=timeout)
|
||||
return r.status_code in (200,206)
|
||||
return False
|
||||
except requests.RequestException: return False
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='Prepare images for all SHiNE test channels')
|
||||
ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args()
|
||||
cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent
|
||||
rp=lambda s:(Path(s) if Path(s).is_absolute() else (base/s).resolve())
|
||||
gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/')
|
||||
statep=rp(cfg.get('upload_state_file','upload-state.json'))
|
||||
state=json.loads(statep.read_text()) if statep.exists() else {'version':2,'files':{}}
|
||||
wallet=None
|
||||
def ensure_wallet():
|
||||
nonlocal wallet
|
||||
if wallet is None:
|
||||
if arweave is None: raise SystemExit('pip install -r requirements.txt')
|
||||
wallet=arweave.Wallet(str(rp(cfg['arweave']['wallet_file'])))
|
||||
return wallet
|
||||
|
||||
summary={'channels':0,'posts':0,'images':0,'verified_existing':0,'uploaded':0}
|
||||
for ch in cfg['channels']:
|
||||
rawp=rp(ch['queue_file']); outp=rp(ch['prepared_queue_file'])
|
||||
items=json.loads(rawp.read_text(encoding='utf8')); prepared=[]; summary['channels']+=1
|
||||
for n,item in enumerate(items,1):
|
||||
refs=[]
|
||||
for rel in item.get('images',[]):
|
||||
fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest()
|
||||
summary['images']+=1; rec=state['files'].get(sha); txid=None
|
||||
if rec and exists(gateway,rec['id']):
|
||||
txid=rec['id']; summary['verified_existing']+=1
|
||||
print(f"[{ch['name']}:{n}] verified {fp.name} -> {txid}")
|
||||
else:
|
||||
ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream'
|
||||
tx=arweave.Transaction(ensure_wallet(),data=data)
|
||||
tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha)
|
||||
tx.sign(); tx.send(); txid=tx.id; summary['uploaded']+=1
|
||||
state['files'][sha]={'id':txid,'file':str(fp),'uploaded_at':int(time.time())}; atomic(statep,state)
|
||||
print(f"[{ch['name']}:{n}] uploaded {fp.name} -> {txid}")
|
||||
refs.append({'arweave_id':txid,'url':f'{gateway}/{txid}','sha256':sha})
|
||||
text=item['text'].rstrip()
|
||||
if refs: text += '\n\n' + '\n'.join(x['url'] for x in refs)
|
||||
prepared.append({'id':item.get('id',str(n)),'text':text,'images':refs})
|
||||
summary['posts']+=1
|
||||
atomic(outp,prepared)
|
||||
print(f"Ready {ch['name']}: {outp} ({len(prepared)} posts)")
|
||||
print(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse,json,hashlib,mimetypes,sys,time
|
||||
from pathlib import Path
|
||||
import requests
|
||||
try:
|
||||
import arweave
|
||||
except ImportError:
|
||||
arweave=None
|
||||
|
||||
def atomic(path,obj):
|
||||
p=Path(path); t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='Upload all queue images to Arweave and build prepared queue')
|
||||
ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args()
|
||||
cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent
|
||||
def rp(s):
|
||||
p=Path(s); return p if p.is_absolute() else (base/p).resolve()
|
||||
rawp=rp(cfg['queue_file']); outp=rp(cfg['prepared_queue_file']); walletp=rp(cfg['arweave']['wallet_file']); gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/')
|
||||
statep=rp(cfg.get('upload_state_file','upload-state.json')); state=json.loads(statep.read_text()) if statep.exists() else {'files':{}}
|
||||
if arweave is None: raise SystemExit('Install requirements first: pip install -r requirements.txt')
|
||||
wallet=arweave.Wallet(str(walletp))
|
||||
items=json.loads(rawp.read_text(encoding='utf8')); prepared=[]
|
||||
for n,item in enumerate(items,1):
|
||||
urls=[]; ids=[]
|
||||
for rel in item.get('images',[]):
|
||||
fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest(); old=state['files'].get(sha)
|
||||
if old: txid=old['id']; print(f'[{n}] cached {fp.name} -> {txid}')
|
||||
else:
|
||||
ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream'
|
||||
tx=arweave.Transaction(wallet, data=data); tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha); tx.sign(); tx.send(); txid=tx.id
|
||||
state['files'][sha]={'id':txid,'file':fp.name,'uploaded_at':int(time.time())}; atomic(statep,state); print(f'[{n}] uploaded {fp.name} -> {txid}')
|
||||
ids.append(txid); urls.append(f'{gateway}/{txid}')
|
||||
text=item['text'].rstrip()
|
||||
if urls:
|
||||
# Current SHiNE POST payload is text, so URLs are appended as ordinary lines.
|
||||
text += '\n\n' + '\n'.join(urls)
|
||||
prepared.append({'id':item.get('id',str(n)),'text':text,'images':[{'arweave_id':i,'url':u} for i,u in zip(ids,urls)]})
|
||||
atomic(outp,prepared)
|
||||
print(f'Ready: {outp} ({len(prepared)} posts)')
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
ZERO32 = bytes(32)
|
||||
FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1
|
||||
|
||||
def atomic_json(path, obj):
|
||||
path=Path(path); path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp=path.with_suffix(path.suffix+'.tmp')
|
||||
tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
os.replace(tmp,path)
|
||||
|
||||
def load_json(path, default=None):
|
||||
p=Path(path)
|
||||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default
|
||||
|
||||
def avro_long(n):
|
||||
n=(n<<1) ^ (n>>63); out=bytearray()
|
||||
while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7
|
||||
out.append(n); return bytes(out)
|
||||
|
||||
def avro_tags(tags):
|
||||
out=bytearray()
|
||||
if tags:
|
||||
out += avro_long(len(tags))
|
||||
for k,v in tags:
|
||||
kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb
|
||||
out += avro_long(0); return bytes(out)
|
||||
|
||||
def deep_hash(x):
|
||||
H=lambda b: hashlib.sha384(b).digest()
|
||||
if isinstance(x,(bytes,bytearray)):
|
||||
b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b))
|
||||
acc=H(f'list{len(x)}'.encode())
|
||||
for child in x: acc=H(acc+deep_hash(child))
|
||||
return acc
|
||||
|
||||
def signing_message(owner,tags,data):
|
||||
raw=avro_tags(tags)
|
||||
return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data])
|
||||
|
||||
def data_item(priv64,tags,data):
|
||||
if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes')
|
||||
seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
|
||||
sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data))
|
||||
rawtags=avro_tags(tags)
|
||||
return struct.pack('<H',2)+sig+owner+b'\0\0'+struct.pack('<QQ',len(tags),len(rawtags))+rawtags+data
|
||||
|
||||
def post_body(line_code, prev_line_num, prev_line_hash, this_line_num, text):
|
||||
tb=text.encode('utf-8')
|
||||
if len(tb)>65535: raise ValueError('post text too long')
|
||||
return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb
|
||||
|
||||
def frame(prev_hash, block_num, body, ts=None):
|
||||
ts=int(time.time()) if ts is None else int(ts); size=56+len(body)
|
||||
return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body
|
||||
|
||||
def h32(b): return hashlib.sha256(b).digest()
|
||||
def hx(b): return b.hex()
|
||||
|
||||
async def ws_call(url, op, payload, timeout=20):
|
||||
req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload}
|
||||
async with connect(url, open_timeout=timeout, close_timeout=5) as ws:
|
||||
await ws.send(json.dumps(req,separators=(',',':')))
|
||||
end=time.monotonic()+timeout
|
||||
while True:
|
||||
left=end-time.monotonic()
|
||||
if left<=0: raise TimeoutError(op)
|
||||
msg=await asyncio.wait_for(ws.recv(),left)
|
||||
obj=json.loads(msg)
|
||||
if obj.get('requestId')==req['requestId']: return obj
|
||||
|
||||
async def head(cfg):
|
||||
r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{})
|
||||
if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}')
|
||||
for x in r.get('payload',{}).get('blockchains',[]):
|
||||
if x.get('blockchainName')==cfg['blockchain_name']:
|
||||
return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','')
|
||||
raise RuntimeError('blockchain not found on server')
|
||||
|
||||
async def block(cfg,n):
|
||||
r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}')
|
||||
p=r.get('payload',r)
|
||||
return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64')
|
||||
|
||||
async def channel_tail(cfg, root_hash):
|
||||
r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}')
|
||||
items=r.get('payload',{}).get('messages',[])
|
||||
if not items: return 0, ZERO32
|
||||
m=items[0]; ref=m.get('messageRef') or {}
|
||||
return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32))
|
||||
|
||||
def read_key(path):
|
||||
a=load_json(path)
|
||||
if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array')
|
||||
return bytes(int(x)&255 for x in a)
|
||||
|
||||
def queue_items(cfg):
|
||||
q=load_json(cfg['queue_file'])
|
||||
if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array')
|
||||
for x in q:
|
||||
if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text')
|
||||
return q
|
||||
|
||||
def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds']))
|
||||
|
||||
async def init_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
root_hash,_=await block(cfg,int(cfg['channel_root_block_number']))
|
||||
line_num,line_hash=await channel_tail(cfg,root_hash)
|
||||
st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
async def reconcile(cfg,st):
|
||||
p=st.get('pending')
|
||||
bn,bhash=await head(cfg)
|
||||
if p:
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn)
|
||||
elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower():
|
||||
print('Pending block was not accepted; it will be retried')
|
||||
else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}')
|
||||
else:
|
||||
if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_one(cfg,st,items):
|
||||
if st.get('pending'):
|
||||
p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash']
|
||||
else:
|
||||
idx=int(st['next_index'])
|
||||
if idx>=len(items):
|
||||
if cfg.get('loop_queue',False): idx=0
|
||||
else: print('Queue exhausted. Nothing to publish.'); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1
|
||||
body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
nextidx=idx+1
|
||||
p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash']
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash')
|
||||
if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st)
|
||||
print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}")
|
||||
return True
|
||||
|
||||
async def main_async(args):
|
||||
cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds']
|
||||
for k in required:
|
||||
if k not in cfg: raise ValueError('missing config: '+k)
|
||||
base=Path(args.config).resolve().parent
|
||||
for k in ('key_file','queue_file','state_file'):
|
||||
p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p)
|
||||
items=queue_items(cfg)
|
||||
st=load_json(cfg['state_file'])
|
||||
if st is None: st=await init_state(cfg); print('Initialized state from server/channel')
|
||||
st=await reconcile(cfg,st)
|
||||
if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return
|
||||
if args.command=='publish-now': await publish_one(cfg,st,items); return
|
||||
while True:
|
||||
st=await reconcile(cfg,load_json(cfg['state_file']))
|
||||
wait=max(0,int(st.get('next_publish_at',0))-int(time.time()))
|
||||
if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait)
|
||||
ok=await publish_one(cfg,st,items)
|
||||
if not ok: await asyncio.sleep(3600)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='SHiNE test channel publisher')
|
||||
ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json')
|
||||
a=ap.parse_args()
|
||||
try: asyncio.run(main_async(a))
|
||||
except KeyboardInterrupt: pass
|
||||
except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1)
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
ZERO32 = bytes(32)
|
||||
FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1
|
||||
|
||||
def atomic_json(path, obj):
|
||||
path=Path(path); path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp=path.with_suffix(path.suffix+'.tmp')
|
||||
tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
os.replace(tmp,path)
|
||||
|
||||
def load_json(path, default=None):
|
||||
p=Path(path)
|
||||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default
|
||||
|
||||
def avro_long(n):
|
||||
n=(n<<1) ^ (n>>63); out=bytearray()
|
||||
while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7
|
||||
out.append(n); return bytes(out)
|
||||
|
||||
def avro_tags(tags):
|
||||
out=bytearray()
|
||||
if tags:
|
||||
out += avro_long(len(tags))
|
||||
for k,v in tags:
|
||||
kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb
|
||||
out += avro_long(0); return bytes(out)
|
||||
|
||||
def deep_hash(x):
|
||||
H=lambda b: hashlib.sha384(b).digest()
|
||||
if isinstance(x,(bytes,bytearray)):
|
||||
b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b))
|
||||
acc=H(f'list{len(x)}'.encode())
|
||||
for child in x: acc=H(acc+deep_hash(child))
|
||||
return acc
|
||||
|
||||
def signing_message(owner,tags,data):
|
||||
raw=avro_tags(tags)
|
||||
return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data])
|
||||
|
||||
def data_item(priv64,tags,data):
|
||||
if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes')
|
||||
seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
|
||||
sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data))
|
||||
rawtags=avro_tags(tags)
|
||||
return struct.pack('<H',2)+sig+owner+b'\0\0'+struct.pack('<QQ',len(tags),len(rawtags))+rawtags+data
|
||||
|
||||
def post_body(line_code, prev_line_num, prev_line_hash, this_line_num, text):
|
||||
tb=text.encode('utf-8')
|
||||
if len(tb)>65535: raise ValueError('post text too long')
|
||||
return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb
|
||||
|
||||
def frame(prev_hash, block_num, body, ts=None):
|
||||
ts=int(time.time()) if ts is None else int(ts); size=56+len(body)
|
||||
return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body
|
||||
|
||||
def h32(b): return hashlib.sha256(b).digest()
|
||||
def hx(b): return b.hex()
|
||||
|
||||
async def ws_call(url, op, payload, timeout=20):
|
||||
req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload}
|
||||
async with connect(url, open_timeout=timeout, close_timeout=5) as ws:
|
||||
await ws.send(json.dumps(req,separators=(',',':')))
|
||||
end=time.monotonic()+timeout
|
||||
while True:
|
||||
left=end-time.monotonic()
|
||||
if left<=0: raise TimeoutError(op)
|
||||
msg=await asyncio.wait_for(ws.recv(),left)
|
||||
obj=json.loads(msg)
|
||||
if obj.get('requestId')==req['requestId']: return obj
|
||||
|
||||
async def head(cfg):
|
||||
r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{})
|
||||
if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}')
|
||||
for x in r.get('payload',{}).get('blockchains',[]):
|
||||
if x.get('blockchainName')==cfg['blockchain_name']:
|
||||
return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','')
|
||||
raise RuntimeError('blockchain not found on server')
|
||||
|
||||
async def block(cfg,n):
|
||||
r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}')
|
||||
p=r.get('payload',r)
|
||||
return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64')
|
||||
|
||||
async def channel_tail(cfg, root_hash):
|
||||
r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}')
|
||||
items=r.get('payload',{}).get('messages',[])
|
||||
if not items: return int(cfg['channel_root_block_number']), bytes.fromhex(root_hash), -1
|
||||
m=items[0]; ref=m.get('messageRef') or {}
|
||||
return int(ref.get('blockNumber')), bytes.fromhex(ref.get('blockHash','00'*32)), int(m.get('lineStep') or 0)
|
||||
|
||||
def read_key(path):
|
||||
a=load_json(path)
|
||||
if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array')
|
||||
return bytes(int(x)&255 for x in a)
|
||||
|
||||
def queue_items(cfg):
|
||||
q=load_json(cfg['queue_file'])
|
||||
if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array')
|
||||
for x in q:
|
||||
if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text')
|
||||
return q
|
||||
|
||||
def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds']))
|
||||
|
||||
async def init_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
root_hash,_=await block(cfg,int(cfg['channel_root_block_number']))
|
||||
line_num,line_hash=await channel_tail(cfg,root_hash)
|
||||
st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
async def reconcile(cfg,st):
|
||||
p=st.get('pending')
|
||||
bn,bhash=await head(cfg)
|
||||
if p:
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn)
|
||||
elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower():
|
||||
print('Pending block was not accepted; it will be retried')
|
||||
else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}')
|
||||
else:
|
||||
if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_one(cfg,st,items):
|
||||
if st.get('pending'):
|
||||
p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash']
|
||||
else:
|
||||
idx=int(st['next_index'])
|
||||
if idx>=len(items):
|
||||
if cfg.get('loop_queue',False): idx=0
|
||||
else: print('Queue exhausted. Nothing to publish.'); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1
|
||||
body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
nextidx=idx+1
|
||||
p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash']
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash')
|
||||
if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st)
|
||||
print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}")
|
||||
return True
|
||||
|
||||
|
||||
async def init_multi_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
chans={}
|
||||
now=int(time.time())
|
||||
for ch in cfg['channels']:
|
||||
local=dict(cfg); local.update(ch)
|
||||
root_hash,_=await block(local,int(ch['channel_root_block_number']))
|
||||
line_block,line_hash,line_step=await channel_tail(local,root_hash)
|
||||
chans[ch['channel_name']]={'next_index':0,'last_line_block_number':line_block,
|
||||
'last_line_number':line_step,'last_line_hash':hx(line_hash),
|
||||
'next_publish_at':next_channel_time(cfg,ch,now),
|
||||
'published_total':0}
|
||||
st={'version':2,'last_block_number':bn,'last_block_hash':bhash,'pending':None,'channels':chans}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
def channel_delay(cfg,ch):
|
||||
return random.randint(int(ch.get('min_interval_seconds',cfg['min_interval_seconds'])),int(ch.get('max_interval_seconds',cfg['max_interval_seconds'])))
|
||||
|
||||
def _hm(v):
|
||||
h,m=str(v).split(':',1); return int(h),int(m)
|
||||
|
||||
def _quiet(dt,ch,cfg):
|
||||
q=ch.get('quiet_hours',cfg.get('quiet_hours',{}))
|
||||
if not q or not q.get('enabled',True): return False
|
||||
sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00'))
|
||||
t=dt.hour*60+dt.minute; a=sh*60+sm; b=eh*60+em
|
||||
return (a<=t or t<b) if a>b else (a<=t<b)
|
||||
|
||||
def active_deadline(cfg,ch,active_seconds,start_ts=None):
|
||||
"""Advance only through non-quiet seconds. Quiet time does not consume interval."""
|
||||
tz=ZoneInfo(ch.get('timezone',cfg.get('timezone','UTC')))
|
||||
dt=datetime.fromtimestamp(start_ts or time.time(),tz)
|
||||
remain=int(active_seconds)
|
||||
# Minute/second boundaries are tiny compared with 3h intervals; calculate exact chunks.
|
||||
while remain>0:
|
||||
q=ch.get('quiet_hours',cfg.get('quiet_hours',{}))
|
||||
if not q or not q.get('enabled',True):
|
||||
return int((dt+timedelta(seconds=remain)).timestamp())
|
||||
sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00'))
|
||||
if _quiet(dt,ch,cfg):
|
||||
end=dt.replace(hour=eh,minute=em,second=0,microsecond=0)
|
||||
if end<=dt: end+=timedelta(days=1)
|
||||
dt=end; continue
|
||||
qs=dt.replace(hour=sh,minute=sm,second=0,microsecond=0)
|
||||
if qs<=dt: qs+=timedelta(days=1)
|
||||
usable=max(0,int((qs-dt).total_seconds()))
|
||||
if remain<=usable: return int((dt+timedelta(seconds=remain)).timestamp())
|
||||
remain-=usable; dt=qs
|
||||
return int(dt.timestamp())
|
||||
|
||||
def next_channel_time(cfg,ch,start_ts=None):
|
||||
return active_deadline(cfg,ch,channel_delay(cfg,ch),start_ts)
|
||||
|
||||
async def reconcile_multi(cfg,st):
|
||||
bn,bhash=await head(cfg); p=st.get('pending')
|
||||
if p:
|
||||
cs=st['channels'][p['channel_name']]
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash
|
||||
cs['last_line_block_number']=p['block_number']; cs['last_line_number']=p['this_line_number']; cs['last_line_hash']=p['block_hash']
|
||||
cs['next_index']=p['next_index_after']; cs['published_total']=int(cs.get('published_total',0))+1
|
||||
ch=next(x for x in cfg['channels'] if x['channel_name']==p['channel_name'])
|
||||
cs['next_publish_at']=next_channel_time(cfg,ch); st['pending']=None; atomic_json(cfg['state_file'],st)
|
||||
elif bn!=p['previous_block_number'] or bhash.lower()!=p['previous_block_hash'].lower():
|
||||
raise RuntimeError('global chain changed while a pending block exists; refusing to fork')
|
||||
elif bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower():
|
||||
raise RuntimeError('server global chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_channel(cfg,st,ch,force=False):
|
||||
cs=st['channels'][ch['channel_name']]; items=queue_items(ch)
|
||||
idx=int(cs['next_index'])
|
||||
if idx>=len(items):
|
||||
if ch.get('loop_queue',cfg.get('loop_queue',False)): idx=0
|
||||
else:
|
||||
cs['next_publish_at']=int(time.time())+3600; atomic_json(cfg['state_file'],st); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1
|
||||
prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(cs['last_line_number'])+1
|
||||
prev_line_block=int(cs.get('last_line_block_number', ch['channel_root_block_number']))
|
||||
body=post_body(int(ch['channel_root_block_number']),prev_line_block,bytes.fromhex(cs['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr)
|
||||
tags=[('App','test5590'),('c',ch['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
p={'channel_name':ch['channel_name'],'queue_index':idx,'next_index_after':idx+1,'block_number':block_num,
|
||||
'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],
|
||||
'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st)
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,
|
||||
'prevBlockHash':st['last_block_hash'],'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']
|
||||
cs['last_line_block_number']=block_num; cs['last_line_number']=this_line; cs['last_line_hash']=p['block_hash']; cs['next_index']=idx+1
|
||||
cs['published_total']=int(cs.get('published_total',0))+1; cs['next_publish_at']=next_channel_time(cfg,ch)
|
||||
st['pending']=None; atomic_json(cfg['state_file'],st)
|
||||
print(f"Published {ch['channel_name']}[{idx}] as global block #{block_num}")
|
||||
return True
|
||||
|
||||
async def main_async(args):
|
||||
cfg=load_json(args.config); base=Path(args.config).resolve().parent
|
||||
for k in ('key_file','state_file'):
|
||||
p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p)
|
||||
if not isinstance(cfg.get('channels'),list) or not cfg['channels']: raise ValueError('channels[] required')
|
||||
for ch in cfg['channels']:
|
||||
p=Path(ch['queue_file']); ch['queue_file']=str(p if p.is_absolute() else base/p)
|
||||
for k in ('channel_name','channel_root_block_number','queue_file'):
|
||||
if k not in ch: raise ValueError('channel missing '+k)
|
||||
st=load_json(cfg['state_file'])
|
||||
if st is None: st=await init_multi_state(cfg)
|
||||
st=await reconcile_multi(cfg,st)
|
||||
if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return
|
||||
if args.command=='publish-now':
|
||||
name=args.channel or cfg['channels'][0]['channel_name']
|
||||
ch=next((x for x in cfg['channels'] if x['channel_name']==name),None)
|
||||
if not ch: raise ValueError('unknown channel '+name)
|
||||
await publish_channel(cfg,st,ch,True); return
|
||||
while True:
|
||||
st=await reconcile_multi(cfg,load_json(cfg['state_file']))
|
||||
now=int(time.time())
|
||||
due=sorted(cfg['channels'],key=lambda c:st['channels'][c['channel_name']]['next_publish_at'])
|
||||
ch=due[0]; wait=max(0,int(st['channels'][ch['channel_name']]['next_publish_at'])-now)
|
||||
if wait: await asyncio.sleep(wait)
|
||||
await publish_channel(cfg,st,ch)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='SHiNE multi-channel test publisher')
|
||||
ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run')
|
||||
ap.add_argument('--config',default='config.json'); ap.add_argument('--channel',default='')
|
||||
a=ap.parse_args()
|
||||
try: asyncio.run(main_async(a))
|
||||
except KeyboardInterrupt: pass
|
||||
except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1)
|
||||
if __name__=='__main__': main()
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,345 @@
|
||||
[
|
||||
{
|
||||
"id": "001",
|
||||
"text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "002",
|
||||
"text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.",
|
||||
"images": [
|
||||
"images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "003",
|
||||
"text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "004",
|
||||
"text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "005",
|
||||
"text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.",
|
||||
"images": [
|
||||
"images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "006",
|
||||
"text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.",
|
||||
"images": [
|
||||
"images/joke_06.webp",
|
||||
"images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "007",
|
||||
"text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "008",
|
||||
"text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.",
|
||||
"images": [
|
||||
"images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "009",
|
||||
"text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "010",
|
||||
"text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "011",
|
||||
"text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.",
|
||||
"images": [
|
||||
"images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "012",
|
||||
"text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "013",
|
||||
"text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "014",
|
||||
"text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.",
|
||||
"images": [
|
||||
"images/joke_04.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "015",
|
||||
"text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "016",
|
||||
"text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "017",
|
||||
"text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.",
|
||||
"images": [
|
||||
"images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "018",
|
||||
"text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.",
|
||||
"images": [
|
||||
"images/joke_08.webp",
|
||||
"images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019",
|
||||
"text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "020",
|
||||
"text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.",
|
||||
"images": [
|
||||
"images/joke_10.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "021",
|
||||
"text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "022",
|
||||
"text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "023",
|
||||
"text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.",
|
||||
"images": [
|
||||
"images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "024",
|
||||
"text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "025",
|
||||
"text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "026",
|
||||
"text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.",
|
||||
"images": [
|
||||
"images/joke_06.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "027",
|
||||
"text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "028",
|
||||
"text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "029",
|
||||
"text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.",
|
||||
"images": [
|
||||
"images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "030",
|
||||
"text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.",
|
||||
"images": [
|
||||
"images/joke_10.webp",
|
||||
"images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "031",
|
||||
"text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "032",
|
||||
"text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.",
|
||||
"images": [
|
||||
"images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "033",
|
||||
"text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "034",
|
||||
"text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "035",
|
||||
"text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.",
|
||||
"images": [
|
||||
"images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "036",
|
||||
"text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "037",
|
||||
"text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "038",
|
||||
"text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.",
|
||||
"images": [
|
||||
"images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "039",
|
||||
"text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "040",
|
||||
"text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "041",
|
||||
"text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.",
|
||||
"images": [
|
||||
"images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "042",
|
||||
"text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.",
|
||||
"images": [
|
||||
"images/joke_02.webp",
|
||||
"images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "043",
|
||||
"text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "044",
|
||||
"text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "045",
|
||||
"text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "046",
|
||||
"text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "047",
|
||||
"text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "048",
|
||||
"text": "— Ты выключил утюг? — Теперь это главная интрига поездки.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "049",
|
||||
"text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "050",
|
||||
"text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "051",
|
||||
"text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "052",
|
||||
"text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "053",
|
||||
"text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "054",
|
||||
"text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.",
|
||||
"images": [
|
||||
"images/joke_04.webp",
|
||||
"images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "055",
|
||||
"text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "056",
|
||||
"text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "057",
|
||||
"text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "058",
|
||||
"text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "059",
|
||||
"text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "060",
|
||||
"text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
cryptography>=43,<47
|
||||
websockets>=14,<17
|
||||
requests>=2.32,<3
|
||||
arweave-python-client>=1.0.18
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=SHiNE multi-channel test publisher
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=shine
|
||||
WorkingDirectory=/opt/shine-test-publisher/v2
|
||||
ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher_multi.py run --config config.multi.json
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=SHiNE Test Publisher v2
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/shine-test-publisher/v2
|
||||
ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json
|
||||
Restart=always
|
||||
RestartSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
CONFIG_FILE="${1:-prepare.multi.json}"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PYTHON_BIN="$VENV_DIR/bin/python"
|
||||
PIP_BIN="$VENV_DIR/bin/pip"
|
||||
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
echo "ERROR: config not found: $CONFIG_FILE" >&2
|
||||
echo "Copy prepare.multi.example.json to prepare.multi.json first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f keys/arweave-wallet.json ]]; then
|
||||
echo "ERROR: Arweave wallet not found: keys/arweave-wallet.json" >&2
|
||||
echo "Put the private JWK there. The file is ignored by git." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||
python3 -m venv "$VENV_DIR"
|
||||
"$PIP_BIN" install -r requirements.txt
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" prepare/prepare_all_channels.py --config "$CONFIG_FILE"
|
||||