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>
This commit is contained in:
parent
45b9e15152
commit
ac5c262bfb
9 changed files with 1081 additions and 0 deletions
322
bin/dispatch-social.php
Executable file
322
bin/dispatch-social.php
Executable file
|
|
@ -0,0 +1,322 @@
|
|||
#!/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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue