- When LinkedIn is connected, replace the Connect button with a pencil (test post to first personal profile) and a reconnect arrow icon - Add Process Now button to the Schedule tab that shells out to the cron script on demand and shows its output inline - Add flock() to the cron script to prevent overlapping runs - Fix cron install path comment to match deployed location Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
278 lines
11 KiB
PHP
278 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Cron-driven scheduled social post processor.
|
|
*
|
|
* Install once on the host (as root, in /etc/cron.d/hackmancms):
|
|
* * * * * * www-data /usr/bin/php /var/www/html/hackmancms/bin/process-scheduled-posts.php >> /var/log/hackman_scheduled_posts.log 2>&1
|
|
*/
|
|
|
|
define('ROOT', dirname(__DIR__));
|
|
require ROOT . '/lib/bootstrap.php';
|
|
|
|
// Prevent overlapping runs
|
|
$_lock_fp = fopen(sys_get_temp_dir() . '/hackman_schedule.lock', 'c');
|
|
if (!$_lock_fp || !flock($_lock_fp, LOCK_EX | LOCK_NB)) exit(0);
|
|
|
|
$now = gmdate('Y-m-d H:i:s');
|
|
|
|
// Load all due pending targets grouped by post
|
|
$stmt = $db->prepare("
|
|
SELECT t.id AS target_id, t.post_id, t.platform, t.target,
|
|
p.content, p.url, p.project_id,
|
|
pr.path AS project_path
|
|
FROM scheduled_post_targets t
|
|
JOIN scheduled_posts p ON p.id = t.post_id
|
|
JOIN projects pr ON pr.id = p.project_id AND pr.is_active = 1
|
|
WHERE t.status = 'pending'
|
|
AND p.status IN ('pending', 'partial')
|
|
AND p.scheduled_at <= ?
|
|
ORDER BY p.scheduled_at ASC, t.id ASC
|
|
");
|
|
$stmt->execute([$now]);
|
|
$targets = $stmt->fetchAll();
|
|
|
|
if (!$targets) exit(0);
|
|
|
|
function log_msg(string $msg): void {
|
|
fwrite(STDOUT, '[' . gmdate('Y-m-d H:i:s') . '] ' . $msg . "\n");
|
|
}
|
|
|
|
function read_bot_config(string $base): array {
|
|
$path = $base . '/data/config.json';
|
|
if (!file_exists($path)) return [];
|
|
$data = json_decode(file_get_contents($path), true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
function write_bot_config(string $base, array $data): void {
|
|
$path = $base . '/data/config.json';
|
|
$tmp = $path . '.tmp';
|
|
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
rename($tmp, $path);
|
|
}
|
|
|
|
function read_dot_env(string $base): array {
|
|
$path = $base . '/.env';
|
|
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_json(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];
|
|
}
|
|
|
|
// Refresh LinkedIn token if near expiry; returns fresh token or null on failure
|
|
function ensure_linkedin_token(string $base, array &$config): ?string {
|
|
$li = $config['linkedin'] ?? [];
|
|
if (empty($li['access_token'])) return null;
|
|
|
|
$expiry_str = $li['token_expiry'] ?? null;
|
|
if ($expiry_str) {
|
|
try {
|
|
$expiry = new DateTimeImmutable($expiry_str, new DateTimeZone('UTC'));
|
|
if ($expiry <= new DateTimeImmutable('now', new DateTimeZone('UTC'))) {
|
|
log_msg('LinkedIn token expired — re-run OAuth');
|
|
return null;
|
|
}
|
|
// Threshold: 90 days before expiry → still valid, no refresh needed yet
|
|
$threshold = new DateTimeImmutable('+90 days', new DateTimeZone('UTC'));
|
|
if ($expiry > $threshold) return $li['access_token'];
|
|
} catch (Exception $e) {
|
|
log_msg('LinkedIn token_expiry parse error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Refresh
|
|
$env = read_dot_env($base);
|
|
$refresh_token = $li['refresh_token'] ?? null;
|
|
$client_id = $env['LINKEDIN_CLIENT_ID'] ?? null;
|
|
$client_secret = $env['LINKEDIN_CLIENT_SECRET'] ?? null;
|
|
if (!$refresh_token || !$client_id || !$client_secret) {
|
|
log_msg('LinkedIn refresh failed — missing credentials');
|
|
return null;
|
|
}
|
|
|
|
$ch = curl_init('https://www.linkedin.com/oauth/v2/accessToken');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query([
|
|
'grant_type' => 'refresh_token',
|
|
'refresh_token' => $refresh_token,
|
|
'client_id' => $client_id,
|
|
'client_secret' => $client_secret,
|
|
]),
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
$data = json_decode($resp, true);
|
|
if ($status !== 200 || empty($data['access_token'])) {
|
|
log_msg('LinkedIn refresh HTTP ' . $status);
|
|
return null;
|
|
}
|
|
|
|
$new_token = $data['access_token'];
|
|
$expires_in = $data['expires_in'] ?? 5184000;
|
|
$config['linkedin']['access_token'] = $new_token;
|
|
$config['linkedin']['token_expiry'] = gmdate('c', time() + $expires_in);
|
|
if (!empty($data['refresh_token'])) $config['linkedin']['refresh_token'] = $data['refresh_token'];
|
|
write_bot_config($base, $config);
|
|
return $new_token;
|
|
}
|
|
|
|
// Mark parent post done/partial based on target statuses
|
|
function update_post_status(PDO $db, int $post_id): void {
|
|
$row = $db->prepare("SELECT COUNT(*) AS total,
|
|
SUM(status = 'pending') AS pending,
|
|
SUM(status = 'failed') AS failed
|
|
FROM scheduled_post_targets WHERE post_id = ?");
|
|
$row->execute([$post_id]);
|
|
$counts = $row->fetch();
|
|
if ((int)$counts['pending'] > 0) return; // still in flight
|
|
$status = (int)$counts['failed'] > 0 ? 'partial' : 'done';
|
|
$db->prepare("UPDATE scheduled_posts SET status = ? WHERE id = ?")->execute([$status, $post_id]);
|
|
}
|
|
|
|
// Process each target
|
|
$project_configs = []; // cache per project_id
|
|
|
|
foreach ($targets as $t) {
|
|
$base = realpath($t['project_path']);
|
|
$pid = (int)$t['project_id'];
|
|
$target_id = (int)$t['target_id'];
|
|
$post_id = (int)$t['post_id'];
|
|
$platform = $t['platform'];
|
|
$target = $t['target'];
|
|
$content = $t['content'];
|
|
$url = $t['url'] ?? '';
|
|
$text = $url ? "$content\n$url" : $content;
|
|
|
|
log_msg("post_id=$post_id target_id=$target_id platform=$platform target=$target");
|
|
|
|
// Load config (cached per project)
|
|
if (!isset($project_configs[$pid])) {
|
|
$project_configs[$pid] = read_bot_config($base);
|
|
}
|
|
$config = &$project_configs[$pid];
|
|
|
|
$error = null;
|
|
|
|
if ($platform === 'discord') {
|
|
$channel = $config['discord_channels'][$target] ?? null;
|
|
if (!$channel) { $error = "Discord channel '$target' not in config"; }
|
|
else {
|
|
$env = read_dot_env($base);
|
|
$webhook_url = $env['DISCORD_WEBHOOK_' . strtoupper($target)] ?? '';
|
|
if (!$webhook_url) {
|
|
$error = 'DISCORD_WEBHOOK_' . strtoupper($target) . ' not set in .env';
|
|
} else {
|
|
[$status, $resp] = curl_post_json($webhook_url, ['Content-Type: application/json'], json_encode(['content' => $text]));
|
|
if ($status !== 200 && $status !== 204) {
|
|
$error = 'Discord HTTP ' . $status . ': ' . (json_decode($resp, true)['message'] ?? $resp);
|
|
}
|
|
}
|
|
}
|
|
|
|
} elseif ($platform === 'mastodon') {
|
|
$account = $config['mastodon'][$target] ?? null;
|
|
if (!$account) { $error = "Mastodon account '$target' not in config"; }
|
|
else {
|
|
$env = read_dot_env($base);
|
|
$token = $env['MASTODON_TOKEN_' . strtoupper($target)] ?? '';
|
|
$api_base = rtrim($account['api_base_url'] ?? '', '/');
|
|
if (!$token) { $error = 'MASTODON_TOKEN_' . strtoupper($target) . ' not set'; }
|
|
elseif (!$api_base) { $error = "Mastodon account '$target' has no api_base_url"; }
|
|
else {
|
|
[$status, $resp] = curl_post_json(
|
|
$api_base . '/api/v1/statuses',
|
|
['Authorization: Bearer ' . $token, 'Content-Type: application/x-www-form-urlencoded'],
|
|
http_build_query(['status' => $text])
|
|
);
|
|
if ($status !== 200) {
|
|
$error = 'Mastodon HTTP ' . $status . ': ' . (json_decode($resp, true)['error'] ?? $resp);
|
|
}
|
|
}
|
|
}
|
|
|
|
} elseif ($platform === 'linkedin') {
|
|
$page = $config['linkedin']['pages'][$target] ?? null;
|
|
if (!$page) { $error = "LinkedIn page '$target' not in config"; }
|
|
else {
|
|
$token = ensure_linkedin_token($base, $config);
|
|
if (!$token) { $error = 'LinkedIn token unavailable — re-run OAuth'; }
|
|
else {
|
|
$page_type = $page['type'] ?? 'organization';
|
|
if ($page_type === 'personal') {
|
|
$member_id = $config['linkedin']['member_id'] ?? null;
|
|
if (!$member_id) { $error = 'member_id missing — re-run OAuth'; }
|
|
else {
|
|
$author = str_starts_with($member_id, 'urn:') ? $member_id : "urn:li:person:$member_id";
|
|
}
|
|
} else {
|
|
$org_id = $page['organization_id'] ?? '';
|
|
if (!$org_id) { $error = "Organization page '$target' has no organization_id"; }
|
|
else { $author = "urn:li:organization:$org_id"; }
|
|
}
|
|
if (!$error) {
|
|
$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_json(
|
|
'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) {
|
|
$error = 'LinkedIn HTTP ' . $status . ': ' . (json_decode($resp, true)['message'] ?? $resp);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$error = "Unknown platform '$platform'";
|
|
}
|
|
|
|
if ($error) {
|
|
log_msg(" FAILED: $error");
|
|
$db->prepare("UPDATE scheduled_post_targets SET status = 'failed', error = ? WHERE id = ?")
|
|
->execute([$error, $target_id]);
|
|
} else {
|
|
log_msg(" OK");
|
|
$db->prepare("UPDATE scheduled_post_targets SET status = 'sent', sent_at = ? WHERE id = ?")
|
|
->execute([gmdate('Y-m-d H:i:s'), $target_id]);
|
|
}
|
|
|
|
update_post_status($db, $post_id);
|
|
}
|