feat: AI Press Secretary - content generation and publishing platform

Features:
- AI-powered post generation from brand profiles
- Image generation with templates
- Content planning
- Telegram publishing
- Social profile analyzer
- Community organization support
- Markdown to HTML conversion for Telegram
- Inline AI chat for post editing

Deploy:
- Add deployment scripts for Linux servers
- Add PowerShell deploy script for Windows
- Add Nginx and SSL setup scripts
- Add backup and restore scripts
This commit is contained in:
apuc committed 2026-08-27 15:24:15 +03:00
1 parent 81625b2e6f
commit c6048aeea0
37 files changed
+7037 -225

No files matched your search

+527 -2
View File
@@ -80,6 +80,113 @@ def create_db():
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS brand_surveys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
title TEXT,
data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
chat_id TEXT,
title TEXT,
username TEXT,
chat_type TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS provider_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE,
text_provider_type TEXT DEFAULT 'piapi',
text_api_key TEXT,
text_base_url TEXT,
text_model TEXT,
image_provider_type TEXT DEFAULT 'piapi',
image_api_key TEXT,
image_base_url TEXT,
image_model TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS post_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER,
user_id INTEGER,
filename TEXT,
source TEXT DEFAULT 'upload',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
task_type TEXT NOT NULL,
status TEXT DEFAULT 'pending',
result_json TEXT,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS post_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
)
''')
# Миграция со старой схемы (provider_type/api_key/base_url/text_model/image_model)
# на новую (text_* + image_*). Если старых колонок нет — пропускаем.
cursor.execute("PRAGMA table_info(provider_settings)")
ps_columns = [row[1] for row in cursor.fetchall()]
if 'provider_type' in ps_columns and 'text_provider_type' in ps_columns:
# Промежуточное состояние миграции — копируем данные из старых колонок в новые
try:
cursor.execute('''
UPDATE provider_settings SET
text_provider_type = COALESCE(text_provider_type, provider_type),
text_api_key = COALESCE(text_api_key, api_key),
text_base_url = COALESCE(text_base_url, base_url),
image_provider_type = COALESCE(image_provider_type, provider_type),
image_api_key = COALESCE(image_api_key, api_key),
image_base_url = COALESCE(image_base_url, base_url),
image_model = COALESCE(image_model, image_model)
WHERE api_key IS NOT NULL
''')
except Exception:
pass
# Колонка published в posts
cursor.execute("PRAGMA table_info(posts)")
posts_columns = [row[1] for row in cursor.fetchall()]
if 'published' not in posts_columns:
cursor.execute('ALTER TABLE posts ADD COLUMN published BOOLEAN DEFAULT 0')
print("✅ Колонка 'published' добавлена в таблицу posts")
if 'published_at' not in posts_columns:
cursor.execute('ALTER TABLE posts ADD COLUMN published_at TIMESTAMP')
print("✅ Колонка 'published_at' добавлена в таблицу posts")
if 'published_to' not in posts_columns:
cursor.execute('ALTER TABLE posts ADD COLUMN published_to TEXT')
print("✅ Колонка 'published_to' добавлена в таблицу posts")
conn.commit()
conn.close()
print("✅ База данных создана!")
@@ -209,8 +316,10 @@ def get_user_by_id(user_id):
return dict(row) if row else None
def create_web_user(email, password_hash, name, phone, business_type, description):
"""Создаёт пользователя веб-кабинета (без telegram_id). Возвращает id нового пользователя."""
def create_web_user(email, password_hash, name, phone, business_type=None, description=None):
"""Создаёт пользователя веб-кабинета (без telegram_id).
business_type и description опциональны — посты генерируются на основе медиа-образа.
Возвращает id нового пользователя."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute('''
@@ -253,7 +362,15 @@ def save_post_for_user(user_id, platform, content, style=None, wishes=None):
VALUES (?, ?, ?, ?, ?)
''', (user_id, platform, content, style, wishes))
conn.commit()
post_id = cursor.lastrowid
# Сохраняем начальную ревизию
cursor.execute(
'INSERT INTO post_revisions (post_id, user_id, content) VALUES (?, ?, ?)',
(post_id, user_id, content),
)
conn.commit()
conn.close()
return post_id
def get_posts_for_user(user_id, limit=10):
@@ -327,6 +444,35 @@ def delete_post_by_id(user_id, post_id):
conn.close()
# === РЕВИЗИИ ПОСТОВ ===
def add_post_revision(post_id, user_id, content):
"""Сохраняет ревизию (каждое изменение текста поста)."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'INSERT INTO post_revisions (post_id, user_id, content) VALUES (?, ?, ?)',
(post_id, user_id, content),
)
conn.commit()
conn.close()
def get_post_revisions(post_id, user_id, limit=50):
"""Возвращает последние N ревизий поста (от новых к старым)."""
conn = sqlite3.connect('users.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
'SELECT id, content, created_at FROM post_revisions '
'WHERE post_id = ? AND user_id = ? ORDER BY id DESC LIMIT ?',
(post_id, user_id, limit),
)
rows = [dict(r) for r in cursor.fetchall()]
conn.close()
return rows
# === КОНТЕНТ-ПЛАН (веб-кабинет) ===
PLAN_STATUSES = ('planned', 'published', 'postponed')
@@ -438,5 +584,384 @@ def delete_media_by_id(user_id, media_id):
return None
# === ОПРОСЫ МЕДИА-ОБРАЗА (веб-кабинет) ===
import json as _json
def save_brand_survey(user_id, title, data):
"""Сохраняет опрос медиа-образа. data — dict (сериализуется в JSON).
Возвращает id новой записи."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'INSERT INTO brand_surveys (user_id, title, data) VALUES (?, ?, ?)',
(user_id, title, _json.dumps(data, ensure_ascii=False))
)
conn.commit()
survey_id = cursor.lastrowid
conn.close()
return survey_id
def get_brand_surveys_for_user(user_id, limit=50):
"""Возвращает список опросов пользователя (без поля data)."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute(
'SELECT id, title, created_at FROM brand_surveys '
'WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
(user_id, limit)
)
rows = [dict(row) for row in cursor.fetchall()]
conn.close()
return rows
def get_latest_brand_survey_for_user(user_id):
"""Возвращает последний опрос медиа-образа пользователя (с data) или None."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM brand_surveys WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
(user_id,)
)
row = cursor.fetchone()
conn.close()
if not row:
return None
result = dict(row)
result['data'] = _json.loads(result['data']) if result['data'] else {}
return result
def get_brand_survey_by_id(survey_id, user_id):
"""Возвращает один опрос (с data) только если он принадлежит user_id."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM brand_surveys WHERE id = ? AND user_id = ?',
(survey_id, user_id)
)
row = cursor.fetchone()
conn.close()
if not row:
return None
result = dict(row)
result['data'] = _json.loads(result['data']) if result['data'] else {}
return result
def delete_brand_survey(user_id, survey_id):
"""Удаляет опрос. Только если он принадлежит user_id."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'DELETE FROM brand_surveys WHERE id = ? AND user_id = ?',
(survey_id, user_id)
)
conn.commit()
conn.close()
# === КАНАЛЫ ДЛЯ ПУБЛИКАЦИИ (веб-кабинет) ===
def save_channel(user_id, chat_id, title, username, chat_type):
"""Сохраняет канал/чат для публикации. Возвращает id записи."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'INSERT INTO channels (user_id, chat_id, title, username, chat_type) VALUES (?, ?, ?, ?, ?)',
(user_id, str(chat_id), title, username, chat_type)
)
conn.commit()
channel_id = cursor.lastrowid
conn.close()
return channel_id
def get_channels_for_user(user_id):
"""Возвращает список каналов пользователя."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute(
'SELECT id, chat_id, title, username, chat_type, created_at '
'FROM channels WHERE user_id = ? ORDER BY created_at DESC',
(user_id,)
)
rows = [dict(row) for row in cursor.fetchall()]
conn.close()
return rows
def get_channel_by_id(channel_id, user_id):
"""Возвращает один канал только если он принадлежит user_id."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM channels WHERE id = ? AND user_id = ?',
(channel_id, user_id)
)
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
def delete_channel(user_id, channel_id):
"""Удаляет канал. Только если он принадлежит user_id."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'DELETE FROM channels WHERE id = ? AND user_id = ?',
(channel_id, user_id)
)
conn.commit()
conn.close()
def is_channel_already_added(user_id, chat_id):
"""Проверяет, добавлен ли уже канал с таким chat_id."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'SELECT id FROM channels WHERE user_id = ? AND chat_id = ?',
(user_id, str(chat_id))
)
row = cursor.fetchone()
conn.close()
return row is not None
# === ОТМЕТКА О ПУБЛИКАЦИИ ПОСТА ===
def mark_post_published(post_id, user_id, channel_title):
"""Отмечает пост как опубликованный."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'UPDATE posts SET published = 1, published_at = CURRENT_TIMESTAMP, '
'published_to = ? WHERE id = ? AND user_id = ?',
(channel_title, post_id, user_id)
)
conn.commit()
conn.close()
# === НАСТРОЙКИ ПРОВАЙДЕРА ИИ (веб-кабинет) ===
# Пресеты провайдеров
PROVIDER_PRESETS = {
'piapi': {
'name': 'PiAPI',
'base_url': 'https://api.piapi.ai/v1',
'text_model': 'gpt-4o',
'image_model': 'gpt-image-1',
'needs_key': True,
'supports_images': True,
},
'openrouter': {
'name': 'OpenRouter',
'base_url': 'https://openrouter.ai/api/v1',
'text_model': 'openai/gpt-4o',
'image_model': 'openai/dall-e-3',
'needs_key': True,
'supports_images': True,
},
'opencode': {
'name': 'OpenCode Zen',
'base_url': 'https://opencode.ai/zen/v1',
'text_model': 'nemotron-3-ultra-free',
'image_model': '',
'needs_key': True,
'supports_images': False,
},
'openai': {
'name': 'OpenAI',
'base_url': 'https://api.openai.com/v1',
'text_model': 'gpt-4o',
'image_model': 'gpt-image-1',
'needs_key': True,
'supports_images': True,
},
'custom': {
'name': 'Кастомный (OpenAI-совместимый)',
'base_url': '',
'text_model': '',
'image_model': '',
'needs_key': True,
'supports_images': True,
},
}
def get_provider_settings(user_id):
"""Возвращает настройки провайдеров пользователя (text + image) или None."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute('''
SELECT text_provider_type, text_api_key, text_base_url, text_model,
image_provider_type, image_api_key, image_base_url, image_model,
updated_at
FROM provider_settings WHERE user_id = ?
''', (user_id,))
row = cursor.fetchone()
conn.close()
if not row:
return None
d = dict(row)
return {
# Text provider
'text_provider_type': d['text_provider_type'],
'text_api_key': d['text_api_key'],
'text_base_url': d['text_base_url'],
'text_model': d['text_model'],
# Image provider
'image_provider_type': d['image_provider_type'],
'image_api_key': d['image_api_key'],
'image_base_url': d['image_base_url'],
'image_model': d['image_model'],
# Meta
'updated_at': d['updated_at'],
}
def get_text_provider_settings(user_id):
"""Возвращает настройки только text-провайдера в формате для ContentGenerator."""
s = get_provider_settings(user_id)
if not s or not s.get('text_api_key'):
return None
return {
'provider_type': s['text_provider_type'],
'api_key': s['text_api_key'],
'base_url': s['text_base_url'],
'text_model': s['text_model'],
}
def get_image_provider_settings(user_id):
"""Возвращает настройки только image-провайдера в формате для ImageGenerator."""
s = get_provider_settings(user_id)
if not s or not s.get('image_api_key'):
return None
return {
'provider_type': s['image_provider_type'],
'api_key': s['image_api_key'],
'base_url': s['image_base_url'],
'image_model': s['image_model'],
}
def save_provider_settings(user_id, text_provider_type, text_api_key, text_base_url, text_model,
image_provider_type, image_api_key, image_base_url, image_model):
"""Сохраняет или обновляет настройки обоих провайдеров."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute('''
INSERT INTO provider_settings (
user_id, text_provider_type, text_api_key, text_base_url, text_model,
image_provider_type, image_api_key, image_base_url, image_model, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
text_provider_type = excluded.text_provider_type,
text_api_key = excluded.text_api_key,
text_base_url = excluded.text_base_url,
text_model = excluded.text_model,
image_provider_type = excluded.image_provider_type,
image_api_key = excluded.image_api_key,
image_base_url = excluded.image_base_url,
image_model = excluded.image_model,
updated_at = CURRENT_TIMESTAMP
''', (user_id, text_provider_type, text_api_key, text_base_url, text_model,
image_provider_type, image_api_key, image_base_url, image_model))
conn.commit()
conn.close()
# === ИЗОБРАЖЕНИЯ ПОСТОВ (веб-кабинет) ===
def save_post_image(post_id, user_id, filename, source='upload'):
"""Сохраняет запись об изображении, прикреплённом к посту."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'INSERT INTO post_images (post_id, user_id, filename, source) VALUES (?, ?, ?, ?)',
(post_id, user_id, filename, source)
)
conn.commit()
img_id = cursor.lastrowid
conn.close()
return img_id
def get_post_images(post_id, user_id):
"""Возвращает все изображения поста (только если пост принадлежит user_id)."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute(
'SELECT id, filename, source, created_at FROM post_images '
'WHERE post_id = ? AND user_id = ? ORDER BY created_at ASC',
(post_id, user_id)
)
rows = [dict(row) for row in cursor.fetchall()]
conn.close()
return rows
def delete_post_image(image_id, user_id):
"""Удаляет изображение. Возвращает filename для удаления файла, или None."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'SELECT filename FROM post_images WHERE id = ? AND user_id = ?',
(image_id, user_id)
)
row = cursor.fetchone()
if row:
cursor.execute('DELETE FROM post_images WHERE id = ? AND user_id = ?', (image_id, user_id))
conn.commit()
conn.close()
return row[0]
conn.close()
return None
# === Задачи (background tasks) ===
def create_task(user_id, task_type):
"""Создаёт задачу. Возвращает task_id."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'INSERT INTO tasks (user_id, task_type, status) VALUES (?, ?, ?)',
(user_id, task_type, 'pending')
)
conn.commit()
task_id = cursor.lastrowid
conn.close()
return task_id
def update_task_status(task_id, status, result_json=None, error=None):
"""Обновляет статус задачи."""
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(
'UPDATE tasks SET status = ?, result_json = ?, error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(status, result_json, error, task_id)
)
conn.commit()
conn.close()
def get_task(task_id):
"""Возвращает задачу по ID."""
conn = _row_conn()
cursor = conn.cursor()
cursor.execute('SELECT * FROM tasks WHERE id = ?', (task_id,))
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
# Создаем базу при первом импорте
create_db()