Добавить тестовые каналы и Arweave-синхронизацию
@@ -0,0 +1,41 @@
|
||||
# AGENTS.md — SHiNE test channels
|
||||
|
||||
Эта папка — тестовый генератор реальной пользовательской активности SHiNE. Это не часть консенсуса и не отдельный протокол.
|
||||
|
||||
## Назначение
|
||||
Один тестовый пользователь публикует заранее подготовленные POST-блоки сразу в несколько своих каналов. Все POST должны идти через обычный SHiNE `AddBlock`, поэтому сервер проверяет их как обычные пользовательские блоки и затем использует штатную Arweave-публикацию.
|
||||
|
||||
## Почему publisher один
|
||||
У всех каналов одного пользователя одна глобальная blockchain-цепочка (`blockNumber + prevHash`). Нельзя запускать независимый publisher на каждый канал: они могут одновременно построить следующий глобальный блок. `publisher_multi.py` сериализует публикации всех каналов, но хранит отдельные `prevLineNumber/prevLineHash` и позиции очередей.
|
||||
|
||||
## Каналы в примере
|
||||
- `jokes` — короткие анекдоты; часть постов содержит 1–2 изображения.
|
||||
- `mindfulness` — короткие практики осознанности.
|
||||
- `positive` — позитивные и мотивирующие напоминания.
|
||||
- `friends` — советы об общении и дружбе, в духе классических принципов человеческого общения, без выдачи текста за дословные цитаты.
|
||||
- `wisdom` — короткие духовные/созерцательные мысли. Не придумывать цитаты и не приписывать человеку слова без источника.
|
||||
|
||||
## Изображения
|
||||
`prepare/prepare_all_channels.py` проходит все `channels[].queue_file`.
|
||||
Для каждого локального файла:
|
||||
1. вычисляет SHA-256;
|
||||
2. смотрит общий `upload-state.json`;
|
||||
3. если ID уже известен — проверяет `https://gateway/<id>`;
|
||||
4. если файл существует — повторно НЕ загружает;
|
||||
5. если отсутствует/ID неизвестен — загружает через Arweave JWK;
|
||||
6. пишет `.prepared.json`, добавляя постоянные Arweave URL в текст POST и сохраняя `images[]` как служебные метаданные.
|
||||
|
||||
JWK используется только подготовщиком. Не помещать приватный JWK или blockchain key в git/архив.
|
||||
|
||||
## State
|
||||
`multi-state.json` содержит один global head и отдельное состояние каждого канала. Pending DataItem записывается до `AddBlock`; при неопределённом состоянии глобальной цепочки publisher обязан остановиться, а не строить форк.
|
||||
|
||||
## Не менять
|
||||
- SHiNE Frame v1 и его SHA-256.
|
||||
- `prevHash` глобальной цепочки.
|
||||
- `prevLineNumber/prevLineHash` канальной линии.
|
||||
- ANS-104 signing/deepHash без сверки с серверной реализацией.
|
||||
- `App=test5590` и `c=<canonical channel>` без согласованного изменения импортера/Viewer.
|
||||
|
||||
## Viewer
|
||||
Viewer распознаёт Arweave URL в тексте POST и показывает изображения inline. Экран «Проверка данных» независимо считает SHA-256 Frame, проверяет доступные `prevHash` и `prevLineHash`, показывает отсутствие соседнего блока как «нет данных», а доказанное несовпадение — как ошибку. Подпись ANS-104 нельзя помечать локально проверенной, если gateway не предоставил сырую подпись DataItem.
|
||||
@@ -0,0 +1,21 @@
|
||||
# SHiNE multi-channel test publisher v3
|
||||
|
||||
В комплекте сохранены v1 и v2. Основной режим v3 — файлы `v2/publisher/publisher_multi.py` и `v2/prepare/prepare_all_channels.py`.
|
||||
|
||||
## Быстрый порядок
|
||||
1. Создать тестового пользователя и пять каналов на сервере.
|
||||
2. Заполнить реальные `channel_root_block_number` в `config.multi.json`.
|
||||
3. Положить blockchain key в `keys/blockchain.json`.
|
||||
4. Только для подготовки картинок положить JWK в `keys/arweave-wallet.json`.
|
||||
5. Скопировать `prepare.multi.example.json` → `prepare.multi.json`, затем:
|
||||
`python prepare/prepare_all_channels.py --config prepare.multi.json`
|
||||
6. Проверить созданные `channels/*.prepared.json`.
|
||||
7. Скопировать `config.multi.example.json` → `config.multi.json`.
|
||||
8. Проверка: `python publisher/publisher_multi.py status --config config.multi.json`
|
||||
9. Один тестовый пост: `python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json`
|
||||
10. Постоянный режим: `python publisher/publisher_multi.py run --config config.multi.json`
|
||||
|
||||
Повторный запуск подготовки безопасен: известные картинки сначала проверяются на gateway. Существующие не загружаются повторно.
|
||||
|
||||
## v3: media + расписание + VPS
|
||||
У всех пяти каналов теперь есть media-примеры: в обучающих каналах по 18 иллюстрированных постов (12×1, 4×2, 2×3 картинки). По умолчанию обучающие каналы публикуют раз в 3–7 активных часов, `jokes` — раз в 1–3 активных часа. Quiet hours 22:00–08:00 не расходуют интервал. Инструкция автономной установки: `v2/CODEX_VPS_SETUP.md`.
|
||||
@@ -0,0 +1,90 @@
|
||||
# SHiNE Test Publisher
|
||||
|
||||
Автономный publisher заранее подготовленных постов в существующий канал SHiNE.
|
||||
|
||||
## Что делает
|
||||
|
||||
- публикует следующий элемент `queue/jokes.json` через случайный интервал;
|
||||
- создаёт настоящий SHiNE Frame v1 + подписанный Ed25519 ANS-104 DataItem;
|
||||
- отправляет его обычной операцией `AddBlock` на SHiNE-server;
|
||||
- хранит прогресс в `state.json` и продолжает после рестарта;
|
||||
- перед отправкой сохраняет `pending`, поэтому падение между отправкой и записью state не должно съедать или дублировать элемент очереди;
|
||||
- при расхождении head цепочки с state останавливается вместо создания форка;
|
||||
- очередь по умолчанию НЕ зациклена.
|
||||
|
||||
В комплекте 60 заранее подготовленных нейтральных коротких анекдотов/шуток.
|
||||
|
||||
## Требования
|
||||
|
||||
Python 3.11+.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Настройка
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
Заполнить:
|
||||
|
||||
- `server_ws` — WebSocket SHiNE-server;
|
||||
- `blockchain_name` — активная цепочка тестового пользователя;
|
||||
- `channel_name` — имя существующего канала (его canonical slug = lowercase);
|
||||
- `channel_root_block_number` — номер CREATE_CHANNEL этого канала;
|
||||
- `key_file` — Solana JSON blockchain key пользователя;
|
||||
- интервалы публикации.
|
||||
|
||||
Ключ должен соответствовать owner активной blockchain. Реальный ключ не класть в git, права рекомендуется `chmod 600`.
|
||||
|
||||
При первом запуске `state.json` отсутствует: publisher читает head цепочки и последний POST канала с сервера и создаёт state автоматически.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
.venv/bin/python publisher/publisher.py status --config config.json
|
||||
```
|
||||
|
||||
`status` при первом запуске также инициализирует state.
|
||||
|
||||
Принудительно опубликовать следующий пост:
|
||||
|
||||
```bash
|
||||
.venv/bin/python publisher/publisher.py publish-now --config config.json
|
||||
```
|
||||
|
||||
Обычный режим:
|
||||
|
||||
```bash
|
||||
.venv/bin/python publisher/publisher.py run --config config.json
|
||||
```
|
||||
|
||||
## systemd
|
||||
|
||||
Скопировать каталог в `/opt/shine-test-publisher`, создать venv и настроить `config.json`, затем:
|
||||
|
||||
```bash
|
||||
sudo cp systemd/shine-test-publisher.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now shine-test-publisher
|
||||
journalctl -u shine-test-publisher -f
|
||||
```
|
||||
|
||||
Service предполагает пользователя `shine`; поменяйте `User=` при необходимости.
|
||||
|
||||
## state.json
|
||||
|
||||
Не редактировать при запущенном publisher. В нём сохраняются:
|
||||
|
||||
- следующий индекс очереди;
|
||||
- последний global block/hash;
|
||||
- последний line number/hash канала;
|
||||
- время следующей публикации;
|
||||
- `pending` для crash recovery;
|
||||
- общее число опубликованных элементов.
|
||||
|
||||
Если очередь закончилась и `loop_queue=false`, новые посты не создаются. Можно дописать новые элементы в JSON; `next_index` продолжит движение дальше.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"server_ws": "wss://t3.shineup.me/ws",
|
||||
"blockchain_name": "testjokes-001",
|
||||
"channel_name": "jokes",
|
||||
"channel_root_block_number": 1,
|
||||
"key_file": "keys/blockchain.json",
|
||||
"queue_file": "queue/jokes.json",
|
||||
"state_file": "state.json",
|
||||
"min_interval_seconds": 1800,
|
||||
"max_interval_seconds": 10800,
|
||||
"loop_queue": false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
Put the Solana-format blockchain key JSON here, for example blockchain.json.
|
||||
The file is an array of 64 bytes [private seed + public key].
|
||||
Recommended permissions: chmod 600 keys/blockchain.json
|
||||
Never commit the real key.
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
ZERO32 = bytes(32)
|
||||
FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1
|
||||
|
||||
def atomic_json(path, obj):
|
||||
path=Path(path); path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp=path.with_suffix(path.suffix+'.tmp')
|
||||
tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
os.replace(tmp,path)
|
||||
|
||||
def load_json(path, default=None):
|
||||
p=Path(path)
|
||||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default
|
||||
|
||||
def avro_long(n):
|
||||
n=(n<<1) ^ (n>>63); out=bytearray()
|
||||
while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7
|
||||
out.append(n); return bytes(out)
|
||||
|
||||
def avro_tags(tags):
|
||||
out=bytearray()
|
||||
if tags:
|
||||
out += avro_long(len(tags))
|
||||
for k,v in tags:
|
||||
kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb
|
||||
out += avro_long(0); return bytes(out)
|
||||
|
||||
def deep_hash(x):
|
||||
H=lambda b: hashlib.sha384(b).digest()
|
||||
if isinstance(x,(bytes,bytearray)):
|
||||
b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b))
|
||||
acc=H(f'list{len(x)}'.encode())
|
||||
for child in x: acc=H(acc+deep_hash(child))
|
||||
return acc
|
||||
|
||||
def signing_message(owner,tags,data):
|
||||
raw=avro_tags(tags)
|
||||
return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data])
|
||||
|
||||
def data_item(priv64,tags,data):
|
||||
if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes')
|
||||
seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
|
||||
sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data))
|
||||
rawtags=avro_tags(tags)
|
||||
return struct.pack('<H',2)+sig+owner+b'\0\0'+struct.pack('<QQ',len(tags),len(rawtags))+rawtags+data
|
||||
|
||||
def post_body(line_code, prev_line_num, prev_line_hash, this_line_num, text):
|
||||
tb=text.encode('utf-8')
|
||||
if len(tb)>65535: raise ValueError('post text too long')
|
||||
return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb
|
||||
|
||||
def frame(prev_hash, block_num, body, ts=None):
|
||||
ts=int(time.time()) if ts is None else int(ts); size=56+len(body)
|
||||
return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body
|
||||
|
||||
def h32(b): return hashlib.sha256(b).digest()
|
||||
def hx(b): return b.hex()
|
||||
|
||||
async def ws_call(url, op, payload, timeout=20):
|
||||
req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload}
|
||||
async with connect(url, open_timeout=timeout, close_timeout=5) as ws:
|
||||
await ws.send(json.dumps(req,separators=(',',':')))
|
||||
end=time.monotonic()+timeout
|
||||
while True:
|
||||
left=end-time.monotonic()
|
||||
if left<=0: raise TimeoutError(op)
|
||||
msg=await asyncio.wait_for(ws.recv(),left)
|
||||
obj=json.loads(msg)
|
||||
if obj.get('requestId')==req['requestId']: return obj
|
||||
|
||||
async def head(cfg):
|
||||
r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{})
|
||||
if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}')
|
||||
for x in r.get('payload',{}).get('blockchains',[]):
|
||||
if x.get('blockchainName')==cfg['blockchain_name']:
|
||||
return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','')
|
||||
raise RuntimeError('blockchain not found on server')
|
||||
|
||||
async def block(cfg,n):
|
||||
r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}')
|
||||
p=r.get('payload',r)
|
||||
return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64')
|
||||
|
||||
async def channel_tail(cfg, root_hash):
|
||||
r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}')
|
||||
items=r.get('payload',{}).get('messages',[])
|
||||
if not items: return 0, ZERO32
|
||||
m=items[0]; ref=m.get('messageRef') or {}
|
||||
return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32))
|
||||
|
||||
def read_key(path):
|
||||
a=load_json(path)
|
||||
if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array')
|
||||
return bytes(int(x)&255 for x in a)
|
||||
|
||||
def queue_items(cfg):
|
||||
q=load_json(cfg['queue_file'])
|
||||
if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array')
|
||||
for x in q:
|
||||
if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text')
|
||||
return q
|
||||
|
||||
def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds']))
|
||||
|
||||
async def init_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
root_hash,_=await block(cfg,int(cfg['channel_root_block_number']))
|
||||
line_num,line_hash=await channel_tail(cfg,root_hash)
|
||||
st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
async def reconcile(cfg,st):
|
||||
p=st.get('pending')
|
||||
bn,bhash=await head(cfg)
|
||||
if p:
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn)
|
||||
elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower():
|
||||
print('Pending block was not accepted; it will be retried')
|
||||
else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}')
|
||||
else:
|
||||
if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_one(cfg,st,items):
|
||||
if st.get('pending'):
|
||||
p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash']
|
||||
else:
|
||||
idx=int(st['next_index'])
|
||||
if idx>=len(items):
|
||||
if cfg.get('loop_queue',False): idx=0
|
||||
else: print('Queue exhausted. Nothing to publish.'); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1
|
||||
body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
nextidx=idx+1
|
||||
p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash']
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash')
|
||||
if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st)
|
||||
print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}")
|
||||
return True
|
||||
|
||||
async def main_async(args):
|
||||
cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds']
|
||||
for k in required:
|
||||
if k not in cfg: raise ValueError('missing config: '+k)
|
||||
base=Path(args.config).resolve().parent
|
||||
for k in ('key_file','queue_file','state_file'):
|
||||
p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p)
|
||||
items=queue_items(cfg)
|
||||
st=load_json(cfg['state_file'])
|
||||
if st is None: st=await init_state(cfg); print('Initialized state from server/channel')
|
||||
st=await reconcile(cfg,st)
|
||||
if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return
|
||||
if args.command=='publish-now': await publish_one(cfg,st,items); return
|
||||
while True:
|
||||
st=await reconcile(cfg,load_json(cfg['state_file']))
|
||||
wait=max(0,int(st.get('next_publish_at',0))-int(time.time()))
|
||||
if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait)
|
||||
ok=await publish_one(cfg,st,items)
|
||||
if not ok: await asyncio.sleep(3600)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='SHiNE test channel publisher')
|
||||
ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json')
|
||||
a=ap.parse_args()
|
||||
try: asyncio.run(main_async(a))
|
||||
except KeyboardInterrupt: pass
|
||||
except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1)
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,182 @@
|
||||
[
|
||||
{
|
||||
"text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java."
|
||||
},
|
||||
{
|
||||
"text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой."
|
||||
},
|
||||
{
|
||||
"text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра."
|
||||
},
|
||||
{
|
||||
"text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы."
|
||||
},
|
||||
{
|
||||
"text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья."
|
||||
},
|
||||
{
|
||||
"text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен."
|
||||
},
|
||||
{
|
||||
"text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается."
|
||||
},
|
||||
{
|
||||
"text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать."
|
||||
},
|
||||
{
|
||||
"text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный»."
|
||||
},
|
||||
{
|
||||
"text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть."
|
||||
},
|
||||
{
|
||||
"text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать."
|
||||
},
|
||||
{
|
||||
"text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя."
|
||||
},
|
||||
{
|
||||
"text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее."
|
||||
},
|
||||
{
|
||||
"text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой."
|
||||
},
|
||||
{
|
||||
"text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно."
|
||||
},
|
||||
{
|
||||
"text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд."
|
||||
},
|
||||
{
|
||||
"text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно."
|
||||
},
|
||||
{
|
||||
"text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать."
|
||||
},
|
||||
{
|
||||
"text": "— Что делаешь? — Ничего. — А вчера? — Не закончил."
|
||||
},
|
||||
{
|
||||
"text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке."
|
||||
},
|
||||
{
|
||||
"text": "План на день был прекрасен. Жаль, что день о нём ничего не знал."
|
||||
},
|
||||
{
|
||||
"text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее."
|
||||
},
|
||||
{
|
||||
"text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты."
|
||||
},
|
||||
{
|
||||
"text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник."
|
||||
},
|
||||
{
|
||||
"text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше."
|
||||
},
|
||||
{
|
||||
"text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди."
|
||||
},
|
||||
{
|
||||
"text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу."
|
||||
},
|
||||
{
|
||||
"text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления."
|
||||
},
|
||||
{
|
||||
"text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука."
|
||||
},
|
||||
{
|
||||
"text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры."
|
||||
},
|
||||
{
|
||||
"text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось."
|
||||
},
|
||||
{
|
||||
"text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта."
|
||||
},
|
||||
{
|
||||
"text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь."
|
||||
},
|
||||
{
|
||||
"text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес."
|
||||
},
|
||||
{
|
||||
"text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду."
|
||||
},
|
||||
{
|
||||
"text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа."
|
||||
},
|
||||
{
|
||||
"text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил."
|
||||
},
|
||||
{
|
||||
"text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%."
|
||||
},
|
||||
{
|
||||
"text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник."
|
||||
},
|
||||
{
|
||||
"text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном."
|
||||
},
|
||||
{
|
||||
"text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут."
|
||||
},
|
||||
{
|
||||
"text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя."
|
||||
},
|
||||
{
|
||||
"text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся."
|
||||
},
|
||||
{
|
||||
"text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием."
|
||||
},
|
||||
{
|
||||
"text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать."
|
||||
},
|
||||
{
|
||||
"text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается."
|
||||
},
|
||||
{
|
||||
"text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы."
|
||||
},
|
||||
{
|
||||
"text": "— Ты выключил утюг? — Теперь это главная интрига поездки."
|
||||
},
|
||||
{
|
||||
"text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает."
|
||||
},
|
||||
{
|
||||
"text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления."
|
||||
},
|
||||
{
|
||||
"text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю."
|
||||
},
|
||||
{
|
||||
"text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены."
|
||||
},
|
||||
{
|
||||
"text": "Микроволновка пищит так, будто лично приготовила ужин с нуля."
|
||||
},
|
||||
{
|
||||
"text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях."
|
||||
},
|
||||
{
|
||||
"text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать»."
|
||||
},
|
||||
{
|
||||
"text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона."
|
||||
},
|
||||
{
|
||||
"text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду."
|
||||
},
|
||||
{
|
||||
"text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым."
|
||||
},
|
||||
{
|
||||
"text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча."
|
||||
},
|
||||
{
|
||||
"text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
cryptography>=42
|
||||
websockets>=14
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=SHiNE test channel publisher
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=shine
|
||||
WorkingDirectory=/opt/shine-test-publisher
|
||||
ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
keys/*.json
|
||||
!keys/README.txt
|
||||
state.json
|
||||
multi-state.json
|
||||
upload-state.json
|
||||
channels/*.prepared.json
|
||||
config.json
|
||||
config.multi.json
|
||||
prepare.config.json
|
||||
prepare.multi.json
|
||||
__pycache__/
|
||||
.venv/
|
||||
@@ -0,0 +1,87 @@
|
||||
# CODEX_VPS_SETUP.md — развернуть автономный SHiNE test publisher
|
||||
|
||||
Цель: установить test publisher на Ubuntu VPS так, чтобы он постоянно работал через systemd, переживал reboot/сбой сети, публиковал в несколько каналов одного тестового пользователя и не требовал ручного запуска.
|
||||
|
||||
## Что агент должен получить от оператора
|
||||
Никогда не печатай приватные ключи в лог.
|
||||
1. Реальный `blockchain_name` тестового пользователя.
|
||||
2. Solana-format JSON blockchain key → `keys/blockchain.json`.
|
||||
3. Arweave JWK → `keys/arweave-wallet.json` (нужен только для prepare).
|
||||
4. Для каждого канала реальный `channel_root_block_number` CREATE_CHANNEL.
|
||||
5. Подтверждение WebSocket URL сервера (example: `wss://t3.shineup.me/ws`).
|
||||
|
||||
## Установка
|
||||
Предполагаемый каталог: `/opt/shine-test-publisher/v2`, Linux user: `shine`.
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /opt/shine-test-publisher --shell /usr/sbin/nologin shine 2>/dev/null || true
|
||||
sudo mkdir -p /opt/shine-test-publisher
|
||||
sudo chown -R shine:shine /opt/shine-test-publisher
|
||||
# скопировать содержимое tools/test-publisher/v2 в /opt/shine-test-publisher/v2
|
||||
|
||||
cd /opt/shine-test-publisher
|
||||
sudo -u shine python3 -m venv .venv
|
||||
sudo -u shine .venv/bin/pip install -r v2/requirements.txt
|
||||
sudo chmod 700 v2/keys
|
||||
sudo chmod 600 v2/keys/*.json
|
||||
```
|
||||
|
||||
`requirements.txt` должен содержать зависимости publisher и prepare (`cryptography`, `websockets`, `requests`, `arweave-python-client`).
|
||||
|
||||
## Конфиги
|
||||
Скопировать:
|
||||
- `config.multi.example.json` → `config.multi.json`
|
||||
- `prepare.multi.example.json` → `prepare.multi.json`
|
||||
|
||||
Заменить placeholders. Не угадывать CREATE_CHANNEL block numbers.
|
||||
|
||||
Расписание по умолчанию:
|
||||
- jokes: случайно 1–3 активных часа;
|
||||
- остальные: случайно 3–7 активных часов;
|
||||
- timezone: Europe/Warsaw;
|
||||
- quiet: 22:00–08:00.
|
||||
|
||||
Quiet hours НЕ расходуют интервал. Если из 5 часов до ночи прошло 2 часа, после 08:00 должно остаться 3 часа.
|
||||
|
||||
## Подготовка media
|
||||
Один раз и затем после добавления новых картинок:
|
||||
```bash
|
||||
cd /opt/shine-test-publisher/v2
|
||||
../.venv/bin/python prepare/prepare_all_channels.py --config prepare.multi.json
|
||||
```
|
||||
Скрипт обязан пройти ВСЕ channels. Известный Arweave ID сначала проверяется через gateway. Существующий файл повторно не загружается. Отсутствующий — загружается и state обновляется атомарно.
|
||||
|
||||
После успешной подготовки JWK не нужен постоянному publisher. Для минимизации риска можно перенести JWK в защищённое offline-хранилище; тогда повторный prepare потребует вернуть его.
|
||||
|
||||
## Проверка до systemd
|
||||
```bash
|
||||
../.venv/bin/python publisher/publisher_multi.py status --config config.multi.json
|
||||
../.venv/bin/python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json
|
||||
```
|
||||
Проверить появление блока на SHiNE server и затем в Viewer. Не запускать два экземпляра publisher для одного blockchain key.
|
||||
|
||||
## systemd
|
||||
```bash
|
||||
sudo cp systemd/shine-test-publisher-multi.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now shine-test-publisher-multi
|
||||
sudo systemctl status shine-test-publisher-multi
|
||||
journalctl -u shine-test-publisher-multi -f
|
||||
```
|
||||
|
||||
Unit использует `Restart=on-failure`; publisher хранит pending/state на диске и после рестарта сверяет global head перед продолжением. При конфликте global head он обязан остановиться, а не создавать форк.
|
||||
|
||||
## После настройки
|
||||
Проверить:
|
||||
- `systemctl is-enabled` = enabled;
|
||||
- `systemctl is-active` = active;
|
||||
- `multi-state.json` создаётся и меняется;
|
||||
- в quiet hours нет публикаций;
|
||||
- reboot VPS → service возвращается в active;
|
||||
- приватные JSON имеют mode 600;
|
||||
- prepared queues существуют для всех пяти каналов.
|
||||
|
||||
## Запреты
|
||||
Не коммитить/архивировать реальные keys, JWK, `multi-state.json`, `upload-state.json`.
|
||||
Не запускать параллельно v1/v2 single publisher и multi publisher с одним ключом.
|
||||
Не сбрасывать state ради устранения ошибки chain-head: сначала выяснить расхождение.
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "friend-001",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-002",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-003",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-004",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-005",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-006",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-007",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное.",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-008",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-009",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?»",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-010",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-011",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-012",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-013",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-014",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-015",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-016",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-017",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-018",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-019",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-020",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-021",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-022",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-023",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-024",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-025",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp",
|
||||
"../assets/channel-art/friends-hello.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-026",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-027",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp",
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-028",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-029",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-listen.webp",
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-030",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-031",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-talk.webp",
|
||||
"../assets/channel-art/friends-coffee.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-032",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-033",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-coffee.webp",
|
||||
"../assets/channel-art/friends-hello.webp",
|
||||
"../assets/channel-art/friends-listen.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-034",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-035",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/friends-hello.webp",
|
||||
"../assets/channel-art/friends-listen.webp",
|
||||
"../assets/channel-art/friends-talk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friend-036",
|
||||
"text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-037",
|
||||
"text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-038",
|
||||
"text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-039",
|
||||
"text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-040",
|
||||
"text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-041",
|
||||
"text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-042",
|
||||
"text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-043",
|
||||
"text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-044",
|
||||
"text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "friend-045",
|
||||
"text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,345 @@
|
||||
[
|
||||
{
|
||||
"id": "001",
|
||||
"text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "002",
|
||||
"text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.",
|
||||
"images": [
|
||||
"../queue/images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "003",
|
||||
"text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "004",
|
||||
"text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "005",
|
||||
"text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.",
|
||||
"images": [
|
||||
"../queue/images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "006",
|
||||
"text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.",
|
||||
"images": [
|
||||
"../queue/images/joke_06.webp",
|
||||
"../queue/images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "007",
|
||||
"text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "008",
|
||||
"text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.",
|
||||
"images": [
|
||||
"../queue/images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "009",
|
||||
"text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "010",
|
||||
"text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "011",
|
||||
"text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.",
|
||||
"images": [
|
||||
"../queue/images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "012",
|
||||
"text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "013",
|
||||
"text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "014",
|
||||
"text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.",
|
||||
"images": [
|
||||
"../queue/images/joke_04.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "015",
|
||||
"text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "016",
|
||||
"text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "017",
|
||||
"text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.",
|
||||
"images": [
|
||||
"../queue/images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "018",
|
||||
"text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.",
|
||||
"images": [
|
||||
"../queue/images/joke_08.webp",
|
||||
"../queue/images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019",
|
||||
"text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "020",
|
||||
"text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.",
|
||||
"images": [
|
||||
"../queue/images/joke_10.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "021",
|
||||
"text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "022",
|
||||
"text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "023",
|
||||
"text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.",
|
||||
"images": [
|
||||
"../queue/images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "024",
|
||||
"text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "025",
|
||||
"text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "026",
|
||||
"text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.",
|
||||
"images": [
|
||||
"../queue/images/joke_06.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "027",
|
||||
"text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "028",
|
||||
"text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "029",
|
||||
"text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.",
|
||||
"images": [
|
||||
"../queue/images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "030",
|
||||
"text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.",
|
||||
"images": [
|
||||
"../queue/images/joke_10.webp",
|
||||
"../queue/images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "031",
|
||||
"text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "032",
|
||||
"text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.",
|
||||
"images": [
|
||||
"../queue/images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "033",
|
||||
"text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "034",
|
||||
"text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "035",
|
||||
"text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.",
|
||||
"images": [
|
||||
"../queue/images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "036",
|
||||
"text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "037",
|
||||
"text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "038",
|
||||
"text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.",
|
||||
"images": [
|
||||
"../queue/images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "039",
|
||||
"text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "040",
|
||||
"text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "041",
|
||||
"text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.",
|
||||
"images": [
|
||||
"../queue/images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "042",
|
||||
"text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.",
|
||||
"images": [
|
||||
"../queue/images/joke_02.webp",
|
||||
"../queue/images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "043",
|
||||
"text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "044",
|
||||
"text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "045",
|
||||
"text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "046",
|
||||
"text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "047",
|
||||
"text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "048",
|
||||
"text": "— Ты выключил утюг? — Теперь это главная интрига поездки.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "049",
|
||||
"text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "050",
|
||||
"text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "051",
|
||||
"text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "052",
|
||||
"text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "053",
|
||||
"text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "054",
|
||||
"text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.",
|
||||
"images": [
|
||||
"../queue/images/joke_04.webp",
|
||||
"../queue/images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "055",
|
||||
"text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "056",
|
||||
"text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "057",
|
||||
"text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "058",
|
||||
"text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "059",
|
||||
"text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "060",
|
||||
"text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "mind-001",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-002",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-003",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-004",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-005",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-006",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-007",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-008",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-009",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел.",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-010",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-011",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-012",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-013",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-014",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-015",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-016",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-017",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-018",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-019",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-020",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-021",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-022",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-023",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-024",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-025",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp",
|
||||
"../assets/channel-art/mindfulness-tea.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-026",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-027",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp",
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-028",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-029",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-walk.webp",
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-030",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-031",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-window.webp",
|
||||
"../assets/channel-art/mindfulness-breath.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-032",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-033",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-breath.webp",
|
||||
"../assets/channel-art/mindfulness-tea.webp",
|
||||
"../assets/channel-art/mindfulness-walk.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-034",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-035",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/mindfulness-tea.webp",
|
||||
"../assets/channel-art/mindfulness-walk.webp",
|
||||
"../assets/channel-art/mindfulness-window.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mind-036",
|
||||
"text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-037",
|
||||
"text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-038",
|
||||
"text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-039",
|
||||
"text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-040",
|
||||
"text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-041",
|
||||
"text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-042",
|
||||
"text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-043",
|
||||
"text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-044",
|
||||
"text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "mind-045",
|
||||
"text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "pos-001",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут.",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-002",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-003",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас?",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-004",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-005",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой.",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-006",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-007",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление.",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-008",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-009",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?»",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-010",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-011",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-012",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-013",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-014",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-015",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-016",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-017",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-018",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-019",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-020",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-021",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-022",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-023",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-024",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-025",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp",
|
||||
"../assets/channel-art/positive-spark.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-026",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-027",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp",
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-028",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-029",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-step.webp",
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-030",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-031",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-sun.webp",
|
||||
"../assets/channel-art/positive-rest.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-032",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-033",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-rest.webp",
|
||||
"../assets/channel-art/positive-spark.webp",
|
||||
"../assets/channel-art/positive-step.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-034",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-035",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/positive-spark.webp",
|
||||
"../assets/channel-art/positive-step.webp",
|
||||
"../assets/channel-art/positive-sun.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pos-036",
|
||||
"text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-037",
|
||||
"text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-038",
|
||||
"text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-039",
|
||||
"text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-040",
|
||||
"text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-041",
|
||||
"text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-042",
|
||||
"text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-043",
|
||||
"text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-044",
|
||||
"text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "pos-045",
|
||||
"text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,271 @@
|
||||
[
|
||||
{
|
||||
"id": "wise-001",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-002",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-003",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-004",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-005",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-006",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-007",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-008",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее?",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-009",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития.",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-010",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-011",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #11",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-012",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #12",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-013",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #13",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-014",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #14",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-015",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #15",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-016",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #16",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-017",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #17",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-018",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #18",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-019",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #19",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-020",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #20",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-021",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #21",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-022",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #22",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-023",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #23",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-024",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #24",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-025",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #25",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp",
|
||||
"../assets/channel-art/wisdom-moon.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-026",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #26",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-027",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #27",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp",
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-028",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #28",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-029",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #29",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-path.webp",
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-030",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #30",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-031",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #31",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-water.webp",
|
||||
"../assets/channel-art/wisdom-light.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-032",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #32",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-033",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #33",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-light.webp",
|
||||
"../assets/channel-art/wisdom-moon.webp",
|
||||
"../assets/channel-art/wisdom-path.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-034",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #34",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-035",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #35",
|
||||
"images": [
|
||||
"../assets/channel-art/wisdom-moon.webp",
|
||||
"../assets/channel-art/wisdom-path.webp",
|
||||
"../assets/channel-art/wisdom-water.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wise-036",
|
||||
"text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #36",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-037",
|
||||
"text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #37",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-038",
|
||||
"text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #38",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-039",
|
||||
"text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #39",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-040",
|
||||
"text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #40",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-041",
|
||||
"text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #41",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-042",
|
||||
"text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #42",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-043",
|
||||
"text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #43",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-044",
|
||||
"text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #44",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "wise-045",
|
||||
"text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #45",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"server_ws": "ws://127.0.0.1:8080/ws",
|
||||
"blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE",
|
||||
"channel_name": "jokes",
|
||||
"channel_root_block_number": 123,
|
||||
"key_file": "keys/blockchain.json",
|
||||
"queue_file": "queue/jokes.prepared.json",
|
||||
"state_file": "state.json",
|
||||
"min_interval_seconds": 1800,
|
||||
"max_interval_seconds": 10800,
|
||||
"loop_queue": false
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"server_ws": "wss://t3.shineup.me/ws",
|
||||
"blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE",
|
||||
"key_file": "keys/blockchain.json",
|
||||
"state_file": "multi-state.json",
|
||||
"min_interval_seconds": 1800,
|
||||
"max_interval_seconds": 10800,
|
||||
"loop_queue": false,
|
||||
"channels": [
|
||||
{
|
||||
"channel_name": "jokes",
|
||||
"channel_root_block_number": 101,
|
||||
"queue_file": "channels/jokes.prepared.json",
|
||||
"min_interval_seconds": 3600,
|
||||
"max_interval_seconds": 10800
|
||||
},
|
||||
{
|
||||
"channel_name": "mindfulness",
|
||||
"channel_root_block_number": 102,
|
||||
"queue_file": "channels/mindfulness.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
},
|
||||
{
|
||||
"channel_name": "positive",
|
||||
"channel_root_block_number": 103,
|
||||
"queue_file": "channels/positive.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
},
|
||||
{
|
||||
"channel_name": "friends",
|
||||
"channel_root_block_number": 104,
|
||||
"queue_file": "channels/friends.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
},
|
||||
{
|
||||
"channel_name": "wisdom",
|
||||
"channel_root_block_number": 105,
|
||||
"queue_file": "channels/wisdom.prepared.json",
|
||||
"min_interval_seconds": 10800,
|
||||
"max_interval_seconds": 25200
|
||||
}
|
||||
],
|
||||
"timezone": "Europe/Warsaw",
|
||||
"quiet_hours": {
|
||||
"enabled": true,
|
||||
"start": "22:00",
|
||||
"end": "08:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
Put secrets here locally only; never commit them.
|
||||
blockchain.json - SHiNE/Solana 64-byte JSON key array used by publisher
|
||||
arweave-wallet.json - Arweave JWK used ONLY by prepare_queue.py to upload images
|
||||
Recommended: chmod 600 keys/*.json
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"queue_file": "queue/jokes.raw.json",
|
||||
"prepared_queue_file": "queue/jokes.prepared.json",
|
||||
"upload_state_file": "upload-state.json",
|
||||
"arweave": {
|
||||
"wallet_file": "keys/arweave-wallet.json",
|
||||
"gateway": "https://arweave.net"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"upload_state_file": "upload-state.json",
|
||||
"arweave": {
|
||||
"wallet_file": "keys/arweave-wallet.json",
|
||||
"gateway": "https://arweave.net"
|
||||
},
|
||||
"channels": [
|
||||
{
|
||||
"name": "jokes",
|
||||
"queue_file": "channels/jokes.raw.json",
|
||||
"prepared_queue_file": "channels/jokes.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "mindfulness",
|
||||
"queue_file": "channels/mindfulness.raw.json",
|
||||
"prepared_queue_file": "channels/mindfulness.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"queue_file": "channels/positive.raw.json",
|
||||
"prepared_queue_file": "channels/positive.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "friends",
|
||||
"queue_file": "channels/friends.raw.json",
|
||||
"prepared_queue_file": "channels/friends.prepared.json"
|
||||
},
|
||||
{
|
||||
"name": "wisdom",
|
||||
"queue_file": "channels/wisdom.raw.json",
|
||||
"prepared_queue_file": "channels/wisdom.prepared.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse,json,hashlib,mimetypes,time
|
||||
from pathlib import Path
|
||||
import requests
|
||||
try: import arweave
|
||||
except ImportError: arweave=None
|
||||
|
||||
def atomic(path,obj):
|
||||
p=Path(path); p.parent.mkdir(parents=True,exist_ok=True)
|
||||
t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p)
|
||||
|
||||
def exists(gateway,txid,timeout=15):
|
||||
url=f"{gateway.rstrip('/')}/{txid}"
|
||||
try:
|
||||
r=requests.head(url,allow_redirects=True,timeout=timeout)
|
||||
if r.status_code in (200,204): return True
|
||||
if r.status_code in (405,501):
|
||||
r=requests.get(url,headers={'Range':'bytes=0-0'},timeout=timeout)
|
||||
return r.status_code in (200,206)
|
||||
return False
|
||||
except requests.RequestException: return False
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='Prepare images for all SHiNE test channels')
|
||||
ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args()
|
||||
cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent
|
||||
rp=lambda s:(Path(s) if Path(s).is_absolute() else (base/s).resolve())
|
||||
gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/')
|
||||
statep=rp(cfg.get('upload_state_file','upload-state.json'))
|
||||
state=json.loads(statep.read_text()) if statep.exists() else {'version':2,'files':{}}
|
||||
wallet=None
|
||||
def ensure_wallet():
|
||||
nonlocal wallet
|
||||
if wallet is None:
|
||||
if arweave is None: raise SystemExit('pip install -r requirements.txt')
|
||||
wallet=arweave.Wallet(str(rp(cfg['arweave']['wallet_file'])))
|
||||
return wallet
|
||||
|
||||
summary={'channels':0,'posts':0,'images':0,'verified_existing':0,'uploaded':0}
|
||||
for ch in cfg['channels']:
|
||||
rawp=rp(ch['queue_file']); outp=rp(ch['prepared_queue_file'])
|
||||
items=json.loads(rawp.read_text(encoding='utf8')); prepared=[]; summary['channels']+=1
|
||||
for n,item in enumerate(items,1):
|
||||
refs=[]
|
||||
for rel in item.get('images',[]):
|
||||
fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest()
|
||||
summary['images']+=1; rec=state['files'].get(sha); txid=None
|
||||
if rec and exists(gateway,rec['id']):
|
||||
txid=rec['id']; summary['verified_existing']+=1
|
||||
print(f"[{ch['name']}:{n}] verified {fp.name} -> {txid}")
|
||||
else:
|
||||
ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream'
|
||||
tx=arweave.Transaction(ensure_wallet(),data=data)
|
||||
tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha)
|
||||
tx.sign(); tx.send(); txid=tx.id; summary['uploaded']+=1
|
||||
state['files'][sha]={'id':txid,'file':str(fp),'uploaded_at':int(time.time())}; atomic(statep,state)
|
||||
print(f"[{ch['name']}:{n}] uploaded {fp.name} -> {txid}")
|
||||
refs.append({'arweave_id':txid,'url':f'{gateway}/{txid}','sha256':sha})
|
||||
text=item['text'].rstrip()
|
||||
if refs: text += '\n\n' + '\n'.join(x['url'] for x in refs)
|
||||
prepared.append({'id':item.get('id',str(n)),'text':text,'images':refs})
|
||||
summary['posts']+=1
|
||||
atomic(outp,prepared)
|
||||
print(f"Ready {ch['name']}: {outp} ({len(prepared)} posts)")
|
||||
print(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse,json,hashlib,mimetypes,sys,time
|
||||
from pathlib import Path
|
||||
import requests
|
||||
try:
|
||||
import arweave
|
||||
except ImportError:
|
||||
arweave=None
|
||||
|
||||
def atomic(path,obj):
|
||||
p=Path(path); t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='Upload all queue images to Arweave and build prepared queue')
|
||||
ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args()
|
||||
cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent
|
||||
def rp(s):
|
||||
p=Path(s); return p if p.is_absolute() else (base/p).resolve()
|
||||
rawp=rp(cfg['queue_file']); outp=rp(cfg['prepared_queue_file']); walletp=rp(cfg['arweave']['wallet_file']); gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/')
|
||||
statep=rp(cfg.get('upload_state_file','upload-state.json')); state=json.loads(statep.read_text()) if statep.exists() else {'files':{}}
|
||||
if arweave is None: raise SystemExit('Install requirements first: pip install -r requirements.txt')
|
||||
wallet=arweave.Wallet(str(walletp))
|
||||
items=json.loads(rawp.read_text(encoding='utf8')); prepared=[]
|
||||
for n,item in enumerate(items,1):
|
||||
urls=[]; ids=[]
|
||||
for rel in item.get('images',[]):
|
||||
fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest(); old=state['files'].get(sha)
|
||||
if old: txid=old['id']; print(f'[{n}] cached {fp.name} -> {txid}')
|
||||
else:
|
||||
ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream'
|
||||
tx=arweave.Transaction(wallet, data=data); tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha); tx.sign(); tx.send(); txid=tx.id
|
||||
state['files'][sha]={'id':txid,'file':fp.name,'uploaded_at':int(time.time())}; atomic(statep,state); print(f'[{n}] uploaded {fp.name} -> {txid}')
|
||||
ids.append(txid); urls.append(f'{gateway}/{txid}')
|
||||
text=item['text'].rstrip()
|
||||
if urls:
|
||||
# Current SHiNE POST payload is text, so URLs are appended as ordinary lines.
|
||||
text += '\n\n' + '\n'.join(urls)
|
||||
prepared.append({'id':item.get('id',str(n)),'text':text,'images':[{'arweave_id':i,'url':u} for i,u in zip(ids,urls)]})
|
||||
atomic(outp,prepared)
|
||||
print(f'Ready: {outp} ({len(prepared)} posts)')
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
ZERO32 = bytes(32)
|
||||
FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1
|
||||
|
||||
def atomic_json(path, obj):
|
||||
path=Path(path); path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp=path.with_suffix(path.suffix+'.tmp')
|
||||
tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
os.replace(tmp,path)
|
||||
|
||||
def load_json(path, default=None):
|
||||
p=Path(path)
|
||||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default
|
||||
|
||||
def avro_long(n):
|
||||
n=(n<<1) ^ (n>>63); out=bytearray()
|
||||
while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7
|
||||
out.append(n); return bytes(out)
|
||||
|
||||
def avro_tags(tags):
|
||||
out=bytearray()
|
||||
if tags:
|
||||
out += avro_long(len(tags))
|
||||
for k,v in tags:
|
||||
kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb
|
||||
out += avro_long(0); return bytes(out)
|
||||
|
||||
def deep_hash(x):
|
||||
H=lambda b: hashlib.sha384(b).digest()
|
||||
if isinstance(x,(bytes,bytearray)):
|
||||
b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b))
|
||||
acc=H(f'list{len(x)}'.encode())
|
||||
for child in x: acc=H(acc+deep_hash(child))
|
||||
return acc
|
||||
|
||||
def signing_message(owner,tags,data):
|
||||
raw=avro_tags(tags)
|
||||
return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data])
|
||||
|
||||
def data_item(priv64,tags,data):
|
||||
if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes')
|
||||
seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
|
||||
sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data))
|
||||
rawtags=avro_tags(tags)
|
||||
return struct.pack('<H',2)+sig+owner+b'\0\0'+struct.pack('<QQ',len(tags),len(rawtags))+rawtags+data
|
||||
|
||||
def post_body(line_code, prev_line_num, prev_line_hash, this_line_num, text):
|
||||
tb=text.encode('utf-8')
|
||||
if len(tb)>65535: raise ValueError('post text too long')
|
||||
return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb
|
||||
|
||||
def frame(prev_hash, block_num, body, ts=None):
|
||||
ts=int(time.time()) if ts is None else int(ts); size=56+len(body)
|
||||
return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body
|
||||
|
||||
def h32(b): return hashlib.sha256(b).digest()
|
||||
def hx(b): return b.hex()
|
||||
|
||||
async def ws_call(url, op, payload, timeout=20):
|
||||
req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload}
|
||||
async with connect(url, open_timeout=timeout, close_timeout=5) as ws:
|
||||
await ws.send(json.dumps(req,separators=(',',':')))
|
||||
end=time.monotonic()+timeout
|
||||
while True:
|
||||
left=end-time.monotonic()
|
||||
if left<=0: raise TimeoutError(op)
|
||||
msg=await asyncio.wait_for(ws.recv(),left)
|
||||
obj=json.loads(msg)
|
||||
if obj.get('requestId')==req['requestId']: return obj
|
||||
|
||||
async def head(cfg):
|
||||
r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{})
|
||||
if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}')
|
||||
for x in r.get('payload',{}).get('blockchains',[]):
|
||||
if x.get('blockchainName')==cfg['blockchain_name']:
|
||||
return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','')
|
||||
raise RuntimeError('blockchain not found on server')
|
||||
|
||||
async def block(cfg,n):
|
||||
r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}')
|
||||
p=r.get('payload',r)
|
||||
return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64')
|
||||
|
||||
async def channel_tail(cfg, root_hash):
|
||||
r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}')
|
||||
items=r.get('payload',{}).get('messages',[])
|
||||
if not items: return 0, ZERO32
|
||||
m=items[0]; ref=m.get('messageRef') or {}
|
||||
return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32))
|
||||
|
||||
def read_key(path):
|
||||
a=load_json(path)
|
||||
if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array')
|
||||
return bytes(int(x)&255 for x in a)
|
||||
|
||||
def queue_items(cfg):
|
||||
q=load_json(cfg['queue_file'])
|
||||
if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array')
|
||||
for x in q:
|
||||
if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text')
|
||||
return q
|
||||
|
||||
def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds']))
|
||||
|
||||
async def init_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
root_hash,_=await block(cfg,int(cfg['channel_root_block_number']))
|
||||
line_num,line_hash=await channel_tail(cfg,root_hash)
|
||||
st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
async def reconcile(cfg,st):
|
||||
p=st.get('pending')
|
||||
bn,bhash=await head(cfg)
|
||||
if p:
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn)
|
||||
elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower():
|
||||
print('Pending block was not accepted; it will be retried')
|
||||
else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}')
|
||||
else:
|
||||
if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_one(cfg,st,items):
|
||||
if st.get('pending'):
|
||||
p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash']
|
||||
else:
|
||||
idx=int(st['next_index'])
|
||||
if idx>=len(items):
|
||||
if cfg.get('loop_queue',False): idx=0
|
||||
else: print('Queue exhausted. Nothing to publish.'); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1
|
||||
body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
nextidx=idx+1
|
||||
p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash']
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash')
|
||||
if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st)
|
||||
print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}")
|
||||
return True
|
||||
|
||||
async def main_async(args):
|
||||
cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds']
|
||||
for k in required:
|
||||
if k not in cfg: raise ValueError('missing config: '+k)
|
||||
base=Path(args.config).resolve().parent
|
||||
for k in ('key_file','queue_file','state_file'):
|
||||
p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p)
|
||||
items=queue_items(cfg)
|
||||
st=load_json(cfg['state_file'])
|
||||
if st is None: st=await init_state(cfg); print('Initialized state from server/channel')
|
||||
st=await reconcile(cfg,st)
|
||||
if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return
|
||||
if args.command=='publish-now': await publish_one(cfg,st,items); return
|
||||
while True:
|
||||
st=await reconcile(cfg,load_json(cfg['state_file']))
|
||||
wait=max(0,int(st.get('next_publish_at',0))-int(time.time()))
|
||||
if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait)
|
||||
ok=await publish_one(cfg,st,items)
|
||||
if not ok: await asyncio.sleep(3600)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='SHiNE test channel publisher')
|
||||
ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json')
|
||||
a=ap.parse_args()
|
||||
try: asyncio.run(main_async(a))
|
||||
except KeyboardInterrupt: pass
|
||||
except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1)
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
ZERO32 = bytes(32)
|
||||
FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1
|
||||
|
||||
def atomic_json(path, obj):
|
||||
path=Path(path); path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp=path.with_suffix(path.suffix+'.tmp')
|
||||
tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
os.replace(tmp,path)
|
||||
|
||||
def load_json(path, default=None):
|
||||
p=Path(path)
|
||||
return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default
|
||||
|
||||
def avro_long(n):
|
||||
n=(n<<1) ^ (n>>63); out=bytearray()
|
||||
while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7
|
||||
out.append(n); return bytes(out)
|
||||
|
||||
def avro_tags(tags):
|
||||
out=bytearray()
|
||||
if tags:
|
||||
out += avro_long(len(tags))
|
||||
for k,v in tags:
|
||||
kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb
|
||||
out += avro_long(0); return bytes(out)
|
||||
|
||||
def deep_hash(x):
|
||||
H=lambda b: hashlib.sha384(b).digest()
|
||||
if isinstance(x,(bytes,bytearray)):
|
||||
b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b))
|
||||
acc=H(f'list{len(x)}'.encode())
|
||||
for child in x: acc=H(acc+deep_hash(child))
|
||||
return acc
|
||||
|
||||
def signing_message(owner,tags,data):
|
||||
raw=avro_tags(tags)
|
||||
return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data])
|
||||
|
||||
def data_item(priv64,tags,data):
|
||||
if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes')
|
||||
seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
|
||||
sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data))
|
||||
rawtags=avro_tags(tags)
|
||||
return struct.pack('<H',2)+sig+owner+b'\0\0'+struct.pack('<QQ',len(tags),len(rawtags))+rawtags+data
|
||||
|
||||
def post_body(line_code, prev_line_num, prev_line_hash, this_line_num, text):
|
||||
tb=text.encode('utf-8')
|
||||
if len(tb)>65535: raise ValueError('post text too long')
|
||||
return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb
|
||||
|
||||
def frame(prev_hash, block_num, body, ts=None):
|
||||
ts=int(time.time()) if ts is None else int(ts); size=56+len(body)
|
||||
return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body
|
||||
|
||||
def h32(b): return hashlib.sha256(b).digest()
|
||||
def hx(b): return b.hex()
|
||||
|
||||
async def ws_call(url, op, payload, timeout=20):
|
||||
req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload}
|
||||
async with connect(url, open_timeout=timeout, close_timeout=5) as ws:
|
||||
await ws.send(json.dumps(req,separators=(',',':')))
|
||||
end=time.monotonic()+timeout
|
||||
while True:
|
||||
left=end-time.monotonic()
|
||||
if left<=0: raise TimeoutError(op)
|
||||
msg=await asyncio.wait_for(ws.recv(),left)
|
||||
obj=json.loads(msg)
|
||||
if obj.get('requestId')==req['requestId']: return obj
|
||||
|
||||
async def head(cfg):
|
||||
r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{})
|
||||
if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}')
|
||||
for x in r.get('payload',{}).get('blockchains',[]):
|
||||
if x.get('blockchainName')==cfg['blockchain_name']:
|
||||
return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','')
|
||||
raise RuntimeError('blockchain not found on server')
|
||||
|
||||
async def block(cfg,n):
|
||||
r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}')
|
||||
p=r.get('payload',r)
|
||||
return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64')
|
||||
|
||||
async def channel_tail(cfg, root_hash):
|
||||
r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'})
|
||||
if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}')
|
||||
items=r.get('payload',{}).get('messages',[])
|
||||
if not items: return int(cfg['channel_root_block_number']), bytes.fromhex(root_hash), -1
|
||||
m=items[0]; ref=m.get('messageRef') or {}
|
||||
return int(ref.get('blockNumber')), bytes.fromhex(ref.get('blockHash','00'*32)), int(m.get('lineStep') or 0)
|
||||
|
||||
def read_key(path):
|
||||
a=load_json(path)
|
||||
if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array')
|
||||
return bytes(int(x)&255 for x in a)
|
||||
|
||||
def queue_items(cfg):
|
||||
q=load_json(cfg['queue_file'])
|
||||
if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array')
|
||||
for x in q:
|
||||
if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text')
|
||||
return q
|
||||
|
||||
def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds']))
|
||||
|
||||
async def init_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
root_hash,_=await block(cfg,int(cfg['channel_root_block_number']))
|
||||
line_num,line_hash=await channel_tail(cfg,root_hash)
|
||||
st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
async def reconcile(cfg,st):
|
||||
p=st.get('pending')
|
||||
bn,bhash=await head(cfg)
|
||||
if p:
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn)
|
||||
elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower():
|
||||
print('Pending block was not accepted; it will be retried')
|
||||
else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}')
|
||||
else:
|
||||
if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_one(cfg,st,items):
|
||||
if st.get('pending'):
|
||||
p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash']
|
||||
else:
|
||||
idx=int(st['next_index'])
|
||||
if idx>=len(items):
|
||||
if cfg.get('loop_queue',False): idx=0
|
||||
else: print('Queue exhausted. Nothing to publish.'); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1
|
||||
body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
nextidx=idx+1
|
||||
p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash']
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash')
|
||||
if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st)
|
||||
print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}")
|
||||
return True
|
||||
|
||||
|
||||
async def init_multi_state(cfg):
|
||||
bn,bhash=await head(cfg)
|
||||
chans={}
|
||||
now=int(time.time())
|
||||
for ch in cfg['channels']:
|
||||
local=dict(cfg); local.update(ch)
|
||||
root_hash,_=await block(local,int(ch['channel_root_block_number']))
|
||||
line_block,line_hash,line_step=await channel_tail(local,root_hash)
|
||||
chans[ch['channel_name']]={'next_index':0,'last_line_block_number':line_block,
|
||||
'last_line_number':line_step,'last_line_hash':hx(line_hash),
|
||||
'next_publish_at':next_channel_time(cfg,ch,now),
|
||||
'published_total':0}
|
||||
st={'version':2,'last_block_number':bn,'last_block_hash':bhash,'pending':None,'channels':chans}
|
||||
atomic_json(cfg['state_file'],st); return st
|
||||
|
||||
def channel_delay(cfg,ch):
|
||||
return random.randint(int(ch.get('min_interval_seconds',cfg['min_interval_seconds'])),int(ch.get('max_interval_seconds',cfg['max_interval_seconds'])))
|
||||
|
||||
def _hm(v):
|
||||
h,m=str(v).split(':',1); return int(h),int(m)
|
||||
|
||||
def _quiet(dt,ch,cfg):
|
||||
q=ch.get('quiet_hours',cfg.get('quiet_hours',{}))
|
||||
if not q or not q.get('enabled',True): return False
|
||||
sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00'))
|
||||
t=dt.hour*60+dt.minute; a=sh*60+sm; b=eh*60+em
|
||||
return (a<=t or t<b) if a>b else (a<=t<b)
|
||||
|
||||
def active_deadline(cfg,ch,active_seconds,start_ts=None):
|
||||
"""Advance only through non-quiet seconds. Quiet time does not consume interval."""
|
||||
tz=ZoneInfo(ch.get('timezone',cfg.get('timezone','UTC')))
|
||||
dt=datetime.fromtimestamp(start_ts or time.time(),tz)
|
||||
remain=int(active_seconds)
|
||||
# Minute/second boundaries are tiny compared with 3h intervals; calculate exact chunks.
|
||||
while remain>0:
|
||||
q=ch.get('quiet_hours',cfg.get('quiet_hours',{}))
|
||||
if not q or not q.get('enabled',True):
|
||||
return int((dt+timedelta(seconds=remain)).timestamp())
|
||||
sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00'))
|
||||
if _quiet(dt,ch,cfg):
|
||||
end=dt.replace(hour=eh,minute=em,second=0,microsecond=0)
|
||||
if end<=dt: end+=timedelta(days=1)
|
||||
dt=end; continue
|
||||
qs=dt.replace(hour=sh,minute=sm,second=0,microsecond=0)
|
||||
if qs<=dt: qs+=timedelta(days=1)
|
||||
usable=max(0,int((qs-dt).total_seconds()))
|
||||
if remain<=usable: return int((dt+timedelta(seconds=remain)).timestamp())
|
||||
remain-=usable; dt=qs
|
||||
return int(dt.timestamp())
|
||||
|
||||
def next_channel_time(cfg,ch,start_ts=None):
|
||||
return active_deadline(cfg,ch,channel_delay(cfg,ch),start_ts)
|
||||
|
||||
async def reconcile_multi(cfg,st):
|
||||
bn,bhash=await head(cfg); p=st.get('pending')
|
||||
if p:
|
||||
cs=st['channels'][p['channel_name']]
|
||||
if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower():
|
||||
st['last_block_number']=bn; st['last_block_hash']=bhash
|
||||
cs['last_line_block_number']=p['block_number']; cs['last_line_number']=p['this_line_number']; cs['last_line_hash']=p['block_hash']
|
||||
cs['next_index']=p['next_index_after']; cs['published_total']=int(cs.get('published_total',0))+1
|
||||
ch=next(x for x in cfg['channels'] if x['channel_name']==p['channel_name'])
|
||||
cs['next_publish_at']=next_channel_time(cfg,ch); st['pending']=None; atomic_json(cfg['state_file'],st)
|
||||
elif bn!=p['previous_block_number'] or bhash.lower()!=p['previous_block_hash'].lower():
|
||||
raise RuntimeError('global chain changed while a pending block exists; refusing to fork')
|
||||
elif bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower():
|
||||
raise RuntimeError('server global chain head differs from publisher state; refusing to fork')
|
||||
return st
|
||||
|
||||
async def publish_channel(cfg,st,ch,force=False):
|
||||
cs=st['channels'][ch['channel_name']]; items=queue_items(ch)
|
||||
idx=int(cs['next_index'])
|
||||
if idx>=len(items):
|
||||
if ch.get('loop_queue',cfg.get('loop_queue',False)): idx=0
|
||||
else:
|
||||
cs['next_publish_at']=int(time.time())+3600; atomic_json(cfg['state_file'],st); return False
|
||||
text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1
|
||||
prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(cs['last_line_number'])+1
|
||||
prev_line_block=int(cs.get('last_line_block_number', ch['channel_root_block_number']))
|
||||
body=post_body(int(ch['channel_root_block_number']),prev_line_block,bytes.fromhex(cs['last_line_hash']),this_line,text)
|
||||
fr=frame(prev_hash,block_num,body); block_hash=h32(fr)
|
||||
tags=[('App','test5590'),('c',ch['channel_name'].strip().lower())]
|
||||
raw=data_item(read_key(cfg['key_file']),tags,fr)
|
||||
p={'channel_name':ch['channel_name'],'queue_index':idx,'next_index_after':idx+1,'block_number':block_num,
|
||||
'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],
|
||||
'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()}
|
||||
st['pending']=p; atomic_json(cfg['state_file'],st)
|
||||
r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,
|
||||
'prevBlockHash':st['last_block_hash'],'blockBytesB64':base64.b64encode(raw).decode()},timeout=30)
|
||||
if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}')
|
||||
st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']
|
||||
cs['last_line_block_number']=block_num; cs['last_line_number']=this_line; cs['last_line_hash']=p['block_hash']; cs['next_index']=idx+1
|
||||
cs['published_total']=int(cs.get('published_total',0))+1; cs['next_publish_at']=next_channel_time(cfg,ch)
|
||||
st['pending']=None; atomic_json(cfg['state_file'],st)
|
||||
print(f"Published {ch['channel_name']}[{idx}] as global block #{block_num}")
|
||||
return True
|
||||
|
||||
async def main_async(args):
|
||||
cfg=load_json(args.config); base=Path(args.config).resolve().parent
|
||||
for k in ('key_file','state_file'):
|
||||
p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p)
|
||||
if not isinstance(cfg.get('channels'),list) or not cfg['channels']: raise ValueError('channels[] required')
|
||||
for ch in cfg['channels']:
|
||||
p=Path(ch['queue_file']); ch['queue_file']=str(p if p.is_absolute() else base/p)
|
||||
for k in ('channel_name','channel_root_block_number','queue_file'):
|
||||
if k not in ch: raise ValueError('channel missing '+k)
|
||||
st=load_json(cfg['state_file'])
|
||||
if st is None: st=await init_multi_state(cfg)
|
||||
st=await reconcile_multi(cfg,st)
|
||||
if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return
|
||||
if args.command=='publish-now':
|
||||
name=args.channel or cfg['channels'][0]['channel_name']
|
||||
ch=next((x for x in cfg['channels'] if x['channel_name']==name),None)
|
||||
if not ch: raise ValueError('unknown channel '+name)
|
||||
await publish_channel(cfg,st,ch,True); return
|
||||
while True:
|
||||
st=await reconcile_multi(cfg,load_json(cfg['state_file']))
|
||||
now=int(time.time())
|
||||
due=sorted(cfg['channels'],key=lambda c:st['channels'][c['channel_name']]['next_publish_at'])
|
||||
ch=due[0]; wait=max(0,int(st['channels'][ch['channel_name']]['next_publish_at'])-now)
|
||||
if wait: await asyncio.sleep(wait)
|
||||
await publish_channel(cfg,st,ch)
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description='SHiNE multi-channel test publisher')
|
||||
ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run')
|
||||
ap.add_argument('--config',default='config.json'); ap.add_argument('--channel',default='')
|
||||
a=ap.parse_args()
|
||||
try: asyncio.run(main_async(a))
|
||||
except KeyboardInterrupt: pass
|
||||
except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1)
|
||||
if __name__=='__main__': main()
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,345 @@
|
||||
[
|
||||
{
|
||||
"id": "001",
|
||||
"text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "002",
|
||||
"text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.",
|
||||
"images": [
|
||||
"images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "003",
|
||||
"text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "004",
|
||||
"text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "005",
|
||||
"text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.",
|
||||
"images": [
|
||||
"images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "006",
|
||||
"text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.",
|
||||
"images": [
|
||||
"images/joke_06.webp",
|
||||
"images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "007",
|
||||
"text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "008",
|
||||
"text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.",
|
||||
"images": [
|
||||
"images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "009",
|
||||
"text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "010",
|
||||
"text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "011",
|
||||
"text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.",
|
||||
"images": [
|
||||
"images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "012",
|
||||
"text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "013",
|
||||
"text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "014",
|
||||
"text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.",
|
||||
"images": [
|
||||
"images/joke_04.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "015",
|
||||
"text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "016",
|
||||
"text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "017",
|
||||
"text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.",
|
||||
"images": [
|
||||
"images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "018",
|
||||
"text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.",
|
||||
"images": [
|
||||
"images/joke_08.webp",
|
||||
"images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019",
|
||||
"text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "020",
|
||||
"text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.",
|
||||
"images": [
|
||||
"images/joke_10.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "021",
|
||||
"text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "022",
|
||||
"text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "023",
|
||||
"text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.",
|
||||
"images": [
|
||||
"images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "024",
|
||||
"text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "025",
|
||||
"text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "026",
|
||||
"text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.",
|
||||
"images": [
|
||||
"images/joke_06.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "027",
|
||||
"text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "028",
|
||||
"text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "029",
|
||||
"text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.",
|
||||
"images": [
|
||||
"images/joke_09.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "030",
|
||||
"text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.",
|
||||
"images": [
|
||||
"images/joke_10.webp",
|
||||
"images/joke_03.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "031",
|
||||
"text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "032",
|
||||
"text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.",
|
||||
"images": [
|
||||
"images/joke_02.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "033",
|
||||
"text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "034",
|
||||
"text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "035",
|
||||
"text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.",
|
||||
"images": [
|
||||
"images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "036",
|
||||
"text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "037",
|
||||
"text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "038",
|
||||
"text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.",
|
||||
"images": [
|
||||
"images/joke_08.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "039",
|
||||
"text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "040",
|
||||
"text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "041",
|
||||
"text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.",
|
||||
"images": [
|
||||
"images/joke_01.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "042",
|
||||
"text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.",
|
||||
"images": [
|
||||
"images/joke_02.webp",
|
||||
"images/joke_05.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "043",
|
||||
"text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "044",
|
||||
"text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "045",
|
||||
"text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "046",
|
||||
"text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "047",
|
||||
"text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "048",
|
||||
"text": "— Ты выключил утюг? — Теперь это главная интрига поездки.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "049",
|
||||
"text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "050",
|
||||
"text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "051",
|
||||
"text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "052",
|
||||
"text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "053",
|
||||
"text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "054",
|
||||
"text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.",
|
||||
"images": [
|
||||
"images/joke_04.webp",
|
||||
"images/joke_07.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "055",
|
||||
"text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "056",
|
||||
"text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "057",
|
||||
"text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "058",
|
||||
"text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "059",
|
||||
"text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.",
|
||||
"images": []
|
||||
},
|
||||
{
|
||||
"id": "060",
|
||||
"text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.",
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
cryptography>=43,<47
|
||||
websockets>=14,<17
|
||||
requests>=2.32,<3
|
||||
arweave-python-client>=1.0.18
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=SHiNE multi-channel test publisher
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=shine
|
||||
WorkingDirectory=/opt/shine-test-publisher/v2
|
||||
ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher_multi.py run --config config.multi.json
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=SHiNE Test Publisher v2
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/shine-test-publisher/v2
|
||||
ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json
|
||||
Restart=always
|
||||
RestartSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
CONFIG_FILE="${1:-prepare.multi.json}"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PYTHON_BIN="$VENV_DIR/bin/python"
|
||||
PIP_BIN="$VENV_DIR/bin/pip"
|
||||
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
echo "ERROR: config not found: $CONFIG_FILE" >&2
|
||||
echo "Copy prepare.multi.example.json to prepare.multi.json first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f keys/arweave-wallet.json ]]; then
|
||||
echo "ERROR: Arweave wallet not found: keys/arweave-wallet.json" >&2
|
||||
echo "Put the private JWK there. The file is ignored by git." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||
python3 -m venv "$VENV_DIR"
|
||||
"$PIP_BIN" install -r requirements.txt
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" prepare/prepare_all_channels.py --config "$CONFIG_FILE"
|
||||