HackmanCMS/web/api/social.php
Bashy ac5c262bfb Add Social Scheduler — global social media post scheduling
- New /social page with left/right layout (form + post list)
- Social topbar link
- Tables: social_posts, social_targets (project-agnostic posts, per-target project refs)
- CRUD: create, edit, duplicate, delete, retry
- Image upload (JPEG/PNG/GIF/WebP, 10MB max) stored in data/social-uploads/
- Per-platform image toggle (Discord multipart, Mastodon /api/v2/media, LinkedIn Assets API)
- bin/dispatch-social.php cron dispatcher (every minute via /etc/cron.d)
- Settings: eligible projects section to enable bot projects as social account sources
- Platforms: Discord (webhook), Mastodon, LinkedIn personal profile

Cron setup:
  * * * * * www-data /usr/bin/php /opt/hackmancms/bin/dispatch-social.php >> /var/log/hackman_social.log 2>&1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:01:03 +03:00

196 lines
9.4 KiB
PHP

<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = $method === 'POST' ? (json_decode(file_get_contents('php://input'), true) ?? []) : [];
function social_enabled_projects(PDO $db): array {
$row = $db->prepare("SELECT value FROM settings WHERE key = 'social_enabled_projects'");
$row->execute();
$val = $row->fetchColumn();
return $val ? (json_decode($val, true) ?? []) : [];
}
function social_project_targets(PDO $db): array {
$enabled = social_enabled_projects($db);
if (!$enabled) return [];
$placeholders = implode(',', array_fill(0, count($enabled), '?'));
$stmt = $db->prepare("SELECT id, name, path FROM projects WHERE id IN ($placeholders) AND is_active = 1");
$stmt->execute($enabled);
$projects = $stmt->fetchAll();
$result = [];
foreach ($projects as $proj) {
$config_path = realpath($proj['path']) . '/data/config.json';
if (!file_exists($config_path)) continue;
$config = json_decode(file_get_contents($config_path), true);
if (!is_array($config)) continue;
$targets = ['discord' => [], 'mastodon' => [], 'linkedin' => []];
foreach ($config['discord_channels'] ?? [] as $key => $ch) {
$targets['discord'][] = ['key' => $key, 'label' => $ch['label'] ?? $key];
}
foreach ($config['mastodon'] ?? [] as $key => $acc) {
$targets['mastodon'][] = ['key' => $key, 'label' => $acc['label'] ?? $key];
}
foreach ($config['linkedin']['pages'] ?? [] as $key => $page) {
if (($page['type'] ?? 'organization') === 'personal') {
$targets['linkedin'][] = ['key' => $key, 'label' => $page['label'] ?? $key];
}
}
if ($targets['discord'] || $targets['mastodon'] || $targets['linkedin']) {
$result[] = ['project_id' => (int)$proj['id'], 'project_name' => $proj['name'], 'targets' => $targets];
}
}
return $result;
}
// ── GET ───────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
$action = $_GET['action'] ?? 'list';
if ($action === 'targets') {
echo json_encode(['groups' => social_project_targets($db)]);
exit;
}
if ($action === 'settings') {
$enabled = social_enabled_projects($db);
$stmt = $db->query("SELECT id, name, type FROM projects WHERE is_active = 1 AND type = 'discord-bot' ORDER BY name");
$projects = $stmt->fetchAll();
echo json_encode(['enabled' => $enabled, 'projects' => $projects]);
exit;
}
// list posts
$status_filter = $_GET['status'] ?? 'pending';
if ($status_filter === 'all') {
$stmt = $db->prepare("SELECT * FROM social_posts ORDER BY scheduled_at DESC LIMIT 200");
$stmt->execute();
} else {
$stmt = $db->prepare("SELECT * FROM social_posts WHERE status IN ('pending','partial') ORDER BY scheduled_at ASC LIMIT 200");
$stmt->execute();
}
$posts = $stmt->fetchAll();
foreach ($posts as &$post) {
$tgt = $db->prepare("SELECT t.*, p.name AS project_name FROM social_targets t JOIN projects p ON p.id = t.project_id WHERE t.post_id = ? ORDER BY t.id");
$tgt->execute([$post['id']]);
$post['targets'] = $tgt->fetchAll();
$post['image_url'] = $post['image_path'] ? '/api/social_upload?action=serve&path=' . rawurlencode(basename($post['image_path'])) : null;
}
echo json_encode(['posts' => $posts]);
exit;
}
if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
$action = $input['action'] ?? '';
// ── SAVE SETTINGS ─────────────────────────────────────────────────────────────
if ($action === 'save_settings') {
$ids = array_map('intval', $input['project_ids'] ?? []);
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('social_enabled_projects', ?)")
->execute([json_encode($ids)]);
Audit::log($db, 'social_settings_save', null, implode(',', $ids));
echo json_encode(['ok' => true]);
exit;
}
// ── CREATE ────────────────────────────────────────────────────────────────────
if ($action === 'create') {
$content = trim($input['content'] ?? '');
$image_path = trim($input['image_path'] ?? '');
$scheduled_at = trim($input['scheduled_at'] ?? '');
$targets = $input['targets'] ?? [];
if (!$content) { echo json_encode(['error' => 'Content required']); exit; }
if (!$scheduled_at) { echo json_encode(['error' => 'Scheduled time required']); exit; }
if (!$targets) { echo json_encode(['error' => 'Select at least one platform target']); exit; }
$db->prepare("INSERT INTO social_posts (content, image_path, scheduled_at) VALUES (?, ?, ?)")
->execute([$content, $image_path ?: null, $scheduled_at]);
$post_id = (int)$db->lastInsertId();
$stmt = $db->prepare("INSERT INTO social_targets (post_id, project_id, platform, target_key, include_image) VALUES (?, ?, ?, ?, ?)");
foreach ($targets as $t) {
$stmt->execute([$post_id, (int)$t['project_id'], $t['platform'], $t['target_key'], (int)($t['include_image'] ?? 0)]);
}
Audit::log($db, 'social_post_create', null, "post_id=$post_id");
echo json_encode(['ok' => true, 'id' => $post_id]);
exit;
}
// ── UPDATE ────────────────────────────────────────────────────────────────────
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
$content = trim($input['content'] ?? '');
$image_path = trim($input['image_path'] ?? '');
$scheduled_at = trim($input['scheduled_at'] ?? '');
$targets = $input['targets'] ?? [];
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
if (!$content) { echo json_encode(['error' => 'Content required']); exit; }
if (!$scheduled_at) { echo json_encode(['error' => 'Scheduled time required']); exit; }
if (!$targets) { echo json_encode(['error' => 'Select at least one platform target']); exit; }
$db->prepare("UPDATE social_posts SET content=?, image_path=?, scheduled_at=?, status='pending' WHERE id=?")
->execute([$content, $image_path ?: null, $scheduled_at, $id]);
$db->prepare("DELETE FROM social_targets WHERE post_id=?")->execute([$id]);
$stmt = $db->prepare("INSERT INTO social_targets (post_id, project_id, platform, target_key, include_image) VALUES (?, ?, ?, ?, ?)");
foreach ($targets as $t) {
$stmt->execute([$id, (int)$t['project_id'], $t['platform'], $t['target_key'], (int)($t['include_image'] ?? 0)]);
}
Audit::log($db, 'social_post_update', null, "post_id=$id");
echo json_encode(['ok' => true]);
exit;
}
// ── DUPLICATE ─────────────────────────────────────────────────────────────────
if ($action === 'duplicate') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$post = $db->prepare("SELECT * FROM social_posts WHERE id=?")->execute([$id]) ? null : null;
$stmt = $db->prepare("SELECT * FROM social_posts WHERE id=?");
$stmt->execute([$id]);
$post = $stmt->fetch();
if (!$post) { echo json_encode(['error' => 'Post not found']); exit; }
$tgt = $db->prepare("SELECT * FROM social_targets WHERE post_id=?");
$tgt->execute([$id]);
$targets = $tgt->fetchAll();
echo json_encode([
'ok' => true,
'post' => ['content' => $post['content'], 'image_path' => $post['image_path'], 'image_url' => $post['image_path'] ? '/api/social_upload?action=serve&path=' . rawurlencode(basename($post['image_path'])) : null],
'targets' => $targets,
]);
exit;
}
// ── DELETE ────────────────────────────────────────────────────────────────────
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$db->prepare("DELETE FROM social_posts WHERE id=?")->execute([$id]);
Audit::log($db, 'social_post_delete', null, "post_id=$id");
echo json_encode(['ok' => true]);
exit;
}
// ── RETRY ─────────────────────────────────────────────────────────────────────
if ($action === 'retry') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$db->prepare("UPDATE social_targets SET status='pending', error=NULL WHERE post_id=? AND status='failed'")->execute([$id]);
$db->prepare("UPDATE social_posts SET status='pending' WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);