diff --git a/bin/dispatch-social.php b/bin/dispatch-social.php new file mode 100755 index 0000000..aaa95e4 --- /dev/null +++ b/bin/dispatch-social.php @@ -0,0 +1,322 @@ +#!/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); +} diff --git a/sql/010_social_posts.sql b/sql/010_social_posts.sql new file mode 100644 index 0000000..3bb1d69 --- /dev/null +++ b/sql/010_social_posts.sql @@ -0,0 +1,24 @@ +-- Global social post scheduler (project-agnostic posts, per-target project refs) +CREATE TABLE IF NOT EXISTS social_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + image_path TEXT, + scheduled_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | done | partial | failed + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS social_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES social_posts(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL, + platform TEXT NOT NULL, -- discord | mastodon | linkedin + target_key TEXT NOT NULL, + include_image INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed + error TEXT, + sent_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_social_posts_status ON social_posts(status, scheduled_at); +CREATE INDEX IF NOT EXISTS idx_social_targets_post ON social_targets(post_id); diff --git a/views/_header.php b/views/_header.php index f360e72..0cd1807 100644 --- a/views/_header.php +++ b/views/_header.php @@ -33,6 +33,11 @@ $_page_title = isset($page_title) ? htmlspecialchars($page_title) . ' — ' : ' Audit +