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:
Bashy 2026-05-26 19:07:45 +03:00
parent 158a5b0775
commit f86859695a
3 changed files with 248 additions and 0 deletions

View file

@ -178,6 +178,30 @@
</div>
</div>
<!-- Shared compose modal -->
<div class="modal fade" id="composeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="composeModalTitle">Post</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="composeModalType">
<input type="hidden" id="composeModalName">
<textarea id="composeText" class="form-control form-control-sm font-monospace"
rows="5" placeholder="What's on your mind?"></textarea>
<div class="form-text small mt-1" id="composeHint"></div>
</div>
<div class="modal-footer py-2">
<div class="text-danger small d-none flex-grow-1" id="composeModalError"></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="composeModalPostBtn">Post</button>
</div>
</div>
</div>
</div>
<!-- LinkedIn page modal -->
<div class="modal fade" id="linkedinPageModal" tabindex="-1">
<div class="modal-dialog modal-sm">

154
web/api/botcompose.php Normal file
View 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']);

View file

@ -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', {