Добавить тестовые каналы и Arweave-синхронизацию

This commit is contained in:
AidarKC
2026-09-23 11:14:02 +03:00
parent 8b12760dde
commit ef707ec217
80 changed files with 15452 additions and 26 deletions
+41
View File
@@ -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.
+21
View File
@@ -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`.
+90
View File
@@ -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
}
+4
View File
@@ -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()
+182
View File
@@ -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": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло."
}
]
+2
View File
@@ -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
+12
View File
@@ -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: сначала выяснить расхождение.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

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"
}
}
+4
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

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": []
}
]
+4
View File
@@ -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
+29
View File
@@ -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"