HackmanCMS/bin/dispatch-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

322 lines
14 KiB
PHP
Executable file

#!/usr/bin/env php
<?php
/**
* Social post dispatcher — runs every minute via cron.
*
* /etc/cron.d/hackmancms-social:
* * * * * * www-data /usr/bin/php /opt/hackmancms/bin/dispatch-social.php >> /var/log/hackman_social.log 2>&1
*/
define('ROOT', dirname(__DIR__));
require ROOT . '/lib/bootstrap.php';
// Prevent overlapping runs
$lock = fopen(sys_get_temp_dir() . '/hackman_social.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) exit(0);
$now = gmdate('Y-m-d H:i:s');
$stmt = $db->prepare("
SELECT t.id AS target_id, t.post_id, t.project_id, t.platform, t.target_key, t.include_image,
p.content, p.image_path,
pr.path AS project_path
FROM social_targets t
JOIN social_posts p ON p.id = t.post_id
JOIN projects pr ON pr.id = t.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 {
echo '[' . gmdate('Y-m-d H:i:s') . '] ' . $msg . "\n";
}
function read_config(string $base): array {
$path = $base . '/data/config.json';
if (!file_exists($path)) return [];
$d = json_decode(file_get_contents($path), true);
return is_array($d) ? $d : [];
}
function write_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_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 || $line[0] === '#' || !str_contains($line, '=')) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim($v, " \t\"'");
}
return $env;
}
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 social_targets WHERE post_id=?");
$row->execute([$post_id]);
$c = $row->fetch();
if ((int)$c['pending'] > 0) return;
$status = (int)$c['failed'] > 0 ? 'partial' : 'done';
$db->prepare("UPDATE social_posts SET status=? WHERE id=?")->execute([$status, $post_id]);
}
// LinkedIn token refresh
function ensure_li_token(string $base, array &$config): ?string {
$li = $config['linkedin'] ?? [];
if (empty($li['access_token'])) return null;
if (!empty($li['token_expiry'])) {
try {
$exp = new DateTimeImmutable($li['token_expiry'], new DateTimeZone('UTC'));
if ($exp <= new DateTimeImmutable('now', new DateTimeZone('UTC'))) { log_msg('LinkedIn token expired'); return null; }
} catch (Exception) {}
}
return $li['access_token'];
}
// Upload image to Mastodon, return media_id or null
function mastodon_upload_image(string $api_base, string $token, string $image_path): ?string {
if (!file_exists($image_path)) return null;
$ch = curl_init($api_base . '/api/v2/media');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => ['file' => new CURLFile($image_path)],
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
]);
$resp = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200 || $status === 202) return $resp['id'] ?? null;
log_msg(' Mastodon media upload HTTP ' . $status);
return null;
}
// Register + upload LinkedIn image, return asset URN or null
function linkedin_upload_image(string $token, string $author, string $image_path): ?string {
if (!file_exists($image_path)) return null;
// Step 1: register upload
$payload = json_encode([
'registerUploadRequest' => [
'recipes' => ['urn:li:digitalmediaRecipe:feedshare-image'],
'owner' => $author,
'serviceRelationships' => [['relationshipType' => 'OWNER', 'identifier' => 'urn:li:userGeneratedContent']],
],
]);
$ch = curl_init('https://api.linkedin.com/v2/assets?action=registerUpload');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json', 'X-Restli-Protocol-Version: 2.0.0'],
]);
$resp = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) { log_msg(' LinkedIn registerUpload HTTP ' . $status); return null; }
$upload_url = $resp['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'] ?? null;
$asset_urn = $resp['value']['asset'] ?? null;
if (!$upload_url || !$asset_urn) { log_msg(' LinkedIn registerUpload missing uploadUrl/asset'); return null; }
// Step 2: PUT binary
$ch = curl_init($upload_url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => file_get_contents($image_path),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: ' . (mime_content_type($image_path) ?: 'image/jpeg')],
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) { log_msg(' LinkedIn image PUT HTTP ' . $status); return null; }
return $asset_urn;
}
// Discord multipart post (with optional file attachment)
function discord_post(string $webhook_url, string $text, ?string $image_path): array {
$boundary = '----FormBoundary' . bin2hex(random_bytes(8));
$body = "--$boundary\r\n";
$body .= "Content-Disposition: form-data; name=\"payload_json\"\r\n\r\n";
$body .= json_encode(['content' => $text]) . "\r\n";
if ($image_path && file_exists($image_path)) {
$body .= "--$boundary\r\n";
$body .= 'Content-Disposition: form-data; name="files[0]"; filename="' . basename($image_path) . '"' . "\r\n";
$body .= 'Content-Type: ' . (mime_content_type($image_path) ?: 'image/jpeg') . "\r\n\r\n";
$body .= file_get_contents($image_path) . "\r\n";
}
$body .= "--$boundary--\r\n";
$ch = curl_init($webhook_url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: multipart/form-data; boundary=' . $boundary],
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$status, $resp];
}
$configs = [];
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_key = $t['target_key'];
$content = $t['content'];
$with_image = (int)$t['include_image'] === 1 && $t['image_path'];
$image_path = $with_image ? ROOT . '/data/social-uploads/' . basename($t['image_path']) : null;
log_msg("post=$post_id target=$target_id platform=$platform key=$target_key" . ($image_path ? ' +image' : ''));
if (!isset($configs[$pid])) $configs[$pid] = read_config($base);
$config = &$configs[$pid];
$error = null;
if ($platform === 'discord') {
$channel = $config['discord_channels'][$target_key] ?? null;
if (!$channel) {
$error = "Discord channel '$target_key' not in config";
} else {
$env = read_env($base);
$webhook_url = $env['DISCORD_WEBHOOK_' . strtoupper($target_key)] ?? '';
if (!$webhook_url) {
$error = 'DISCORD_WEBHOOK_' . strtoupper($target_key) . ' not set in .env';
} else {
[$status, $resp] = discord_post($webhook_url, $content, $image_path);
if ($status !== 200 && $status !== 204) {
$error = 'Discord HTTP ' . $status . ': ' . (json_decode($resp, true)['message'] ?? substr($resp, 0, 200));
}
}
}
} elseif ($platform === 'mastodon') {
$account = $config['mastodon'][$target_key] ?? null;
if (!$account) {
$error = "Mastodon account '$target_key' not in config";
} else {
$env = read_env($base);
$token = $env['MASTODON_TOKEN_' . strtoupper($target_key)] ?? '';
$api_base = rtrim($account['api_base_url'] ?? '', '/');
if (!$token || !$api_base) {
$error = "Mastodon '$target_key' missing token or api_base_url";
} else {
$media_ids = [];
if ($image_path) {
$mid = mastodon_upload_image($api_base, $token, $image_path);
if ($mid) $media_ids[] = $mid;
}
$params = ['status' => $content];
if ($media_ids) $params['media_ids[]'] = $media_ids[0];
$ch = curl_init($api_base . '/api/v1/statuses');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/x-www-form-urlencoded'],
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
$error = 'Mastodon HTTP ' . $status . ': ' . (json_decode($resp, true)['error'] ?? substr($resp, 0, 200));
}
}
}
} elseif ($platform === 'linkedin') {
$page = $config['linkedin']['pages'][$target_key] ?? null;
if (!$page) {
$error = "LinkedIn page '$target_key' not in config";
} else {
$token = ensure_li_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_key' has no organization_id"; }
else { $author = "urn:li:organization:$org_id"; }
}
if (!$error) {
$asset_urn = null;
if ($image_path) $asset_urn = linkedin_upload_image($token, $author, $image_path);
if ($asset_urn) {
$share_content = [
'shareCommentary' => ['text' => $content],
'shareMediaCategory' => 'IMAGE',
'media' => [['status' => 'READY', 'media' => $asset_urn]],
];
} else {
$share_content = [
'shareCommentary' => ['text' => $content],
'shareMediaCategory' => 'NONE',
];
}
$payload = json_encode([
'author' => $author,
'lifecycleState' => 'PUBLISHED',
'specificContent' => ['com.linkedin.ugc.ShareContent' => $share_content],
'visibility' => ['com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC'],
]);
$ch = curl_init('https://api.linkedin.com/v2/ugcPosts');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json', 'X-Restli-Protocol-Version: 2.0.0'],
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200 && $status !== 201) {
$error = 'LinkedIn HTTP ' . $status . ': ' . (json_decode($resp, true)['message'] ?? substr($resp, 0, 200));
}
}
}
}
} else {
$error = "Unknown platform '$platform'";
}
if ($error) {
log_msg(" FAILED: $error");
$db->prepare("UPDATE social_targets SET status='failed', error=? WHERE id=?")->execute([$error, $target_id]);
} else {
log_msg(" OK");
$db->prepare("UPDATE social_targets SET status='sent', sent_at=? WHERE id=?")->execute([$now, $target_id]);
}
update_post_status($db, $post_id);
}