Add scheduled social posts, Discord channels config, and LinkedIn personal posting fix

- Scheduled posts: new Schedule tab with multi-platform post scheduling (Discord,
  Mastodon, LinkedIn); SQLite queue with per-target delivery tracking and retry;
  cron script in bin/process-scheduled-posts.php processes due targets inline
- Discord channels: new config section for webhook-based channels used as scheduled
  post targets; webhook URLs stored in .env as DISCORD_WEBHOOK_<KEY>
- LinkedIn: fix botcompose_post to use member_id (urn:li:person) for personal
  profiles instead of hardcoded org author; filter org pages out of compose UI
  and RSS modal picker since org posting is unsupported; label org pages clearly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bashy 2026-05-29 15:14:03 +03:00
parent 09e3406847
commit 30b21b6214
9 changed files with 848 additions and 14 deletions

View file

@ -0,0 +1,274 @@
<?php
/**
* Cron-driven scheduled social post processor.
*
* Install once on the host (as the apache user):
* * * * * * /usr/bin/php /opt/hackmancms/bin/process-scheduled-posts.php >> /var/log/hackman_scheduled_posts.log 2>&1
*/
define('ROOT', dirname(__DIR__));
require ROOT . '/lib/bootstrap.php';
$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);
}

View file

@ -0,0 +1,26 @@
-- Scheduled social posts campaign (one row = one scheduled send)
CREATE TABLE IF NOT EXISTS scheduled_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
content TEXT NOT NULL,
url TEXT,
scheduled_at DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | done | partial
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Per-platform delivery target (one row per platform × target per post)
CREATE TABLE IF NOT EXISTS scheduled_post_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES scheduled_posts(id) ON DELETE CASCADE,
platform TEXT NOT NULL, -- discord | mastodon | linkedin
target TEXT NOT NULL, -- channel key (discord) | account key (mastodon) | page key (linkedin)
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed
error TEXT,
sent_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_project_status
ON scheduled_posts(project_id, status, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_scheduled_post_targets_post
ON scheduled_post_targets(post_id, status);

View file

@ -31,8 +31,21 @@
<hr>
<!-- LinkedIn Pages -->
<h6 class="mb-3 mt-3"><i class="bi bi-linkedin me-1 text-primary"></i>LinkedIn Pages</h6>
<!-- Discord Channels (for scheduled posts) -->
<h6 class="mb-1 mt-3"><i class="bi bi-discord me-1 text-primary"></i>Discord Channels</h6>
<p class="text-muted small mb-3">
Add the webhook URL to <code>.env</code> as <code>DISCORD_WEBHOOK_&lt;KEY&gt;</code> (uppercase).
These channels are available as targets for scheduled posts.
</p>
<div id="discordChannelList" class="mb-2"><div class="text-muted small">Loading…</div></div>
<button class="btn btn-sm btn-outline-primary mb-4" id="addDiscordChannelBtn">
<i class="bi bi-plus-lg me-1"></i>Add Channel
</button>
<hr>
<!-- LinkedIn -->
<h6 class="mb-3 mt-3"><i class="bi bi-linkedin me-1 text-primary"></i>LinkedIn</h6>
<div class="card mb-3">
<div class="card-body py-2 d-flex align-items-center gap-3">
@ -202,6 +215,36 @@
</div>
</div>
<!-- Discord channel modal -->
<div class="modal fade" id="discordChannelModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="discordChannelModalTitle">Add Discord Channel</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="discordChannelModalMode">
<div class="mb-2">
<label class="form-label small">Channel key</label>
<input type="text" id="discordChannelName" class="form-control form-control-sm font-monospace"
placeholder="general" autocomplete="off">
<div class="form-text small">Used as <code>DISCORD_WEBHOOK_&lt;KEY&gt;</code> in .env (uppercase).</div>
</div>
<div class="mb-2">
<label class="form-label small">Label</label>
<input type="text" id="discordChannelLabel" class="form-control form-control-sm" placeholder="General">
</div>
</div>
<div class="modal-footer py-2">
<div class="text-danger small d-none flex-grow-1" id="discordChannelModalError"></div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="discordChannelModalSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- LinkedIn page modal -->
<div class="modal fade" id="linkedinPageModal" tabindex="-1">
<div class="modal-dialog modal-sm">

View file

@ -0,0 +1,80 @@
<div id="botSchedulePanel" data-project-id="<?= $pid ?>">
<!-- Create form -->
<div class="card mb-4">
<div class="card-header py-2 small fw-semibold"><i class="bi bi-clock me-1"></i>Schedule a Post</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Content</label>
<textarea id="schedContent" rows="4" class="form-control form-control-sm font-monospace"
placeholder="What's on your mind?"></textarea>
</div>
<div class="mb-3">
<label class="form-label small">URL <span class="text-muted">(optional appended to content)</span></label>
<input type="url" id="schedUrl" class="form-control form-control-sm font-monospace"
placeholder="https://example.com/post">
</div>
<div class="mb-3">
<label class="form-label small">Send at</label>
<input type="datetime-local" id="schedAt" class="form-control form-control-sm" style="max-width:240px">
</div>
<label class="form-label small">Platforms</label>
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="schedUseDiscord">
<label class="form-check-label small fw-semibold" for="schedUseDiscord">
<i class="bi bi-discord me-1 text-primary"></i>Discord
</label>
</div>
<div id="schedDiscordPicker" class="d-none ms-3 mt-1">
<div id="schedDiscordChannels" class="d-flex flex-wrap gap-2"></div>
<div class="text-muted small d-none" id="schedDiscordEmpty">
No Discord channels configured add them in the Config tab.
</div>
</div>
</div>
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="schedUseMastodon">
<label class="form-check-label small fw-semibold" for="schedUseMastodon">
<i class="bi bi-mastodon me-1 text-primary"></i>Mastodon
</label>
</div>
<div id="schedMastodonPicker" class="d-none ms-3 mt-1">
<div id="schedMastodonAccounts" class="d-flex flex-wrap gap-2"></div>
<div class="text-muted small d-none" id="schedMastodonEmpty">
No Mastodon accounts configured add them in the Config tab.
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="schedUseLinkedin">
<label class="form-check-label small fw-semibold" for="schedUseLinkedin">
<i class="bi bi-linkedin me-1 text-primary"></i>LinkedIn
</label>
</div>
<div id="schedLinkedinPicker" class="d-none ms-3 mt-1">
<div id="schedLinkedinProfiles" class="d-flex flex-wrap gap-2"></div>
<div class="text-muted small d-none" id="schedLinkedinEmpty">
No LinkedIn profiles connected connect your account in the Config tab.
</div>
</div>
</div>
<div class="text-danger small d-none mb-2" id="schedError"></div>
<button class="btn btn-primary btn-sm" id="schedSubmitBtn">
<i class="bi bi-calendar-check me-1"></i>Schedule Post
</button>
</div>
</div>
<!-- Post list -->
<h6 class="mb-3">Scheduled &amp; Recent Posts</h6>
<div id="schedPostsList"><div class="text-muted small">Loading…</div></div>
</div>

View file

@ -40,7 +40,7 @@ $tabLabels = [
'plugins' => 'Plugins', 'git' => 'Git',
'notes' => 'Notes', 'settings' => 'Settings',
'bot' => 'Bot', 'botconfig' => 'Config',
'logs' => 'Logs',
'botschedule' => 'Schedule', 'logs' => 'Logs',
];
$tabIcons = [
'dashboard' => 'bi-grid-1x2', 'analytics' => 'bi-graph-up',
@ -50,11 +50,11 @@ $tabIcons = [
'plugins' => 'bi-puzzle', 'git' => 'bi-git',
'notes' => 'bi-sticky', 'settings' => 'bi-gear',
'bot' => 'bi-robot', 'botconfig' => 'bi-sliders2',
'logs' => 'bi-journal-text',
'botschedule' => 'bi-calendar-event', 'logs' => 'bi-journal-text',
];
$tabGroups = [
['dashboard', 'analytics'],
['bot', 'botconfig', 'logs'],
['bot', 'botconfig', 'botschedule', 'logs'],
['posts', 'config', 'files', 'media'],
['run', 'themes', 'plugins', 'git'],
['notes', 'settings'],
@ -267,6 +267,9 @@ $tabGroups = [
<?php elseif ($tab === 'botconfig'): ?>
<?php include ROOT . '/views/project/_tab_botconfig.php'; ?>
<?php elseif ($tab === 'botschedule'): ?>
<?php include ROOT . '/views/project/_tab_botschedule.php'; ?>
<?php elseif ($tab === 'logs'): ?>
<?php include ROOT . '/views/project/_tab_logs.php'; ?>

View file

@ -120,9 +120,19 @@ if ($action === 'linkedin_post') {
if (!$page) { echo json_encode(['error' => "LinkedIn page '$page_name' not in config"]); exit; }
if (!$token) { echo json_encode(['error' => 'LinkedIn not connected — use Connect LinkedIn first']); exit; }
$org_id = $page['organization_id'] ?? '';
$page_type = $page['type'] ?? 'organization';
if ($page_type === 'personal') {
$member_id = $config['linkedin']['member_id'] ?? null;
if (!$member_id) { echo json_encode(['error' => 'Personal posting requires member_id — re-run OAuth flow']); exit; }
$author = str_starts_with($member_id, 'urn:') ? $member_id : "urn:li:person:$member_id";
} else {
$org_id = $page['organization_id'] ?? '';
if (!$org_id) { echo json_encode(['error' => "Organization page '$page_name' has no organization_id"]); exit; }
$author = "urn:li:organization:$org_id";
}
$payload = json_encode([
'author' => "urn:li:organization:$org_id",
'author' => $author,
'lifecycleState' => 'PUBLISHED',
'specificContent' => [
'com.linkedin.ugc.ShareContent' => [
@ -153,5 +163,34 @@ if ($action === 'linkedin_post') {
exit;
}
// ── Discord: post via webhook ─────────────────────────────────────────────────
if ($action === 'discord_post') {
$channel_key = $input['channel_key'] ?? '';
$text = trim($input['text'] ?? '');
if (!$channel_key || !$text) { echo json_encode(['error' => 'channel_key and text required']); exit; }
$config = read_config($config_path);
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
$channel = $config['discord_channels'][$channel_key] ?? null;
if (!$channel) { echo json_encode(['error' => "Discord channel '$channel_key' not in config"]); exit; }
$env = read_dot_env($base . '/.env');
$webhook_url = $env['DISCORD_WEBHOOK_' . strtoupper($channel_key)] ?? '';
if (!$webhook_url) {
echo json_encode(['error' => 'DISCORD_WEBHOOK_' . strtoupper($channel_key) . ' not set in .env']); exit;
}
[$status, $resp] = curl_post($webhook_url, ['Content-Type: application/json'], json_encode(['content' => $text]));
if ($status !== 200 && $status !== 204) {
$err = json_decode($resp, true)['message'] ?? "HTTP $status";
echo json_encode(['error' => "Discord error: $err"]); exit;
}
Audit::log($db, 'botcompose_discord_post', $pid, $channel_key);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);

View file

@ -159,6 +159,37 @@ if ($action === 'remove_mastodon') {
exit;
}
// ── ADD / SAVE / REMOVE DISCORD CHANNEL ──────────────────────────────────────
if ($action === 'add_discord_channel' || $action === 'save_discord_channel') {
$name = trim($input['name'] ?? '');
$data = $input['data'] ?? [];
if (!$name) { echo json_encode(['error' => 'Channel key required']); exit; }
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
echo json_encode(['error' => 'Channel key must be alphanumeric/underscore (used as env var suffix)']); exit;
}
if ($action === 'add_discord_channel' && isset($config['discord_channels'][$name])) {
echo json_encode(['error' => 'Channel already exists']); exit;
}
if (!isset($config['discord_channels'])) $config['discord_channels'] = [];
$config['discord_channels'][$name] = ['label' => trim($data['label'] ?? $name)];
write_config($config_path, $config);
Audit::log($db, 'botconfig_discord_channel_' . ($action === 'add_discord_channel' ? 'add' : 'save'), $pid, $name);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'remove_discord_channel') {
$name = trim($input['name'] ?? '');
if (!$name || !isset($config['discord_channels'][$name])) {
echo json_encode(['error' => 'Channel not found']); exit;
}
unset($config['discord_channels'][$name]);
write_config($config_path, $config);
Audit::log($db, 'botconfig_discord_channel_remove', $pid, $name);
echo json_encode(['ok' => true]);
exit;
}
// ── ADD / SAVE / REMOVE LINKEDIN PAGE ────────────────────────────────────────
if ($action === 'add_linkedin_page' || $action === 'save_linkedin_page') {
$name = trim($input['name'] ?? '');

131
web/api/post_schedule.php Normal file
View file

@ -0,0 +1,131 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = $method === 'POST' ? (json_decode(file_get_contents('php://input'), true) ?? []) : [];
$pid = (int)(($method === 'GET' ? $_GET : $input)['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$pid]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
$config_path = $base . '/data/config.json';
function ps_read_config(string $path): array {
if (!file_exists($path)) return [];
$data = json_decode(file_get_contents($path), true);
return is_array($data) ? $data : [];
}
// ── GET ───────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
$action = $_GET['action'] ?? 'list';
// Return available targets for the schedule form
if ($action === 'targets') {
$config = ps_read_config($config_path);
$discord = [];
$mastodon = [];
$linkedin = [];
foreach ($config['discord_channels'] ?? [] as $key => $ch) {
$discord[] = ['key' => $key, 'label' => $ch['label'] ?? $key];
}
foreach ($config['mastodon'] ?? [] as $key => $acc) {
$mastodon[] = ['key' => $key, 'label' => $acc['label'] ?? $key];
}
foreach ($config['linkedin']['pages'] ?? [] as $key => $page) {
// Only personal profiles can post — org pages not supported by LinkedIn API
if (($page['type'] ?? 'organization') === 'personal') {
$linkedin[] = ['key' => $key, 'label' => $page['label'] ?? $key];
}
}
echo json_encode(['discord' => $discord, 'mastodon' => $mastodon, 'linkedin' => $linkedin]);
exit;
}
// List posts with their targets
$rows = $db->prepare(
'SELECT * FROM scheduled_posts WHERE project_id = ? ORDER BY scheduled_at DESC LIMIT 200'
);
$rows->execute([$pid]);
$posts = $rows->fetchAll();
foreach ($posts as &$post) {
$tgt = $db->prepare('SELECT * FROM scheduled_post_targets WHERE post_id = ? ORDER BY id');
$tgt->execute([$post['id']]);
$post['targets'] = $tgt->fetchAll();
}
echo json_encode(['posts' => $posts]);
exit;
}
if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
$action = $input['action'] ?? '';
// ── CREATE ────────────────────────────────────────────────────────────────────
if ($action === 'create') {
$content = trim($input['content'] ?? '');
$url = trim($input['url'] ?? '');
$scheduled_at = trim($input['scheduled_at'] ?? '');
$targets = $input['targets'] ?? [];
if (!$content) { echo json_encode(['error' => 'Content required']); exit; }
if (!$scheduled_at) { echo json_encode(['error' => 'Scheduled time required']); exit; }
if (!$targets) { echo json_encode(['error' => 'At least one target required']); exit; }
$db->prepare(
'INSERT INTO scheduled_posts (project_id, content, url, scheduled_at) VALUES (?, ?, ?, ?)'
)->execute([$pid, $content, $url ?: null, $scheduled_at]);
$post_id = (int)$db->lastInsertId();
$stmt = $db->prepare('INSERT INTO scheduled_post_targets (post_id, platform, target) VALUES (?, ?, ?)');
foreach ($targets as $t) {
$platform = $t['platform'] ?? '';
$target = $t['target'] ?? '';
if (!in_array($platform, ['discord', 'mastodon', 'linkedin'], true) || !$target) continue;
$stmt->execute([$post_id, $platform, $target]);
}
Audit::log($db, 'scheduled_post_create', $pid, "post_id=$post_id");
echo json_encode(['ok' => true, 'id' => $post_id]);
exit;
}
// ── DELETE ────────────────────────────────────────────────────────────────────
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$db->prepare('DELETE FROM scheduled_posts WHERE id = ? AND project_id = ?')->execute([$id, $pid]);
Audit::log($db, 'scheduled_post_delete', $pid, "post_id=$id");
echo json_encode(['ok' => true]);
exit;
}
// ── RETRY FAILED TARGET ───────────────────────────────────────────────────────
if ($action === 'retry_target') {
$target_id = (int)($input['target_id'] ?? 0);
if (!$target_id) { echo json_encode(['error' => 'target_id required']); exit; }
// Verify target belongs to this project
$row = $db->prepare(
'SELECT t.post_id FROM scheduled_post_targets t
JOIN scheduled_posts p ON p.id = t.post_id
WHERE t.id = ? AND p.project_id = ?'
);
$row->execute([$target_id, $pid]);
$r = $row->fetch();
if (!$r) { echo json_encode(['error' => 'Target not found']); exit; }
$db->prepare("UPDATE scheduled_post_targets SET status = 'pending', error = NULL WHERE id = ?")
->execute([$target_id]);
$db->prepare("UPDATE scheduled_posts SET status = 'pending' WHERE id = ? AND status IN ('done', 'partial')")
->execute([$r['post_id']]);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);

View file

@ -2364,10 +2364,15 @@ if (addScanPathForm) {
botconfig_mastodon_remove: 'mastodon removed',
botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated',
botconfig_linkedin_page_remove: 'linkedin page removed',
botconfig_discord_channel_add: 'discord channel added', botconfig_discord_channel_save: 'discord channel updated',
botconfig_discord_channel_remove: 'discord channel removed',
linkedin_oauth_connect: 'linkedin connected',
botcompose_rss_repost: 'rss repost queued',
botcompose_mastodon_post: 'mastodon posted',
botcompose_linkedin_post: 'linkedin posted',
botcompose_discord_post: 'discord posted',
scheduled_post_create: 'post scheduled',
scheduled_post_delete: 'scheduled post deleted',
file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload',
post_create: 'post created', post_delete: 'post deleted',
post_publish: 'post published', post_duplicate: 'post duplicated',
@ -2403,10 +2408,14 @@ if (addScanPathForm) {
botconfig_rss_add: 'bi-rss', botconfig_rss_save: 'bi-rss', botconfig_rss_remove: 'bi-rss',
botconfig_mastodon_add: 'bi-mastodon', botconfig_mastodon_save: 'bi-mastodon', botconfig_mastodon_remove: 'bi-mastodon',
botconfig_linkedin_page_add: 'bi-linkedin', botconfig_linkedin_page_save: 'bi-linkedin', botconfig_linkedin_page_remove: 'bi-linkedin',
botconfig_discord_channel_add: 'bi-discord', botconfig_discord_channel_save: 'bi-discord', botconfig_discord_channel_remove: 'bi-discord',
linkedin_oauth_connect: 'bi-linkedin',
botcompose_rss_repost: 'bi-arrow-repeat',
botcompose_mastodon_post: 'bi-mastodon',
botcompose_linkedin_post: 'bi-linkedin',
botcompose_discord_post: 'bi-discord',
scheduled_post_create: 'bi-calendar-plus',
scheduled_post_delete: 'bi-calendar-x',
file_write: 'bi-pencil', file_delete: 'bi-trash', file_upload: 'bi-cloud-upload',
post_create: 'bi-file-earmark-plus', post_delete: 'bi-file-earmark-x',
post_publish: 'bi-send', post_duplicate: 'bi-files',
@ -3208,6 +3217,7 @@ window.addEventListener('DOMContentLoaded', () => {
renderStreamers(_config.twitch || {});
renderFeeds(_config.rss || {});
renderMastodon(_config.mastodon || {});
renderDiscordChannels(_config.discord_channels || {});
renderLinkedinPages(_config.linkedin?.pages || {});
renderLinkedinConnection(_config.linkedin || {});
}
@ -3325,7 +3335,9 @@ window.addEventListener('DOMContentLoaded', () => {
Object.entries(_config?.mastodon || {}).map(([k, v]) => [k, v.label || k]),
f.mastodon_account || '');
populateSelect('rssLinkedinPage',
Object.entries(_config?.linkedin?.pages || {}).map(([k, v]) => [k, v.label || k]),
Object.entries(_config?.linkedin?.pages || {})
.filter(([, v]) => (v.type ?? 'organization') === 'personal')
.map(([k, v]) => [k, v.label || k]),
f.linkedin_page || '');
document.getElementById('rssModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).show();
@ -3401,6 +3413,54 @@ window.addEventListener('DOMContentLoaded', () => {
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).hide(); showSuccess('Account saved'); loadConfig(); }
});
// ── Discord channels ──────────────────────────────────────────────────────────
function renderDiscordChannels(channels) {
const list = document.getElementById('discordChannelList');
if (!list) return;
const names = Object.keys(channels);
if (!names.length) { list.innerHTML = '<div class="text-muted small">No channels configured.</div>'; return; }
list.innerHTML = names.map(name => {
const ch = channels[name];
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<i class="bi bi-discord text-primary flex-shrink-0"></i>
<span class="fw-semibold small">${esc(ch.label || name)}</span>
<code class="small text-muted">DISCORD_WEBHOOK_${esc(name.toUpperCase())}</code>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-discord-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-discord-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.edit-discord-btn').forEach(b => b.addEventListener('click', () => openDiscordChannelModal('edit', b.dataset.name)));
list.querySelectorAll('.del-discord-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_discord_channel', b.dataset.name, `Remove channel "${b.dataset.name}"?`, 'Channel removed')));
}
function openDiscordChannelModal(mode, name = '') {
const ch = (mode === 'edit' && _config?.discord_channels?.[name]) || {};
document.getElementById('discordChannelModalTitle').textContent = mode === 'add' ? 'Add Discord Channel' : 'Edit Discord Channel';
document.getElementById('discordChannelModalMode').value = mode;
document.getElementById('discordChannelName').value = name;
document.getElementById('discordChannelName').disabled = mode === 'edit';
document.getElementById('discordChannelLabel').value = ch.label ?? '';
document.getElementById('discordChannelModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('discordChannelModal')).show();
}
document.getElementById('addDiscordChannelBtn').addEventListener('click', () => openDiscordChannelModal('add'));
document.getElementById('discordChannelModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('discordChannelModalMode').value;
const name = document.getElementById('discordChannelName').value.trim();
const errEl = document.getElementById('discordChannelModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({
action: mode === 'add' ? 'add_discord_channel' : 'save_discord_channel',
name,
data: { label: document.getElementById('discordChannelLabel').value.trim() },
}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('discordChannelModal')).hide(); showSuccess('Channel saved'); loadConfig(); }
});
// ── LinkedIn pages ────────────────────────────────────────────────────────────
function renderLinkedinConnection(li) {
const label = document.getElementById('liConnectionLabel');
@ -3421,20 +3481,23 @@ window.addEventListener('DOMContentLoaded', () => {
function renderLinkedinPages(pages) {
const list = document.getElementById('linkedinPagesList');
const names = Object.keys(pages);
if (!names.length) { list.innerHTML = '<div class="text-muted small">No pages configured.</div>'; return; }
if (!names.length) { list.innerHTML = '<div class="text-muted small">No profiles configured.</div>'; return; }
list.innerHTML = names.map(name => {
const p = pages[name];
const isPersonal = p.type === 'personal';
const badge = isPersonal
? `<span class="badge bg-secondary fw-normal">Personal</span>`
: `<code class="small text-muted">${esc(p.organization_id || '')}</code>`;
: `<span class="badge bg-warning text-dark fw-normal">Org (posting not supported)</span>`;
const composeBtn = isPersonal
? `<button class="btn btn-xs btn-outline-secondary py-0 px-1 compose-linkedin-btn"
data-name="${esc(name)}" data-label="${esc(p.label || name)}" title="Post to LinkedIn">
<i class="bi bi-pencil-square"></i></button>`
: '';
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<span class="fw-semibold small">${esc(p.label || name)}</span>
${badge}
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 compose-linkedin-btn"
data-name="${esc(name)}" data-label="${esc(p.label || name)}" title="Post to LinkedIn">
<i class="bi bi-pencil-square"></i></button>
${composeBtn}
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-lipage-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-lipage-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
@ -3490,7 +3553,7 @@ window.addEventListener('DOMContentLoaded', () => {
document.getElementById('composeModalError').classList.add('d-none');
document.getElementById('composeHint').textContent = type === 'mastodon'
? 'Plain text post — no image upload from this composer.'
: 'Text post to LinkedIn organization page.';
: 'Text post to your LinkedIn profile.';
bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).show();
setTimeout(() => document.getElementById('composeText').focus(), 300);
}
@ -3546,6 +3609,150 @@ window.addEventListener('DOMContentLoaded', () => {
loadConfig();
})();
// ── Bot: schedule editor ─────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botSchedulePanel');
if (!panel) return;
const pid = panel.dataset.projectId;
let _targets = { discord: [], mastodon: [], linkedin: [] };
async function loadTargets() {
const r = await fetch('/api/post_schedule?action=targets&project_id=' + pid);
const d = await r.json();
if (d.error) { showError(d.error); return; }
_targets = d;
renderPicker('Discord', 'schedDiscordChannels', 'schedDiscordEmpty', d.discord, 'discord');
renderPicker('Mastodon', 'schedMastodonAccounts', 'schedMastodonEmpty', d.mastodon, 'mastodon');
renderPicker('LinkedIn', 'schedLinkedinProfiles', 'schedLinkedinEmpty', d.linkedin, 'linkedin');
}
function renderPicker(name, containerId, emptyId, items, platform) {
const container = document.getElementById(containerId);
const emptyEl = document.getElementById(emptyId);
if (!container) return;
if (!items.length) {
container.innerHTML = '';
emptyEl?.classList.remove('d-none');
return;
}
emptyEl?.classList.add('d-none');
container.innerHTML = items.map(item => `
<div class="form-check">
<input class="form-check-input sched-target-check"
type="checkbox" id="st_${platform}_${esc(item.key)}"
data-platform="${esc(platform)}" data-target="${esc(item.key)}">
<label class="form-check-label small" for="st_${platform}_${esc(item.key)}">${esc(item.label)}</label>
</div>`).join('');
}
function togglePicker(checkboxId, pickerId) {
const cb = document.getElementById(checkboxId);
const pk = document.getElementById(pickerId);
if (cb && pk) cb.addEventListener('change', () => pk.classList.toggle('d-none', !cb.checked));
}
togglePicker('schedUseDiscord', 'schedDiscordPicker');
togglePicker('schedUseMastodon', 'schedMastodonPicker');
togglePicker('schedUseLinkedin', 'schedLinkedinPicker');
document.getElementById('schedSubmitBtn').addEventListener('click', async () => {
const content = document.getElementById('schedContent').value.trim();
const url = document.getElementById('schedUrl').value.trim();
const at = document.getElementById('schedAt').value;
const errEl = document.getElementById('schedError');
errEl.classList.add('d-none');
if (!content) { errEl.textContent = 'Content is required'; errEl.classList.remove('d-none'); return; }
if (!at) { errEl.textContent = 'Scheduled time is required'; errEl.classList.remove('d-none'); return; }
const targets = [];
panel.querySelectorAll('.sched-target-check:checked').forEach(cb => {
targets.push({ platform: cb.dataset.platform, target: cb.dataset.target });
});
if (!targets.length) { errEl.textContent = 'Select at least one platform target'; errEl.classList.remove('d-none'); return; }
const r = await fetch('/api/post_schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'create', content, url, scheduled_at: at, targets }),
});
const d = await r.json();
if (d.error) { errEl.textContent = d.error; errEl.classList.remove('d-none'); return; }
showSuccess('Post scheduled');
document.getElementById('schedContent').value = '';
document.getElementById('schedUrl').value = '';
document.getElementById('schedAt').value = '';
panel.querySelectorAll('.sched-target-check').forEach(cb => cb.checked = false);
['schedDiscordPicker', 'schedMastodonPicker', 'schedLinkedinPicker'].forEach(id => document.getElementById(id)?.classList.add('d-none'));
['schedUseDiscord', 'schedUseMastodon', 'schedUseLinkedin'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; });
loadPosts();
});
async function loadPosts() {
const r = await fetch('/api/post_schedule?project_id=' + pid);
const d = await r.json();
const list = document.getElementById('schedPostsList');
if (d.error) { list.innerHTML = `<div class="text-danger small">${esc(d.error)}</div>`; return; }
if (!d.posts.length) { list.innerHTML = '<div class="text-muted small">No scheduled posts yet.</div>'; return; }
const statusBadge = s => {
const map = { pending: 'bg-warning text-dark', processing: 'bg-info text-dark', done: 'bg-success', partial: 'bg-danger', sent: 'bg-success', failed: 'bg-danger' };
return `<span class="badge ${map[s] || 'bg-secondary'} fw-normal">${esc(s)}</span>`;
};
const platformIcon = p => ({ discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' }[p] || 'bi-dot');
list.innerHTML = d.posts.map(post => {
const targets = (post.targets || []).map(t => `
<div class="d-flex align-items-center gap-2 small ms-2">
<i class="bi ${platformIcon(t.platform)} text-muted"></i>
<span class="text-muted">${esc(t.target)}</span>
${statusBadge(t.status)}
${t.error ? `<span class="text-danger small" title="${esc(t.error)}"><i class="bi bi-exclamation-circle"></i></span>` : ''}
${t.status === 'failed' ? `<button class="btn btn-xs btn-outline-secondary py-0 px-1 retry-target-btn" data-target-id="${t.id}">Retry</button>` : ''}
</div>`).join('');
return `<div class="border rounded p-2 mb-2">
<div class="d-flex align-items-start gap-2 mb-1">
<div class="flex-grow-1">
<div class="small fw-semibold">${esc(post.content.substring(0, 120))}${post.content.length > 120 ? '…' : ''}</div>
${post.url ? `<a href="${esc(post.url)}" class="small text-muted" target="_blank">${esc(post.url)}</a>` : ''}
</div>
<div class="d-flex flex-column align-items-end gap-1 flex-shrink-0">
${statusBadge(post.status)}
<span class="text-muted small">${esc(post.scheduled_at)}</span>
</div>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-post-btn flex-shrink-0" data-post-id="${post.id}"><i class="bi bi-trash"></i></button>
</div>
${targets}
</div>`;
}).join('');
list.querySelectorAll('.del-post-btn').forEach(b => b.addEventListener('click', () => {
confirmAction('Delete this scheduled post?', async () => {
const r = await fetch('/api/post_schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'delete', id: +b.dataset.postId }),
});
const d = await r.json();
if (d.ok) { showSuccess('Post deleted'); loadPosts(); }
else showError(d.error || 'Delete failed');
});
}));
list.querySelectorAll('.retry-target-btn').forEach(b => b.addEventListener('click', async () => {
const r = await fetch('/api/post_schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'retry_target', target_id: +b.dataset.targetId }),
});
const d = await r.json();
if (d.ok) { showSuccess('Target queued for retry'); loadPosts(); }
else showError(d.error || 'Retry failed');
}));
}
loadTargets();
loadPosts();
})();
// ── Bot: log viewer ───────────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botLogsPanel');