Update UI and migrations, fix dockerfile

This commit is contained in:
Your Name
2026-07-18 21:29:05 +05:00
parent 580c75cbf6
commit 26ef145ccf
95 changed files with 9541 additions and 111 deletions
@@ -21,8 +21,6 @@ SET value_json = to_jsonb($prompt$
Формат текста:
- 3-6 коротких строк или 2-4 компактных абзаца;
- можно использовать 1-3 тематических эмоджи как маркеры структуры, например: 🧵 материал/конструкция, 🎒 переноска, 🛡 защита, детали, 📦 комплект/наличие, 🎯 назначение;
- эмоджи должны помогать читать текст, а не украшать каждую строку;
- не добавляй ссылки и хэштеги в text.
Важное правило про источник:
@@ -30,8 +30,6 @@ SET value_json = to_jsonb($prompt$
ФОРМАТ ТЕКСТА
- 3-6 коротких строк или 2-5 компактных абзацев;
- можно использовать 1-3 тематических эмоджи как маркеры структуры, например: 🧵 материал/конструкция, 🎒 переноска, 🛡 защита, детали, 📦 комплект/наличие, 🎯 назначение;
- эмоджи должны помогать читать текст, а не украшать каждую строку;
- не добавляй ссылки и хэштеги в text.
СТИЛЬ
@@ -0,0 +1,15 @@
DELETE FROM audit_log
WHERE created_at < NOW() - INTERVAL '30 days';
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action_time
ON audit_log(action, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_time
ON audit_log(actor_id, created_at DESC)
WHERE actor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_entity_time
ON audit_log(entity_type, entity_id, created_at DESC);
@@ -0,0 +1,15 @@
UPDATE app_settings s
SET value_json = to_jsonb(wc.enabled),
updated_at = NOW()
FROM worker_controls wc
WHERE (wc.name, s.key) IN (
('ai-qualifier', 'ai_qualifier_enabled'),
('ai-writer', 'ai_writer_enabled')
);
UPDATE raw_posts
SET rewrite_status='failed',
rewrite_notes=COALESCE(rewrite_notes, 'recovered stale writer processing state'),
updated_at=NOW()
WHERE rewrite_status='processing'
AND updated_at < NOW() - INTERVAL '30 minutes';
@@ -0,0 +1,21 @@
-- Keep the AI-writer divider deterministic.
-- Target separator length: 27 characters.
UPDATE app_settings
SET value_json = to_jsonb(
replace(
value_json #>> '{}',
'2. Черта с точным количеством символов ━━━━━━━━━━━━━━━━━━━━━━━━━━━',
'2. Черта-разделитель: отдельная строка ровно из 27 символов. Используй строго эту строку без изменений: ━━━━━━━━━━━━━━━━━━━━━━━━━━━'
)
),
updated_at = NOW()
WHERE key = 'ai_writer_prompt'
AND (value_json #>> '{}') LIKE '%Черта с точным количеством символов%';
UPDATE raw_posts
SET rewritten_text = regexp_replace(rewritten_text, E'(^━{3,}$)', '━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'gm'),
final_text = regexp_replace(final_text, E'(^━{3,}$)', '━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'gm'),
updated_at = NOW()
WHERE COALESCE(rewritten_text, '') LIKE '%━%'
OR COALESCE(final_text, '') LIKE '%━%';
+93
View File
@@ -0,0 +1,93 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS publication_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS publication_error TEXT,
ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS tg_publication_chat_id BIGINT,
ADD COLUMN IF NOT EXISTS tg_publication_thread_id BIGINT,
ADD COLUMN IF NOT EXISTS tg_publication_message_ids BIGINT[],
ADD COLUMN IF NOT EXISTS tg_publication_url TEXT;
CREATE INDEX IF NOT EXISTS idx_raw_posts_publication_queue
ON raw_posts(publication_status, editorial_status, rewrite_status, published_at, reviewed_at, id);
CREATE INDEX IF NOT EXISTS idx_raw_posts_publication_rank
ON raw_posts(final_category_tag, final_source_tag, published_at DESC);
CREATE TABLE IF NOT EXISTS publication_runs (
id BIGSERIAL PRIMARY KEY,
poster TEXT NOT NULL,
schedule_id TEXT NOT NULL,
scheduled_for DATE NOT NULL,
scheduled_time TEXT NOT NULL,
planned_count INTEGER NOT NULL DEFAULT 0,
published_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'started',
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(poster, schedule_id, scheduled_for)
);
CREATE INDEX IF NOT EXISTS idx_publication_runs_poster_date
ON publication_runs(poster, scheduled_for DESC, scheduled_time);
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('tg-poster', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_poster_enabled', 'false'::jsonb, 'bool', 'TG poster enabled', 'Double safety flag for publishing accepted posts to Telegram.', 'TG Poster'),
('tg_poster_bot_token', '""'::jsonb, 'secret', 'TG poster bot token', 'Bot token for final Telegram publication. For test can be the same as upload bot token.', 'TG Poster'),
('tg_poster_chat_id', '"-1003712724327"'::jsonb, 'str', 'TG publication chat id', 'Target Telegram channel/group id. Supports chat_id:thread_id for topics.', 'TG Poster'),
('tg_poster_schedule_json', '[{"id":"morning","time":"10:00","count":1,"enabled":true},{"id":"evening","time":"18:00","count":1,"enabled":true}]'::jsonb, 'json', 'TG publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled.', 'TG Poster'),
('tg_poster_interval_sec', '60'::jsonb, 'int', 'TG poster interval, sec', 'Pause between poster checks.', 'TG Poster'),
('tg_poster_media_group_max_items', '10'::jsonb, 'int', 'TG media group max items', 'Telegram media group supports 2..10 media items.', 'TG Poster'),
('tg_poster_caption_limit', '1024'::jsonb, 'int', 'TG caption limit', 'Telegram caption limit for media messages.', 'TG Poster'),
('tg_poster_message_limit', '4096'::jsonb, 'int', 'TG message limit', 'Telegram text message limit.', 'TG Poster'),
('tg_poster_max_attempts', '3'::jsonb, 'int', 'TG post retry attempts', 'Retries for Telegram API errors.', 'TG Poster'),
('tg_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'TG retry backoff max, sec', 'Max retry sleep for Telegram API errors.', 'TG Poster'),
('tg_poster_send_delay_sec', '1'::jsonb, 'float', 'TG send delay, sec', 'Delay between Telegram sends.', 'TG Poster'),
('tg_poster_text_overflow_caption', '"⬇️ Описание под медиа"'::jsonb, 'str', 'TG overflow caption', 'Caption used when full text is longer than Telegram media caption limit.', 'TG Poster'),
('tg_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many published posts are checked to reduce repeated categories and sources.', 'TG Poster'),
('tg_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'TG Poster'),
('tg_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'TG Poster')
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET description='Обрезает исходный raw-текст перед отправкой в LLM. Итоговый rewrite должен быть компактным: Telegram caption 1024 символа минус будущая строка хэштегов.'
WHERE key='ai_writer_max_text_chars';
UPDATE app_settings
SET value_json = to_jsonb($contract$
OUTPUT SCHEMA (return array matching input order):
{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
Rules:
- Input is a JSON object with key "posts" containing accepted posts.
- Input has key "categories" with objects: id, name, tag.
- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.
- Pick exactly one category from the categories list in input and return its numeric id as category_id.
- Do not add links or hashtags to rewritten text.
- Keep text compact enough for Telegram media caption: target 850 characters, hard maximum 900 characters.
- Remember the system appends category and source hashtags after text, so leave space for them.
- Return JSON only. No markdown. No text outside JSON.
- Never return an empty object. Never omit the "rewrites" key.
- Return one rewrite for every input post id.
- The "text" field must be a ready-to-publish Russian post with normal paragraph line breaks encoded as JSON string newlines.
- The "notes" field must be a short editor note in Russian or null.
$contract$::text),
updated_at = NOW()
WHERE key='ai_writer_contract';
UPDATE app_settings
SET value_json = to_jsonb(
regexp_replace(
value_json #>> '{}',
'- 3-6 коротких строк или 2-5 компактных абзацев;',
'- 3-6 коротких строк или 2-4 компактных абзаца; итоговый text должен целиться в 850 символов и не превышать 900 символов, потому что система добавит хэштеги и Telegram caption ограничен 1024 символами;',
'g'
)
)
WHERE key='ai_writer_prompt'
AND (value_json #>> '{}') LIKE '%3-6 коротких строк или 2-5 компактных абзацев%';
@@ -0,0 +1,46 @@
UPDATE app_settings
SET value_json = '"⬇️ Описание"'::jsonb,
updated_at = NOW()
WHERE key='tg_poster_text_overflow_caption';
UPDATE app_settings
SET value_json = $schedule$[
{"id":"hour-00","time":"00:00","count":4,"enabled":true},
{"id":"hour-01","time":"01:00","count":4,"enabled":true},
{"id":"hour-02","time":"02:00","count":4,"enabled":true},
{"id":"hour-03","time":"03:00","count":4,"enabled":true},
{"id":"hour-04","time":"04:00","count":4,"enabled":true},
{"id":"hour-05","time":"05:00","count":4,"enabled":true},
{"id":"hour-06","time":"06:00","count":4,"enabled":true},
{"id":"hour-07","time":"07:00","count":4,"enabled":true},
{"id":"hour-08","time":"08:00","count":4,"enabled":true},
{"id":"hour-09","time":"09:00","count":4,"enabled":true},
{"id":"hour-10","time":"10:00","count":4,"enabled":true},
{"id":"hour-11","time":"11:00","count":4,"enabled":true},
{"id":"hour-12","time":"12:00","count":4,"enabled":true},
{"id":"hour-13","time":"13:00","count":4,"enabled":true},
{"id":"hour-14","time":"14:00","count":4,"enabled":true},
{"id":"hour-15","time":"15:00","count":4,"enabled":true},
{"id":"hour-16","time":"16:00","count":4,"enabled":true},
{"id":"hour-17","time":"17:00","count":4,"enabled":true},
{"id":"hour-18","time":"18:00","count":4,"enabled":true},
{"id":"hour-19","time":"19:00","count":4,"enabled":true},
{"id":"hour-20","time":"20:00","count":4,"enabled":true},
{"id":"hour-21","time":"21:00","count":4,"enabled":true},
{"id":"hour-22","time":"22:00","count":4,"enabled":true},
{"id":"hour-23","time":"23:00","count":4,"enabled":true}
]$schedule$::jsonb,
updated_at = NOW()
WHERE key='tg_poster_schedule_json';
UPDATE raw_posts
SET editorial_status='published',
updated_at=NOW()
WHERE publication_status='published'
AND editorial_status='accepted';
UPDATE raw_posts
SET editorial_status='publish_failed',
updated_at=NOW()
WHERE publication_status='publish_failed'
AND editorial_status='accepted';
@@ -0,0 +1,44 @@
-- Keep the AI-writer divider deterministic.
-- Target separator length: 17 characters.
UPDATE app_settings
SET value_json = to_jsonb(
regexp_replace(
value_json #>> '{}',
'Черта-разделитель:[^\n]*',
'Черта-разделитель: отдельная строка ровно из 17 символов. Используй строго эту строку без изменений: ━━━━━━━━━━━━━━━━━',
'g'
)
),
updated_at = NOW()
WHERE key = 'ai_writer_prompt'
AND (value_json #>> '{}') LIKE '%Черта-разделитель%';
UPDATE app_settings
SET value_json = to_jsonb(
CASE
WHEN (value_json #>> '{}') LIKE '%Separator line must be exactly%'
THEN regexp_replace(
value_json #>> '{}',
'Separator line must be exactly[^\n]*',
'Separator line must be exactly this 17-character line when used: ━━━━━━━━━━━━━━━━━',
'g'
)
ELSE (value_json #>> '{}') || E'\n- Separator line must be exactly this 17-character line when used: ━━━━━━━━━━━━━━━━━'
END
),
updated_at = NOW()
WHERE key = 'ai_writer_contract';
UPDATE raw_posts
SET rewritten_text = CASE
WHEN rewritten_text IS NULL THEN NULL
ELSE regexp_replace(rewritten_text, E'^[━─—-]{3,}\\s*$', '━━━━━━━━━━━━━━━━━', 'gm')
END,
final_text = CASE
WHEN final_text IS NULL THEN NULL
ELSE regexp_replace(final_text, E'^[━─—-]{3,}\\s*$', '━━━━━━━━━━━━━━━━━', 'gm')
END,
updated_at = NOW()
WHERE COALESCE(rewritten_text, '') ~ E'(?m)^[━─—-]{3,}\\s*$'
OR COALESCE(final_text, '') ~ E'(?m)^[━─—-]{3,}\\s*$';
@@ -0,0 +1,34 @@
-- Manual Raw-to-rewrite flow is implemented in admin.py.
-- Non-target category (sort_order/category_id 18, tag НЦК) must not enter editorial review.
UPDATE app_settings
SET value_json = to_jsonb(
CASE
WHEN (value_json #>> '{}') LIKE '%category_id 18%'
THEN value_json #>> '{}'
ELSE (value_json #>> '{}') || E'\n- If the post is non-target content, choose category_id 18 ("Не целевой контент", tag "НЦК"); the app will reject it automatically.'
END
),
updated_at = NOW()
WHERE key = 'ai_writer_contract';
UPDATE raw_posts
SET editorial_status='rejected',
qualification_status='rejected',
qualification_decision='rejected',
qualification_model_decision='rejected',
qualification_reason=COALESCE(NULLIF(qualification_reason, ''), 'AI writer selected non-target category'),
qualification_reject_tag=COALESCE(NULLIF(qualification_reject_tag, ''), 'non_target_content'),
editor_notes=COALESCE(NULLIF(editor_notes, ''), 'Отклонено AI: Не целевой контент'),
reviewed_at=COALESCE(reviewed_at, NOW()),
updated_at=NOW()
WHERE rewrite_status='ready'
AND COALESCE(editorial_status, 'review')='review'
AND (
rewrite_category_id=18
OR final_category_id=18
OR lower(COALESCE(rewrite_category_tag, '')) IN ('нцк', 'nck')
OR lower(COALESCE(final_category_tag, '')) IN ('нцк', 'nck')
OR lower(COALESCE(rewrite_category, '')) = lower('Не целевой контент')
OR lower(COALESCE(final_category, '')) = lower('Не целевой контент')
);
@@ -0,0 +1,67 @@
CREATE TABLE IF NOT EXISTS post_publications (
id BIGSERIAL PRIMARY KEY,
raw_post_id BIGINT NOT NULL REFERENCES raw_posts(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
target_id TEXT,
external_id TEXT,
url TEXT,
error TEXT,
attachments_json JSONB NOT NULL DEFAULT '[]'::jsonb,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(raw_post_id, platform)
);
CREATE INDEX IF NOT EXISTS idx_post_publications_platform_status
ON post_publications(platform, status, target_id, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_post_publications_raw_post
ON post_publications(raw_post_id);
INSERT INTO post_publications(raw_post_id, platform, status, target_id, external_id, url, error, attachments_json, published_at, updated_at)
SELECT
id,
'tg',
publication_status,
tg_publication_chat_id::text,
CASE WHEN tg_publication_message_ids IS NULL THEN NULL ELSE array_to_string(tg_publication_message_ids, ',') END,
tg_publication_url,
publication_error,
COALESCE(to_jsonb(tg_publication_message_ids), '[]'::jsonb),
published_at,
NOW()
FROM raw_posts
WHERE COALESCE(publication_status, 'pending') <> 'pending'
ON CONFLICT (raw_post_id, platform) DO UPDATE
SET status=EXCLUDED.status,
target_id=EXCLUDED.target_id,
external_id=EXCLUDED.external_id,
url=EXCLUDED.url,
error=EXCLUDED.error,
attachments_json=EXCLUDED.attachments_json,
published_at=EXCLUDED.published_at,
updated_at=NOW();
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('vk-poster', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('vk_poster_enabled', 'false'::jsonb, 'bool', 'VK poster enabled', 'Double safety flag for publishing accepted posts to VK.', 'VK Poster'),
('vk_poster_access_token', '""'::jsonb, 'secret', 'VK poster access token', 'Group/user token with wall.post and photos permissions. If empty, env VK_GROUP_ACCESS_TOKEN/VK_ACCESS_TOKEN is used.', 'VK Poster'),
('vk_poster_owner_id', '0'::jsonb, 'int', 'VK target owner_id', 'Target VK group owner_id, usually negative: -123456789.', 'VK Poster'),
('vk_poster_from_group', 'true'::jsonb, 'bool', 'Post from group', 'Use from_group=1 when publishing to a community wall.', 'VK Poster'),
('vk_poster_schedule_json', '[{"id":"vk-morning","time":"10:05","count":1,"enabled":true},{"id":"vk-evening","time":"18:05","count":1,"enabled":true}]'::jsonb, 'json', 'VK publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled.', 'VK Poster'),
('vk_poster_interval_sec', '60'::jsonb, 'int', 'VK poster interval, sec', 'Pause between poster checks.', 'VK Poster'),
('vk_poster_timeout_sec', '90'::jsonb, 'int', 'VK API timeout, sec', 'Total timeout for VK API calls and media uploads.', 'VK Poster'),
('vk_poster_max_attempts', '3'::jsonb, 'int', 'VK post retry attempts', 'Retries for VK API/network errors.', 'VK Poster'),
('vk_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'VK retry backoff max, sec', 'Max retry sleep for VK API/network errors.', 'VK Poster'),
('vk_poster_send_delay_sec', '1'::jsonb, 'float', 'VK send delay, sec', 'Delay between VK posts.', 'VK Poster'),
('vk_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many VK-published posts are checked to reduce repeated categories and sources.', 'VK Poster'),
('vk_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'VK Poster'),
('vk_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'VK Poster'),
('vk_poster_dry_run', 'false'::jsonb, 'bool', 'VK dry run', 'If true, prepares attachments but does not call wall.post. Keep false for production.', 'VK Poster')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,5 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('vk_poster_app_id', '""'::jsonb, 'str', 'VK OAuth app id', 'VK application id used for user OAuth code flow.', 'VK Poster'),
('vk_poster_client_secret', '""'::jsonb, 'secret', 'VK OAuth client secret', 'Protected key from VK application settings. Used server-side to exchange code for user access token.', 'VK Poster')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,6 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('vk_poster_refresh_token', '""'::jsonb, 'secret', 'VK OAuth refresh token', 'Refresh token from VK ID code flow. Used by VK poster to renew access token automatically.', 'VK Poster'),
('vk_poster_token_device_id', '""'::jsonb, 'str', 'VK OAuth device id', 'VK ID device_id returned with authorization code/token exchange.', 'VK Poster'),
('vk_poster_token_expires_at', '""'::jsonb, 'str', 'VK access token expires at', 'UTC ISO timestamp used by VK poster to refresh the access token before expiry.', 'VK Poster')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,18 @@
-- Make producer mention mandatory in every AI writer result.
UPDATE app_settings
SET value_json = to_jsonb(
CASE
WHEN (value_json #>> '{}') LIKE '%explicitly mention producer_name%'
OR (value_json #>> '{}') LIKE '%явно упоминает producer_name%'
THEN value_json #>> '{}'
ELSE replace(
value_json #>> '{}',
'- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.',
'- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.' || E'\n' ||
'- The "text" field for every rewrite must explicitly mention producer_name at least once. Do not satisfy this only through producer_tag, hashtags, notes, or metadata.'
)
END
),
updated_at = NOW()
WHERE key = 'ai_writer_contract';
+39
View File
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS post_reactions (
id BIGSERIAL PRIMARY KEY,
publication_id BIGINT NOT NULL REFERENCES post_publications(id) ON DELETE CASCADE,
raw_post_id BIGINT NOT NULL REFERENCES raw_posts(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
reactor_key TEXT NOT NULL,
reaction TEXT NOT NULL,
target_id TEXT NOT NULL,
message_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
reacted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(publication_id, reactor_key, message_id)
);
CREATE INDEX IF NOT EXISTS idx_post_reactions_status
ON post_reactions(platform, status, updated_at);
CREATE INDEX IF NOT EXISTS idx_post_reactions_raw_post
ON post_reactions(raw_post_id);
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('tg-reactor', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_reactor_enabled', 'false'::jsonb, 'bool', 'TG reactor enabled', 'Double safety flag for Telegram reaction worker.', 'TG Reactor'),
('tg_reactor_bot_tokens', '""'::jsonb, 'secret', 'TG reactor bot tokens', 'One or more Telegram bot tokens separated by comma/newline. If empty, uses tg_poster_bot_token/TG_BOT_TOKEN.', 'TG Reactor'),
('tg_reactor_reactions_json', '["👍"]'::jsonb, 'json', 'TG reactions', 'Emoji reactions. One bot can set one reaction; multiple tokens can set multiple reactions.', 'TG Reactor'),
('tg_reactor_delay_sec', '60'::jsonb, 'int', 'Delay after publication, sec', 'How long to wait after TG publication before reacting.', 'TG Reactor'),
('tg_reactor_interval_sec', '60'::jsonb, 'int', 'TG reactor interval, sec', 'Pause between reaction worker checks.', 'TG Reactor'),
('tg_reactor_react_all_messages', 'false'::jsonb, 'bool', 'React to all media messages', 'If false, reacts only to the first message of each published post.', 'TG Reactor'),
('tg_reactor_max_attempts', '3'::jsonb, 'int', 'TG reaction retry attempts', 'Retries per publication/message/reactor.', 'TG Reactor'),
('tg_reactor_retry_backoff_max_sec', '30'::jsonb, 'int', 'TG reaction retry backoff max, sec', 'Max sleep for Telegram reaction retry errors.', 'TG Reactor')
ON CONFLICT (key) DO NOTHING;
+4
View File
@@ -0,0 +1,4 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_reactor_since', '""'::jsonb, 'str', 'React only since', 'UTC ISO timestamp. If set, TG reactor ignores publications older than this moment.', 'TG Reactor')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,4 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_reactor_reaction_pause_sec', '2'::jsonb, 'int', 'Pause between TG reactions, sec', 'Small pause after each successful reaction to avoid a burst of Telegram API calls.', 'TG Reactor')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,14 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('video_max_height', '720'::jsonb, 'int', 'Video max height', 'Maximum VK video height selected by yt-dlp before uploading to Telegram.', 'Uploader')
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET value_json='600'::jsonb,
updated_at=NOW()
WHERE key='uploader_yt_dlp_timeout_sec'
AND CASE
WHEN jsonb_typeof(value_json) = 'number' THEN (value_json #>> '{}')::int
WHEN jsonb_typeof(value_json) = 'string' AND (value_json #>> '{}') ~ '^\d+$' THEN (value_json #>> '{}')::int
ELSE 0
END < 600;
+13
View File
@@ -0,0 +1,13 @@
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('daily-report', TRUE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('daily_report_enabled', 'true'::jsonb, 'bool', 'Daily report enabled', 'Double safety flag for daily Telegram operational report.', 'Daily Report'),
('daily_report_bot_token', '""'::jsonb, 'secret', 'Daily report bot token', 'Telegram bot token for reports. If empty, uses tg_poster_bot_token or TG_BOT_TOKEN.', 'Daily Report'),
('daily_report_recipient_ids', '[442509142]'::jsonb, 'json', 'Daily report recipients', 'Telegram user/chat IDs that receive the daily report.', 'Daily Report'),
('daily_report_time', '"00:04"'::jsonb, 'str', 'Daily report time', 'EKB time HH:MM when yesterday report is sent.', 'Daily Report'),
('daily_report_interval_sec', '60'::jsonb, 'int', 'Daily report interval, sec', 'Pause between checks.', 'Daily Report'),
('daily_report_last_sent_date', '""'::jsonb, 'str', 'Last sent report date', 'Internal YYYY-MM-DD report date already sent automatically.', 'Daily Report')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,57 @@
ALTER TABLE content_categories
ADD COLUMN IF NOT EXISTS site_name TEXT,
ADD COLUMN IF NOT EXISTS site_slug TEXT,
ADD COLUMN IF NOT EXISTS site_enabled BOOLEAN NOT NULL DEFAULT TRUE;
UPDATE content_categories
SET site_name = CASE sort_order
WHEN 1 THEN 'Одежда'
WHEN 2 THEN 'Снаряжение'
WHEN 3 THEN 'Броня'
WHEN 4 THEN 'Аксессуары'
WHEN 5 THEN 'Связь'
WHEN 6 THEN 'Патчи'
WHEN 7 THEN 'Навигация'
WHEN 8 THEN 'Прицелы'
WHEN 9 THEN 'Ночные приборы'
WHEN 10 THEN 'Медицина'
WHEN 11 THEN 'Свет'
WHEN 12 THEN 'Электроника'
WHEN 13 THEN 'Ножи'
WHEN 14 THEN 'Оружейный обвес'
WHEN 15 THEN 'Практика'
WHEN 16 THEN 'Теория'
WHEN 17 THEN 'Пушки'
WHEN 18 THEN 'НЦК'
ELSE initcap(replace(tag, '_', ' '))
END,
site_slug = CASE sort_order
WHEN 1 THEN 'odezhda'
WHEN 2 THEN 'snaryazhenie'
WHEN 3 THEN 'bronya'
WHEN 4 THEN 'aksessuary'
WHEN 5 THEN 'svyaz'
WHEN 6 THEN 'patchi'
WHEN 7 THEN 'navigatsiya'
WHEN 8 THEN 'pricely'
WHEN 9 THEN 'nochnye-pribory'
WHEN 10 THEN 'medicina'
WHEN 11 THEN 'svet'
WHEN 12 THEN 'elektronika'
WHEN 13 THEN 'nozhi'
WHEN 14 THEN 'oruzheynyy-obves'
WHEN 15 THEN 'praktika'
WHEN 16 THEN 'teoriya'
WHEN 17 THEN 'pushki'
WHEN 18 THEN 'nck'
ELSE site_slug
END,
site_enabled = sort_order <> 18,
updated_at = NOW();
ALTER TABLE content_categories
ALTER COLUMN site_name SET NOT NULL,
ALTER COLUMN site_slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_content_categories_site_slug
ON content_categories(site_slug);
+13
View File
@@ -0,0 +1,13 @@
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('site-poster', TRUE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('site_poster_enabled', 'true'::jsonb, 'bool', 'Site poster enabled', 'Double safety flag for automatic Ghost publishing.', 'Site Poster'),
('site_poster_interval_sec', '300'::jsonb, 'int', 'Site poster interval, sec', 'Pause between automatic queue checks.', 'Site Poster'),
('site_poster_photo_batch_size', '10'::jsonb, 'int', 'Photo batch size', 'Maximum photo-ready posts per automatic run.', 'Site Poster'),
('site_poster_video_batch_size', '1'::jsonb, 'int', 'Video batch size', 'Maximum video-only posts per automatic run.', 'Site Poster'),
('site_poster_ghost_url', '"https://f-n8.ru"'::jsonb, 'str', 'Ghost URL', 'Target Ghost public URL.', 'Site Poster'),
('site_poster_key_file', '"/opt/vk-parser/.site-poster-key"'::jsonb, 'str', 'Ghost key file', 'Server path to the Ghost Admin API key.', 'Site Poster')
ON CONFLICT (key) DO NOTHING;
+28
View File
@@ -0,0 +1,28 @@
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('max-poster', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('max_poster_enabled', 'false'::jsonb, 'bool', 'MAX poster enabled', 'Double safety flag for publishing accepted posts to MAX.', 'MAX Poster'),
('max_poster_bot_token', '""'::jsonb, 'secret', 'MAX poster bot token', 'Bot access token for MAX API. Keep empty in git and set on server.', 'MAX Poster'),
('max_poster_chat_id', '""'::jsonb, 'str', 'MAX target chat id', 'Target MAX channel/group chat_id.', 'MAX Poster'),
('max_poster_api_base_url', '"https://platform-api2.max.ru"'::jsonb, 'str', 'MAX API base URL', 'MAX Bot API base URL. Since 2026 use platform-api2.max.ru.', 'MAX Poster'),
('max_poster_schedule_json', '[{"id":"hour-00","time":"00:00","count":4,"enabled":true},{"id":"hour-01","time":"01:00","count":4,"enabled":true},{"id":"hour-02","time":"02:00","count":4,"enabled":true},{"id":"hour-03","time":"03:00","count":4,"enabled":true},{"id":"hour-04","time":"04:00","count":4,"enabled":true},{"id":"hour-05","time":"05:00","count":4,"enabled":true},{"id":"hour-06","time":"06:00","count":4,"enabled":true},{"id":"hour-07","time":"07:00","count":4,"enabled":true},{"id":"hour-08","time":"08:00","count":4,"enabled":true},{"id":"hour-09","time":"09:00","count":4,"enabled":true},{"id":"hour-10","time":"10:00","count":4,"enabled":true},{"id":"hour-11","time":"11:00","count":4,"enabled":true},{"id":"hour-12","time":"12:00","count":4,"enabled":true},{"id":"hour-13","time":"13:00","count":4,"enabled":true},{"id":"hour-14","time":"14:00","count":4,"enabled":true},{"id":"hour-15","time":"15:00","count":4,"enabled":true},{"id":"hour-16","time":"16:00","count":4,"enabled":true},{"id":"hour-17","time":"17:00","count":4,"enabled":true},{"id":"hour-18","time":"18:00","count":4,"enabled":true},{"id":"hour-19","time":"19:00","count":4,"enabled":true},{"id":"hour-20","time":"20:00","count":4,"enabled":true},{"id":"hour-21","time":"21:00","count":4,"enabled":true},{"id":"hour-22","time":"22:00","count":4,"enabled":true},{"id":"hour-23","time":"23:00","count":4,"enabled":true}]'::jsonb, 'json', 'MAX publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled. Defaults to the TG hourly schedule.', 'MAX Poster'),
('max_poster_interval_sec', '60'::jsonb, 'int', 'MAX poster interval, sec', 'Pause between poster checks.', 'MAX Poster'),
('max_poster_message_limit', '4000'::jsonb, 'int', 'MAX message limit', 'MAX text message limit.', 'MAX Poster'),
('max_poster_media_max_items', '10'::jsonb, 'int', 'MAX media max items', 'Maximum media attachments per MAX message.', 'MAX Poster'),
('max_poster_media_process_delay_sec', '3'::jsonb, 'float', 'MAX media process delay, sec', 'Pause after media upload before sending the message.', 'MAX Poster'),
('max_poster_video_ready_attempts', '5'::jsonb, 'int', 'MAX video ready attempts', 'Retries for sending a message while MAX is still processing uploaded video.', 'MAX Poster'),
('max_poster_video_ready_delay_sec', '10'::jsonb, 'float', 'MAX video ready delay, sec', 'Initial retry delay while MAX is processing uploaded video. Delay doubles after each attempt.', 'MAX Poster'),
('max_poster_max_attempts', '3'::jsonb, 'int', 'MAX post retry attempts', 'Retries for MAX API/network errors and attachment processing.', 'MAX Poster'),
('max_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'MAX retry backoff max, sec', 'Max retry sleep for MAX API/network errors.', 'MAX Poster'),
('max_poster_send_delay_sec', '1'::jsonb, 'float', 'MAX send delay, sec', 'Delay between MAX sends.', 'MAX Poster'),
('max_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many MAX-published posts are checked to reduce repeated categories and sources.', 'MAX Poster'),
('max_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'MAX Poster'),
('max_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'MAX Poster'),
('max_poster_auto_reaction_enabled', 'true'::jsonb, 'bool', 'MAX auto reaction enabled', 'Try to set a reaction after publishing. If MAX API rejects it, publication remains successful.', 'MAX Poster'),
('max_poster_auto_reaction', '"👍"'::jsonb, 'str', 'MAX auto reaction', 'Emoji reaction to try after publishing.', 'MAX Poster'),
('max_poster_reaction_path_template', '"/messages/{message_id}/reactions"'::jsonb, 'str', 'MAX reaction path template', 'Relative API path for reaction call if supported by MAX API.', 'MAX Poster'),
('max_poster_dry_run', 'false'::jsonb, 'bool', 'MAX dry run', 'If true, prepares publication but does not call MAX send message.', 'MAX Poster')
ON CONFLICT (key) DO NOTHING;