#!/usr/bin/env 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); }