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:
parent
09e3406847
commit
30b21b6214
9 changed files with 848 additions and 14 deletions
274
bin/process-scheduled-posts.php
Normal file
274
bin/process-scheduled-posts.php
Normal 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);
|
||||
}
|
||||
26
sql/009_scheduled_posts.sql
Normal file
26
sql/009_scheduled_posts.sql
Normal 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);
|
||||
|
|
@ -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_<KEY></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_<KEY></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">
|
||||
|
|
|
|||
80
views/project/_tab_botschedule.php
Normal file
80
views/project/_tab_botschedule.php
Normal 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 & Recent Posts</h6>
|
||||
<div id="schedPostsList"><div class="text-muted small">Loading…</div></div>
|
||||
|
||||
</div>
|
||||
|
|
@ -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'; ?>
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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
131
web/api/post_schedule.php
Normal 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']);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue