HackmanCMS/web/api/botcompose.php
Bashy 30b21b6214 Add scheduled social posts, Discord channels config, and LinkedIn personal posting fix
- Scheduled posts: new Schedule tab with multi-platform post scheduling (Discord,
  Mastodon, LinkedIn); SQLite queue with per-target delivery tracking and retry;
  cron script in bin/process-scheduled-posts.php processes due targets inline
- Discord channels: new config section for webhook-based channels used as scheduled
  post targets; webhook URLs stored in .env as DISCORD_WEBHOOK_<KEY>
- LinkedIn: fix botcompose_post to use member_id (urn:li:person) for personal
  profiles instead of hardcoded org author; filter org pages out of compose UI
  and RSS modal picker since org posting is unsupported; label org pages clearly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:14:03 +03:00

196 lines
8.2 KiB
PHP

<?php
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'POST required']); exit; }
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$pid = (int)($input['project_id'] ?? 0);
$action = $input['action'] ?? '';
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$pid]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
$config_path = $base . '/data/config.json';
function read_config(string $path): ?array {
if (!file_exists($path)) return null;
$data = json_decode(file_get_contents($path), true);
return is_array($data) ? $data : null;
}
function write_config(string $path, array $data): bool {
$tmp = $path . '.tmp';
$ok = file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;
if ($ok) $ok = rename($tmp, $path);
return (bool)$ok;
}
function read_dot_env(string $path): array {
if (!file_exists($path)) return [];
$env = [];
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) continue;
[$key, $val] = explode('=', $line, 2);
$env[trim($key)] = trim($val, " \t\"'");
}
return $env;
}
function curl_post(string $url, array $headers, string $body): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$status, $resp];
}
// ── RSS: clear last_id to trigger repost on next poll ─────────────────────────
if ($action === 'rss_repost') {
$feed_name = $input['feed_name'] ?? '';
if (!$feed_name) { echo json_encode(['error' => 'feed_name required']); exit; }
$config = read_config($config_path);
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
if (!isset($config['rss'][$feed_name])) { echo json_encode(['error' => 'Feed not found']); exit; }
$config['rss'][$feed_name]['last_id'] = '';
if (!write_config($config_path, $config)) {
echo json_encode(['error' => 'Failed to write config.json — check file permissions']); exit;
}
Audit::log($db, 'botcompose_rss_repost', $pid, $feed_name);
echo json_encode(['ok' => true, 'message' => 'Last ID cleared — bot will repost on next poll (within 5 min)']);
exit;
}
// ── Mastodon: post directly via API ───────────────────────────────────────────
if ($action === 'mastodon_post') {
$account_name = $input['account_name'] ?? '';
$text = trim($input['text'] ?? '');
if (!$account_name || !$text) { echo json_encode(['error' => 'account_name and text required']); exit; }
$config = read_config($config_path);
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
$account = $config['mastodon'][$account_name] ?? null;
if (!$account) { echo json_encode(['error' => "Mastodon account '$account_name' not in config"]); exit; }
$env = read_dot_env($base . '/.env');
$token = $env['MASTODON_TOKEN_' . strtoupper($account_name)] ?? '';
if (!$token) { echo json_encode(['error' => 'MASTODON_TOKEN_' . strtoupper($account_name) . ' not found in .env']); exit; }
$api_base = rtrim($account['api_base_url'] ?? '', '/');
[$status, $resp] = curl_post(
$api_base . '/api/v1/statuses',
['Authorization: Bearer ' . $token, 'Content-Type: application/x-www-form-urlencoded'],
http_build_query(['status' => $text])
);
if ($status !== 200) {
$err = json_decode($resp, true)['error'] ?? "HTTP $status";
echo json_encode(['error' => "Mastodon error: $err"]); exit;
}
Audit::log($db, 'botcompose_mastodon_post', $pid, $account_name);
echo json_encode(['ok' => true]);
exit;
}
// ── LinkedIn: post directly via API ──────────────────────────────────────────
if ($action === 'linkedin_post') {
$page_name = $input['page_name'] ?? '';
$text = trim($input['text'] ?? '');
if (!$page_name || !$text) { echo json_encode(['error' => 'page_name and text required']); exit; }
$config = read_config($config_path);
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
$page = $config['linkedin']['pages'][$page_name] ?? null;
$token = $config['linkedin']['access_token'] ?? null;
if (!$page) { echo json_encode(['error' => "LinkedIn page '$page_name' not in config"]); exit; }
if (!$token) { echo json_encode(['error' => 'LinkedIn not connected — use Connect LinkedIn first']); exit; }
$page_type = $page['type'] ?? 'organization';
if ($page_type === 'personal') {
$member_id = $config['linkedin']['member_id'] ?? null;
if (!$member_id) { echo json_encode(['error' => 'Personal posting requires member_id — re-run OAuth flow']); exit; }
$author = str_starts_with($member_id, 'urn:') ? $member_id : "urn:li:person:$member_id";
} else {
$org_id = $page['organization_id'] ?? '';
if (!$org_id) { echo json_encode(['error' => "Organization page '$page_name' has no organization_id"]); exit; }
$author = "urn:li:organization:$org_id";
}
$payload = json_encode([
'author' => $author,
'lifecycleState' => 'PUBLISHED',
'specificContent' => [
'com.linkedin.ugc.ShareContent' => [
'shareCommentary' => ['text' => $text],
'shareMediaCategory' => 'NONE',
],
],
'visibility' => ['com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC'],
]);
[$status, $resp] = curl_post(
'https://api.linkedin.com/v2/ugcPosts',
[
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'X-Restli-Protocol-Version: 2.0.0',
],
$payload
);
if ($status !== 200 && $status !== 201) {
$err = json_decode($resp, true)['message'] ?? "HTTP $status";
echo json_encode(['error' => "LinkedIn error: $err"]); exit;
}
Audit::log($db, 'botcompose_linkedin_post', $pid, $page_name);
echo json_encode(['ok' => true]);
exit;
}
// ── Discord: post via webhook ─────────────────────────────────────────────────
if ($action === 'discord_post') {
$channel_key = $input['channel_key'] ?? '';
$text = trim($input['text'] ?? '');
if (!$channel_key || !$text) { echo json_encode(['error' => 'channel_key and text required']); exit; }
$config = read_config($config_path);
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
$channel = $config['discord_channels'][$channel_key] ?? null;
if (!$channel) { echo json_encode(['error' => "Discord channel '$channel_key' not in config"]); exit; }
$env = read_dot_env($base . '/.env');
$webhook_url = $env['DISCORD_WEBHOOK_' . strtoupper($channel_key)] ?? '';
if (!$webhook_url) {
echo json_encode(['error' => 'DISCORD_WEBHOOK_' . strtoupper($channel_key) . ' not set in .env']); exit;
}
[$status, $resp] = curl_post($webhook_url, ['Content-Type: application/json'], json_encode(['content' => $text]));
if ($status !== 200 && $status !== 204) {
$err = json_decode($resp, true)['message'] ?? "HTTP $status";
echo json_encode(['error' => "Discord error: $err"]); exit;
}
Audit::log($db, 'botcompose_discord_post', $pid, $channel_key);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);