Add compose/repost UI for RSS feeds, Mastodon accounts, and LinkedIn pages
- botcompose.php: new API endpoint for rss_repost, mastodon_post, linkedin_post - _tab_botconfig.php: shared compose modal (textarea + post button) - app.js: repost button on RSS cards (clears last_id, bot reposts on next poll), compose button on Mastodon/LinkedIn cards (direct post via compose modal), audit feed labels/icons for new compose actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
158a5b0775
commit
f86859695a
3 changed files with 248 additions and 0 deletions
154
web/api/botcompose.php
Normal file
154
web/api/botcompose.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'POST required']); exit; }
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$pid = (int)($input['project_id'] ?? 0);
|
||||
$action = $input['action'] ?? '';
|
||||
|
||||
$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 read_config(string $path): ?array {
|
||||
if (!file_exists($path)) return null;
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
function write_config(string $path, array $data): void {
|
||||
$tmp = $path . '.tmp';
|
||||
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
rename($tmp, $path);
|
||||
}
|
||||
|
||||
function read_dot_env(string $path): array {
|
||||
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(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];
|
||||
}
|
||||
|
||||
// ── RSS: clear last_id to trigger repost on next poll ─────────────────────────
|
||||
if ($action === 'rss_repost') {
|
||||
$feed_name = $input['feed_name'] ?? '';
|
||||
if (!$feed_name) { echo json_encode(['error' => 'feed_name required']); exit; }
|
||||
|
||||
$config = read_config($config_path);
|
||||
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
|
||||
if (!isset($config['rss'][$feed_name])) { echo json_encode(['error' => 'Feed not found']); exit; }
|
||||
|
||||
$config['rss'][$feed_name]['last_id'] = '';
|
||||
write_config($config_path, $config);
|
||||
|
||||
Audit::log($db, 'botcompose_rss_repost', $pid, $feed_name);
|
||||
echo json_encode(['ok' => true, 'message' => 'Last ID cleared — bot will repost on next poll (within 5 min)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Mastodon: post directly via API ───────────────────────────────────────────
|
||||
if ($action === 'mastodon_post') {
|
||||
$account_name = $input['account_name'] ?? '';
|
||||
$text = trim($input['text'] ?? '');
|
||||
if (!$account_name || !$text) { echo json_encode(['error' => 'account_name and text required']); exit; }
|
||||
|
||||
$config = read_config($config_path);
|
||||
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
|
||||
|
||||
$account = $config['mastodon'][$account_name] ?? null;
|
||||
if (!$account) { echo json_encode(['error' => "Mastodon account '$account_name' not in config"]); exit; }
|
||||
|
||||
$env = read_dot_env($base . '/.env');
|
||||
$token = $env['MASTODON_TOKEN_' . strtoupper($account_name)] ?? '';
|
||||
if (!$token) { echo json_encode(['error' => 'MASTODON_TOKEN_' . strtoupper($account_name) . ' not found in .env']); exit; }
|
||||
|
||||
$api_base = rtrim($account['api_base_url'] ?? '', '/');
|
||||
[$status, $resp] = curl_post(
|
||||
$api_base . '/api/v1/statuses',
|
||||
['Authorization: Bearer ' . $token, 'Content-Type: application/x-www-form-urlencoded'],
|
||||
http_build_query(['status' => $text])
|
||||
);
|
||||
|
||||
if ($status !== 200) {
|
||||
$err = json_decode($resp, true)['error'] ?? "HTTP $status";
|
||||
echo json_encode(['error' => "Mastodon error: $err"]); exit;
|
||||
}
|
||||
|
||||
Audit::log($db, 'botcompose_mastodon_post', $pid, $account_name);
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── LinkedIn: post directly via API ──────────────────────────────────────────
|
||||
if ($action === 'linkedin_post') {
|
||||
$page_name = $input['page_name'] ?? '';
|
||||
$text = trim($input['text'] ?? '');
|
||||
if (!$page_name || !$text) { echo json_encode(['error' => 'page_name and text required']); exit; }
|
||||
|
||||
$config = read_config($config_path);
|
||||
if (!$config) { echo json_encode(['error' => 'config.json not found']); exit; }
|
||||
|
||||
$page = $config['linkedin']['pages'][$page_name] ?? null;
|
||||
$token = $config['linkedin']['access_token'] ?? null;
|
||||
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'] ?? '';
|
||||
$payload = json_encode([
|
||||
'author' => "urn:li:organization:$org_id",
|
||||
'lifecycleState' => 'PUBLISHED',
|
||||
'specificContent' => [
|
||||
'com.linkedin.ugc.ShareContent' => [
|
||||
'shareCommentary' => ['text' => $text],
|
||||
'shareMediaCategory' => 'NONE',
|
||||
],
|
||||
],
|
||||
'visibility' => ['com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC'],
|
||||
]);
|
||||
|
||||
[$status, $resp] = curl_post(
|
||||
'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) {
|
||||
$err = json_decode($resp, true)['message'] ?? "HTTP $status";
|
||||
echo json_encode(['error' => "LinkedIn error: $err"]); exit;
|
||||
}
|
||||
|
||||
Audit::log($db, 'botcompose_linkedin_post', $pid, $page_name);
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Unknown action']);
|
||||
|
|
@ -2365,6 +2365,9 @@ if (addScanPathForm) {
|
|||
botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated',
|
||||
botconfig_linkedin_page_remove: 'linkedin page removed',
|
||||
linkedin_oauth_connect: 'linkedin connected',
|
||||
botcompose_rss_repost: 'rss repost queued',
|
||||
botcompose_mastodon_post: 'mastodon posted',
|
||||
botcompose_linkedin_post: 'linkedin posted',
|
||||
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',
|
||||
|
|
@ -2401,6 +2404,9 @@ if (addScanPathForm) {
|
|||
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',
|
||||
linkedin_oauth_connect: 'bi-linkedin',
|
||||
botcompose_rss_repost: 'bi-arrow-repeat',
|
||||
botcompose_mastodon_post: 'bi-mastodon',
|
||||
botcompose_linkedin_post: 'bi-linkedin',
|
||||
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',
|
||||
|
|
@ -3276,11 +3282,24 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
${acct}${liPage}
|
||||
<small class="text-muted text-truncate" style="max-width:180px" title="${esc(f.rss_url || '')}">${esc(f.rss_url || '–')}</small>
|
||||
<div class="ms-auto d-flex gap-1">
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-1 repost-rss-btn" data-name="${esc(name)}" title="Repeat last post"><i class="bi bi-arrow-repeat"></i></button>
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-rss-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-rss-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.repost-rss-btn').forEach(b => b.addEventListener('click', () => {
|
||||
const name = b.dataset.name;
|
||||
confirmAction(`Repeat last post for feed "${name}"?\nThe bot will repost it on the next poll (within 5 min).`, async () => {
|
||||
const r = await fetch('/api/botcompose', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project_id: +pid, action: 'rss_repost', feed_name: name }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) showSuccess(d.message || 'Repost queued — bot will post on next poll');
|
||||
else showError(d.error || 'Failed');
|
||||
});
|
||||
}));
|
||||
list.querySelectorAll('.edit-rss-btn').forEach(b => b.addEventListener('click', () => openRssModal('edit', b.dataset.name)));
|
||||
list.querySelectorAll('.del-rss-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_rss', b.dataset.name, `Remove RSS feed "${b.dataset.name}"?`, 'Feed removed')));
|
||||
}
|
||||
|
|
@ -3343,11 +3362,15 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
<code class="small text-muted">MASTODON_TOKEN_${esc(name.toUpperCase())}</code>
|
||||
<small class="text-muted text-truncate" style="max-width:160px">${esc(a.api_base_url || '')}</small>
|
||||
<div class="ms-auto d-flex gap-1">
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-1 compose-mastodon-btn"
|
||||
data-name="${esc(name)}" data-label="${esc(a.label || name)}" title="Post to Mastodon">
|
||||
<i class="bi bi-pencil-square"></i></button>
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-mastodon-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-mastodon-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.compose-mastodon-btn').forEach(b => b.addEventListener('click', () => openComposeModal('mastodon', b.dataset.name, b.dataset.label)));
|
||||
list.querySelectorAll('.edit-mastodon-btn').forEach(b => b.addEventListener('click', () => openMastodonModal('edit', b.dataset.name)));
|
||||
list.querySelectorAll('.del-mastodon-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_mastodon', b.dataset.name, `Remove account "${b.dataset.name}"?`, 'Account removed')));
|
||||
}
|
||||
|
|
@ -3405,11 +3428,15 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
<span class="fw-semibold small">${esc(p.label || name)}</span>
|
||||
<code class="small text-muted">${esc(p.organization_id || '–')}</code>
|
||||
<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>
|
||||
<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>
|
||||
</div>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.compose-linkedin-btn').forEach(b => b.addEventListener('click', () => openComposeModal('linkedin', b.dataset.name, b.dataset.label)));
|
||||
list.querySelectorAll('.edit-lipage-btn').forEach(b => b.addEventListener('click', () => openLinkedinPageModal('edit', b.dataset.name)));
|
||||
list.querySelectorAll('.del-lipage-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_linkedin_page', b.dataset.name, `Remove page "${b.dataset.name}"?`, 'Page removed')));
|
||||
}
|
||||
|
|
@ -3440,6 +3467,49 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).hide(); showSuccess('Page saved'); loadConfig(); }
|
||||
});
|
||||
|
||||
// ── Compose (Mastodon / LinkedIn direct post) ─────────────────────────────────
|
||||
function openComposeModal(type, name, label) {
|
||||
document.getElementById('composeModalTitle').textContent = 'Post to ' + label;
|
||||
document.getElementById('composeModalType').value = type;
|
||||
document.getElementById('composeModalName').value = name;
|
||||
document.getElementById('composeText').value = '';
|
||||
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.';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).show();
|
||||
setTimeout(() => document.getElementById('composeText').focus(), 300);
|
||||
}
|
||||
|
||||
document.getElementById('composeModalPostBtn').addEventListener('click', async () => {
|
||||
const type = document.getElementById('composeModalType').value;
|
||||
const name = document.getElementById('composeModalName').value;
|
||||
const text = document.getElementById('composeText').value.trim();
|
||||
const errEl = document.getElementById('composeModalError');
|
||||
errEl.classList.add('d-none');
|
||||
if (!text) { errEl.textContent = 'Text is required'; errEl.classList.remove('d-none'); return; }
|
||||
|
||||
const body = { project_id: +pid, action: type === 'mastodon' ? 'mastodon_post' : 'linkedin_post', text };
|
||||
if (type === 'mastodon') body.account_name = name;
|
||||
else body.page_name = name;
|
||||
|
||||
const btn = document.getElementById('composeModalPostBtn');
|
||||
btn.disabled = true;
|
||||
const r = await fetch('/api/botcompose', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await r.json();
|
||||
btn.disabled = false;
|
||||
if (d.ok) {
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).hide();
|
||||
showSuccess('Posted!');
|
||||
} else {
|
||||
errEl.textContent = d.error || 'Post failed';
|
||||
errEl.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
async function apiPost(body, errEl) {
|
||||
const r = await fetch('/api/botconfig', {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue