This commit is contained in:
Flatlogic Bot 2025-10-27 13:55:31 +00:00
parent 74e14f325c
commit d2f005da56
12 changed files with 973 additions and 44 deletions

View File

@ -3,37 +3,37 @@
require_once __DIR__ . '/db/config.php'; require_once __DIR__ . '/db/config.php';
$error = ''; $error = '';
$telegram_id = ''; $channel_identifier = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$telegram_id = trim($_POST['telegram_id'] ?? ''); $channel_identifier = trim($_POST['channel_identifier'] ?? '');
if (empty($telegram_id)) { if (empty($channel_identifier)) {
$error = 'Telegram Channel ID or URL is required.'; $error = 'Telegram Channel ID or URL is required.';
} else { } else {
// Basic parsing to get ID from URL // Basic parsing to get ID from URL
if (filter_var($telegram_id, FILTER_VALIDATE_URL)) { if (filter_var($channel_identifier, FILTER_VALIDATE_URL)) {
$path = parse_url($telegram_id, PHP_URL_PATH); $path = parse_url($channel_identifier, PHP_URL_PATH);
$telegram_id = basename($path); $channel_identifier = basename($path);
} }
// Prepend '@' if it's a public username without it // Prepend '@' if it's a public username without it
if (strpos($telegram_id, '@') !== 0 && !is_numeric($telegram_id)) { if (strpos($channel_identifier, '@') !== 0 && !is_numeric($channel_identifier)) {
$telegram_id = '@' . $telegram_id; $channel_identifier = '@' . $channel_identifier;
} }
try { try {
$pdo = db(); $pdo = db();
$stmt = $pdo->prepare("INSERT INTO channels (telegram_id) VALUES (:telegram_id)"); $stmt = $pdo->prepare("INSERT INTO channels (channel_identifier) VALUES (:channel_identifier)");
$stmt->execute([':telegram_id' => $telegram_id]); $stmt->execute([':channel_identifier' => $channel_identifier]);
session_start(); session_start();
$_SESSION['flash_message'] = "Channel '{$telegram_id}' added successfully!"; $_SESSION['flash_message'] = "Channel '{$channel_identifier}' added successfully!";
header("Location: index.php"); header("Location: index.php");
exit; exit;
} catch (PDOException $e) { } catch (PDOException $e) {
if ($e->errorInfo[1] == 1062) { // Duplicate entry if ($e->errorInfo[1] == 1062) { // Duplicate entry
$error = "Error: Channel '{$telegram_id}' already exists."; $error = "Error: Channel '{$channel_identifier}' already exists.";
} else { } else {
$error = "Database error: " . $e->getMessage(); $error = "Database error: " . $e->getMessage();
} }
@ -57,10 +57,10 @@ require_once __DIR__ . '/layout_header.php';
<form action="add_channel.php" method="POST"> <form action="add_channel.php" method="POST">
<div class="mb-3"> <div class="mb-3">
<label for="telegram_id" class="form-label">Telegram Channel ID or URL</label> <label for="channel_identifier" class="form-label">Telegram Channel ID or URL</label>
<input type="text" class="form-control" id="telegram_id" name="telegram_id" <input type="text" class="form-control" id="channel_identifier" name="channel_identifier"
placeholder="e.g., @channelname or https://t.me/channelname" placeholder="e.g., @channelname or https://t.me/channelname"
value="<?php echo htmlspecialchars($telegram_id); ?>" required> value="<?php echo htmlspecialchars($channel_identifier); ?>" required>
<div class="form-text text-secondary-color"> <div class="form-text text-secondary-color">
Provide the public channel username (e.g., @durov) or full URL. Provide the public channel username (e.g., @durov) or full URL.
</div> </div>

129
analytics.php Normal file
View File

@ -0,0 +1,129 @@
<?php
require_once 'db/config.php';
require_once 'layout_header.php';
$pdo = db();
// --- Fetch Key Metrics ---
$stats = [
'total' => 0,
'published' => 0,
'failed' => 0,
'pending' => 0
];
try {
$stmt = $pdo->query("SELECT status, COUNT(*) as count FROM scheduled_posts GROUP BY status");
$results = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
$stats['published'] = $results['published'] ?? 0;
$stats['failed'] = $results['failed'] ?? 0;
$stats['pending'] = $results['pending'] ?? 0;
$stats['total'] = array_sum($stats);
} catch (PDOException $e) {
// Handle error, maybe show a message
}
// --- Fetch Recent Webhook Events ---
$recent_events = [];
try {
$event_stmt = $pdo->query("SELECT * FROM webhook_events ORDER BY received_at DESC LIMIT 15");
$recent_events = $event_stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
// Handle error
}
?>
<div class="container-fluid p-4">
<h1 class="h3 mb-4 text-white">Analytics</h1>
<p class="text-muted mb-4">This page provides an overview of your post scheduling and publishing activity, based on status updates received from your n8n workflows.</p>
<!-- Key Metrics -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card bg-dark-surface h-100">
<div class="card-body">
<h6 class="card-title text-muted text-uppercase">Total Scheduled</h6>
<h2 class="h1 fw-bold mb-0"><?php echo $stats['total']; ?></h2>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card bg-dark-surface h-100">
<div class="card-body">
<h6 class="card-title text-muted text-uppercase">Successfully Published</h6>
<h2 class="h1 fw-bold mb-0 text-success"><?php echo $stats['published']; ?></h2>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card bg-dark-surface h-100">
<div class="card-body">
<h6 class="card-title text-muted text-uppercase">Failed to Publish</h6>
<h2 class="h1 fw-bold mb-0 text-danger"><?php echo $stats['failed']; ?></h2>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card bg-dark-surface h-100">
<div class="card-body">
<h6 class="card-title text-muted text-uppercase">Pending / In Progress</h6>
<h2 class="h1 fw-bold mb-0 text-warning"><?php echo $stats['pending']; ?></h2>
</div>
</div>
</div>
</div>
<!-- Recent Activity Log -->
<div class="card bg-dark-surface">
<div class="card-header">
<h5 class="card-title mb-0">Recent Activity (from Webhooks)</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark">
<thead>
<tr>
<th>Post ID</th>
<th>Status</th>
<th>Message</th>
<th>Received At</th>
</tr>
</thead>
<tbody>
<?php if (empty($recent_events)): ?>
<tr>
<td colspan="4" class="text-center text-muted">No recent webhook events found.</td>
</tr>
<?php else: ?>
<?php foreach ($recent_events as $event): ?>
<tr>
<td>#<?php echo htmlspecialchars($event['post_id']); ?></td>
<td>
<?php
$status_class = 'bg-secondary';
if (in_array($event['status'], ['success', 'published'])) {
$status_class = 'bg-success';
} elseif (in_array($event['status'], ['error', 'failed'])) {
$status_class = 'bg-danger';
}
?>
<span class="badge <?php echo $status_class; ?>"><?php echo htmlspecialchars(ucfirst($event['status'])); ?></span>
</td>
<td><?php echo htmlspecialchars($event['message']); ?></td>
<td><?php echo date("F j, Y, g:i a", strtotime($event['received_at'])); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php
require_once 'layout_footer.php';
?>

View File

@ -1,4 +1,3 @@
:root { :root {
--bg-color: #121212; --bg-color: #121212;
--surface-color: #1E1E1E; --surface-color: #1E1E1E;
@ -133,3 +132,10 @@ body {
.btn-close { .btn-close {
filter: invert(1) grayscale(100%) brightness(200%); filter: invert(1) grayscale(100%) brightness(200%);
} }
/* Style for code blocks in documentation */
.code-block-dark {
background-color: #2d2d2d; /* A slightly lighter dark */
color: #e0e0e0; /* Light grey text */
border: 1px solid var(--border-color);
}

94
channels.php Normal file
View File

@ -0,0 +1,94 @@
<?php
require_once 'db/config.php';
require_once 'layout_header.php';
$pdo = db();
$message = '';
$type = '';
// Handle Delete action
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete_channel') {
$channel_id_to_delete = $_POST['channel_id'] ?? 0;
if ($channel_id_to_delete > 0) {
try {
$delete_stmt = $pdo->prepare("DELETE FROM channels WHERE id = ?");
$delete_stmt->execute([$channel_id_to_delete]);
$message = 'Channel deleted successfully. All associated scheduled posts have also been removed.';
$type = 'success';
} catch (PDOException $e) {
$message = 'Error deleting channel: ' . $e->getMessage();
$type = 'danger';
}
}
}
try {
$stmt = $pdo->query("SELECT id, channel_identifier, created_at FROM channels ORDER BY created_at DESC");
$channels = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$message = "Error fetching channels: " . $e->getMessage();
$type = 'danger';
$channels = [];
}
?>
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-white">Channels</h1>
<a href="add_channel.php" class="btn btn-primary">
<i data-feather="plus" class="me-1"></i>
Add Channel
</a>
</div>
<?php if ($message): ?>
<div class="alert alert-<?php echo $type; ?> alert-dismissible fade show" role="alert">
<?php echo htmlspecialchars($message); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div class="card bg-dark-surface">
<div class="card-body">
<?php if (empty($channels)): ?>
<div class="text-center p-4">
<p class="text-muted">No channels found. Get started by adding one.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-dark table-hover">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Channel ID/URL</th>
<th scope="col">Added On</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($channels as $channel): ?>
<tr>
<td><?php echo htmlspecialchars($channel['id']); ?></td>
<td><?php echo htmlspecialchars($channel['channel_identifier']); ?></td>
<td><?php echo date("F j, Y, g:i a", strtotime($channel['created_at'])); ?></td>
<td>
<form method="POST" action="channels.php" onsubmit="return confirm('Are you sure you want to delete this channel? This will also delete all scheduled posts for this channel.');" style="display: inline;">
<input type="hidden" name="action" value="delete_channel">
<input type="hidden" name="channel_id" value="<?php echo $channel['id']; ?>">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php
require_once 'layout_footer.php';
?>

View File

@ -4,7 +4,18 @@ require_once __DIR__ . '/db/config.php';
// Simple migration runner // Simple migration runner
try { try {
$pdo = db(); $pdo = db();
$pdo->exec("CREATE TABLE IF NOT EXISTS channels (id INT AUTO_INCREMENT PRIMARY KEY, telegram_id VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(255), avatar_url VARCHAR(255), description TEXT, subscribers INT, added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"); // Main Tables
$pdo->exec("CREATE TABLE IF NOT EXISTS channels (id INT AUTO_INCREMENT PRIMARY KEY, channel_identifier VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL);");
$pdo->exec("CREATE TABLE IF NOT EXISTS scheduled_posts (id INT AUTO_INCREMENT PRIMARY KEY, channel_id INT NOT NULL, message TEXT NOT NULL, scheduled_at DATETIME NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE);");
$pdo->exec("CREATE TABLE IF NOT EXISTS settings (setting_key VARCHAR(255) PRIMARY KEY, setting_value TEXT);");
$pdo->exec("CREATE TABLE IF NOT EXISTS webhook_events (id INT AUTO_INCREMENT PRIMARY KEY, post_id INT, status VARCHAR(50), message TEXT, raw_payload TEXT, received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY (post_id));");
// Simple migration to rename telegram_id to channel_identifier for backwards compatibility
$checkColumn = $pdo->query("SHOW COLUMNS FROM `channels` LIKE 'telegram_id'");
if ($checkColumn->rowCount() > 0) {
$pdo->exec("ALTER TABLE `channels` CHANGE `telegram_id` `channel_identifier` VARCHAR(255) NOT NULL UNIQUE;");
}
} catch (PDOException $e) { } catch (PDOException $e) {
// In a real app, log this error. For now, we die. // In a real app, log this error. For now, we die.
die("Database migration failed: " . $e->getMessage()); die("Database migration failed: " . $e->getMessage());

View File

@ -4,6 +4,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.0/dist/chart.min.js"></script>
<script> <script>
feather.replace(); feather.replace();

View File

@ -32,37 +32,25 @@ session_start();
<span>telegra</span> <span>telegra</span>
</a> </a>
</div> </div>
<?php
$current_page = basename($_SERVER['PHP_SELF']);
$menu_items = [
'index.php' => ['icon' => 'home', 'label' => 'Dashboard'],
'channels.php' => ['icon' => 'tv', 'label' => 'Channels'],
'scheduler.php' => ['icon' => 'calendar', 'label' => 'Scheduler'],
'analytics.php' => ['icon' => 'bar-chart-2', 'label' => 'Analytics'],
'settings.php' => ['icon' => 'settings', 'label' => 'Settings'],
];
?>
<ul class="nav flex-column"> <ul class="nav flex-column">
<?php foreach ($menu_items as $url => $item): ?>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href="index.php"> <a class="nav-link <?php echo ($current_page == $url) ? 'active' : ''; ?>" href="<?php echo $url; ?>">
<i data-feather="home"></i> <i data-feather="<?php echo $item['icon']; ?>"></i>
<span>Dashboard</span> <span><?php echo $item['label']; ?></span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i data-feather="tv"></i>
<span>Channels</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i data-feather="calendar"></i>
<span>Scheduler</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i data-feather="bar-chart-2"></i>
<span>Analytics</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i data-feather="settings"></i>
<span>Settings</span>
</a> </a>
</li> </li>
<?php endforeach; ?>
</ul> </ul>
</nav> </nav>
<main class="main-content flex-grow-1"> <main class="main-content flex-grow-1">

135
logs/webhook.log Normal file
View File

@ -0,0 +1,135 @@
[2025-10-27 13:19:02] - Received webhook.
Headers: {
"Host": "telegra.dev.flatlogic.app",
"User-Agent": "Go-http-client\/2.0",
"Accept-Encoding": "gzip",
"Cdn-Loop": "cloudflare; loops=1; subreqs=1",
"Cf-Connecting-Ip": "2a06:98c0:3600::103",
"Cf-Ew-Via": "15",
"Cf-Ipcountry": "UA",
"Cf-Ray": "99527b98b5cac045-WAW",
"Cf-Visitor": "{\"scheme\":\"http\"}",
"Cf-Warp-Tag-Id": "2dc3820a-6205-48d9-b0ca-041c1b8c9e3e",
"Cf-Worker": "flatlogic.app",
"Connection": "keep-alive",
"X-Forwarded-For": "2a06:98c0:3600::103",
"X-Forwarded-Proto": "http"
}
Payload:
[2025-10-27 13:19:03] - Received webhook.
Headers: {
"Host": "telegra.dev.flatlogic.app",
"User-Agent": "curl\/8.5.0",
"Content-Length": "149",
"Accept": "*\/*",
"Accept-Encoding": "gzip",
"Cdn-Loop": "cloudflare; loops=1; subreqs=1",
"Cf-Connecting-Ip": "37.53.100.68",
"Cf-Ew-Via": "15",
"Cf-Ipcountry": "UA",
"Cf-Ray": "99527b9b1622c045-WAW",
"Cf-Visitor": "{\"scheme\":\"http\"}",
"Cf-Warp-Tag-Id": "2dc3820a-6205-48d9-b0ca-041c1b8c9e3e",
"Cf-Worker": "flatlogic.app",
"Connection": "keep-alive",
"Content-Type": "application\/json",
"X-Forwarded-For": "37.53.100.68",
"X-Forwarded-Proto": "http"
}
Payload: {"status":"error","order_id":"sub_348228662_20251021_8","amount":"149.00","currency":"UAH","description":"Internal error","email":"test@example.com"}
[2025-10-27 13:19:43] - Received webhook.
Headers: {
"Host": "telegra.dev.flatlogic.app",
"User-Agent": "Go-http-client\/2.0",
"Accept-Encoding": "gzip",
"Cdn-Loop": "cloudflare; loops=1; subreqs=1",
"Cf-Connecting-Ip": "2a06:98c0:3600::103",
"Cf-Ew-Via": "15",
"Cf-Ipcountry": "UA",
"Cf-Ray": "99527c98b2e9c045-WAW",
"Cf-Visitor": "{\"scheme\":\"http\"}",
"Cf-Warp-Tag-Id": "2dc3820a-6205-48d9-b0ca-041c1b8c9e3e",
"Cf-Worker": "flatlogic.app",
"Connection": "keep-alive",
"X-Forwarded-For": "2a06:98c0:3600::103",
"X-Forwarded-Proto": "http"
}
Payload:
[2025-10-27 13:19:43] - Received webhook.
Headers: {
"Host": "telegra.dev.flatlogic.app",
"User-Agent": "curl\/8.5.0",
"Content-Length": "197",
"Accept": "*\/*",
"Accept-Encoding": "gzip",
"Cdn-Loop": "cloudflare; loops=1; subreqs=1",
"Cf-Connecting-Ip": "37.53.100.68",
"Cf-Ew-Via": "15",
"Cf-Ipcountry": "UA",
"Cf-Ray": "99527c999306c045-WAW",
"Cf-Visitor": "{\"scheme\":\"http\"}",
"Cf-Warp-Tag-Id": "2dc3820a-6205-48d9-b0ca-041c1b8c9e3e",
"Cf-Worker": "flatlogic.app",
"Connection": "keep-alive",
"Content-Type": "application\/json",
"X-Forwarded-For": "37.53.100.68",
"X-Forwarded-Proto": "http"
}
Payload: {
"event": "new_post_scheduled",
"channel_identifier": "@my_telegram_channel",
"post_text_idea": "A post about morning coffee",
"scheduled_at_utc": "2025-10-28 14:30:00",
"post_id": 123
}
[2025-10-27 13:20:31] - Received webhook.
Headers: {
"Host": "telegra.dev.flatlogic.app",
"User-Agent": "Go-http-client\/2.0",
"Accept-Encoding": "gzip",
"Cdn-Loop": "cloudflare; loops=1; subreqs=1",
"Cf-Connecting-Ip": "2a06:98c0:3600::103",
"Cf-Ew-Via": "15",
"Cf-Ipcountry": "UA",
"Cf-Ray": "99527dc48185c045-WAW",
"Cf-Visitor": "{\"scheme\":\"http\"}",
"Cf-Warp-Tag-Id": "2dc3820a-6205-48d9-b0ca-041c1b8c9e3e",
"Cf-Worker": "flatlogic.app",
"Connection": "keep-alive",
"X-Forwarded-For": "2a06:98c0:3600::103",
"X-Forwarded-Proto": "http"
}
Payload:
[2025-10-27 13:20:31] - Received webhook.
Headers: {
"Host": "telegra.dev.flatlogic.app",
"User-Agent": "curl\/8.5.0",
"Content-Length": "192",
"Accept": "*\/*",
"Accept-Encoding": "gzip",
"Cdn-Loop": "cloudflare; loops=1; subreqs=1",
"Cf-Connecting-Ip": "37.53.100.68",
"Cf-Ew-Via": "15",
"Cf-Ipcountry": "UA",
"Cf-Ray": "99527dc561b0c045-WAW",
"Cf-Visitor": "{\"scheme\":\"http\"}",
"Cf-Warp-Tag-Id": "2dc3820a-6205-48d9-b0ca-041c1b8c9e3e",
"Cf-Worker": "flatlogic.app",
"Connection": "keep-alive",
"Content-Type": "application\/json",
"X-Forwarded-For": "37.53.100.68",
"X-Forwarded-Proto": "http"
}
Payload: {
"event": "new_post_scheduled",
"channel_identifier": "@bazhayu_zdorovya",
"post_text_idea": "A post about morning coffee",
"scheduled_at_utc": "2025-10-28 14:30:00",
"post_id": 1
}

243
scheduler.php Normal file
View File

@ -0,0 +1,243 @@
<?php
require_once 'db/config.php';
require_once 'layout_header.php';
$pdo = db();
// Create scheduled_posts table if it doesn't exist
$pdo->exec("CREATE TABLE IF NOT EXISTS scheduled_posts (
id INT AUTO_INCREMENT PRIMARY KEY,
channel_id INT NOT NULL,
message TEXT NOT NULL,
scheduled_at DATETIME NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);");
$message = '';
$type = '';
// Handle deletion of a scheduled post
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete_post') {
$post_id_to_delete = $_POST['post_id'] ?? 0;
if ($post_id_to_delete > 0) {
try {
$delete_stmt = $pdo->prepare("DELETE FROM scheduled_posts WHERE id = ?");
$delete_stmt->execute([$post_id_to_delete]);
$message = 'Scheduled post cancelled successfully.';
$type = 'success';
} catch (PDOException $e) {
$message = 'Error cancelling post: ' . $e->getMessage();
$type = 'danger';
}
}
}
// Handle form submission for bulk scheduling
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['schedule_post'])) {
$channel_id = $_POST['channel_id'];
$post_idea = $_POST['message'];
$schedule_date = $_POST['schedule_date'];
$post_count = isset($_POST['post_count']) ? (int)$_POST['post_count'] : 0;
if (empty($channel_id) || empty($post_idea) || empty($schedule_date) || $post_count <= 0) {
$message = 'Please fill out all fields and specify a valid number of posts.';
$type = 'danger';
} else {
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("INSERT INTO scheduled_posts (channel_id, message, scheduled_at) VALUES (?, ?, ?)");
// Fetch webhook URL and channel identifier once before the loop
$webhook_stmt = $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'webhook_new_post'");
$webhook_url = $webhook_stmt->fetchColumn();
$identifier_stmt = $pdo->prepare("SELECT channel_identifier FROM channels WHERE id = ?");
$identifier_stmt->execute([$channel_id]);
$channel_identifier = $identifier_stmt->fetchColumn();
$start_hour = 8; // 8 AM
$end_hour = 22; // 10 PM
$total_hours = $end_hour - $start_hour;
$interval_minutes = ($post_count > 1) ? floor(($total_hours * 60) / ($post_count - 1)) : 0;
$posts_scheduled = 0;
for ($i = 0; $i < $post_count; $i++) {
$minutes_to_add = $i * $interval_minutes;
$scheduled_datetime = new DateTime($schedule_date . ' ' . $start_hour . ':00');
$scheduled_datetime->add(new DateInterval('PT' . $minutes_to_add . 'M'));
$scheduled_at_str = $scheduled_datetime->format('Y-m-d H:i:s');
$stmt->execute([$channel_id, $post_idea, $scheduled_at_str]);
$last_post_id = $pdo->lastInsertId();
// Trigger webhook for each post
if ($webhook_url && $channel_identifier) {
$post_data = [
'event' => 'new_post_scheduled',
'post_id' => $last_post_id,
'channel_identifier' => $channel_identifier,
'post_text_idea' => $post_idea,
'scheduled_at_utc' => gmdate('Y-m-d H:i:s', strtotime($scheduled_at_str))
];
$ch = curl_init($webhook_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_exec($ch);
curl_close($ch);
}
$posts_scheduled++;
}
$pdo->commit();
$message = "$posts_scheduled post(s) scheduled successfully!";
$type = 'success';
} catch (Exception $e) {
$pdo->rollBack();
$message = 'Error scheduling posts: ' . $e->getMessage();
$type = 'danger';
}
}
}
// Fetch channels for the dropdown
try {
$channels_stmt = $pdo->query("SELECT id, channel_identifier FROM channels ORDER BY channel_identifier ASC");
$channels = $channels_stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$channels = [];
$message = 'Error fetching channels: ' . $e->getMessage();
$type = 'danger';
}
// Fetch scheduled posts for the table
try {
$posts_stmt = $pdo->query("
SELECT sp.id, c.channel_identifier, sp.message, sp.scheduled_at, sp.status
FROM scheduled_posts sp
JOIN channels c ON sp.channel_id = c.id
ORDER BY sp.scheduled_at ASC
");
$scheduled_posts = $posts_stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$scheduled_posts = [];
$message = 'Error fetching scheduled posts: ' . $e->getMessage();
$type = 'danger';
}
?>
<div class="container-fluid p-4">
<h1 class="h3 mb-4 text-white">Scheduler</h1>
<?php if ($message): ?>
<div class="alert alert-<?php echo $type; ?> alert-dismissible fade show" role="alert">
<?php echo htmlspecialchars($message); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<!-- Schedule Posts Form -->
<div class="card bg-dark-surface mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Bulk Schedule Posts</h5>
</div>
<div class="card-body">
<form method="POST" action="scheduler.php">
<div class="row">
<div class="col-md-6 mb-3">
<label for="channel_id" class="form-label">Channel</label>
<select class="form-select" id="channel_id" name="channel_id" required>
<option value="" disabled selected>Select a channel</option>
<?php foreach ($channels as $channel): ?>
<option value="<?php echo $channel['id']; ?>">
<?php echo htmlspecialchars($channel['channel_identifier']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3 mb-3">
<label for="schedule_date" class="form-label">Date</label>
<input type="date" class="form-control" id="schedule_date" name="schedule_date" required>
</div>
<div class="col-md-3 mb-3">
<label for="post_count" class="form-label">Number of Posts</label>
<input type="number" class="form-control" id="post_count" name="post_count" min="1" max="100" value="1" required>
</div>
</div>
<div class="mb-3">
<label for="message" class="form-label">Post Idea / Prompt</label>
<textarea class="form-control" id="message" name="message" rows="3" required placeholder="e.g., A motivational quote about success"></textarea>
<div class="form-text">This text will be sent to your n8n workflow as a prompt for AI content generation.</div>
</div>
<button type="submit" name="schedule_post" class="btn btn-primary">Schedule Posts</button>
</form>
</div>
</div>
<!-- Scheduled Posts Table -->
<div class="card bg-dark-surface">
<div class="card-header">
<h5 class="card-title mb-0">Upcoming Posts</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark table-hover">
<thead>
<tr>
<th>ID</th>
<th>Channel</th>
<th>Message</th>
<th>Scheduled Time</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($scheduled_posts)): ?>
<tr>
<td colspan="6" class="text-center text-muted">No posts scheduled yet.</td>
</tr>
<?php else: ?>
<?php
$current_date = null;
foreach ($scheduled_posts as $post):
$post_date = date("F j, Y", strtotime($post['scheduled_at']));
if ($post_date !== $current_date):
$current_date = $post_date;
?>
<tr class="table-group-divider">
<td colspan="6" class="bg-secondary text-white"><strong><?php echo $current_date; ?></strong></td>
</tr>
<?php endif; ?>
<tr>
<td><?php echo $post['id']; ?></td>
<td><?php echo htmlspecialchars($post['channel_identifier']); ?></td>
<td><?php echo nl2br(htmlspecialchars(substr($post['message'], 0, 80))) . (strlen($post['message']) > 80 ? '...' : ''); ?></td>
<td><?php echo date("g:i a", strtotime($post['scheduled_at'])); ?></td>
<td><span class="badge bg-warning text-dark"><?php echo htmlspecialchars(ucfirst($post['status'])); ?></span></td>
<td>
<form method="POST" action="scheduler.php" onsubmit="return confirm('Are you sure you want to cancel this post?');" style="display: inline;">
<input type="hidden" name="action" value="delete_post">
<input type="hidden" name="post_id" value="<?php echo $post['id']; ?>">
<button type="submit" class="btn btn-danger btn-sm">Cancel</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php
require_once 'layout_footer.php';
?>

147
settings.php Normal file
View File

@ -0,0 +1,147 @@
<?php
require_once 'db/config.php';
require_once 'layout_header.php';
$pdo = db();
$success_message = '';
// List of recognized settings keys
$allowed_settings = [
'webhook_new_post',
'webhook_post_sent',
'webhook_new_channel',
'webhook_token' // Add the token to the allowed list
];
// Handle form submission for saving webhook URLs
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = :value");
foreach ($allowed_settings as $key) {
if (isset($_POST[$key])) {
$stmt->execute([':key' => $key, ':value' => $_POST[$key]]);
}
}
$success_message = "Settings saved successfully!";
}
// Handle token regeneration
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regenerate_token'])) {
$new_token = bin2hex(random_bytes(16));
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES ('webhook_token', :token) ON DUPLICATE KEY UPDATE setting_value = :token");
$stmt->execute([':token' => $new_token]);
$success_message = "New webhook token generated successfully!";
}
// Fetch all settings from the database
$stmt = $pdo->query("SELECT * FROM settings");
$db_settings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
// Merge with defaults to ensure all keys exist
$settings = array_merge(
array_fill_keys($allowed_settings, ''),
$db_settings
);
// Generate a token if it doesn't exist
if (empty($settings['webhook_token'])) {
$settings['webhook_token'] = bin2hex(random_bytes(16));
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES ('webhook_token', :token) ON DUPLICATE KEY UPDATE setting_value = :token");
$stmt->execute([':token' => $settings['webhook_token']]);
}
// Construct the full webhook URL
$scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http";
$host = $_SERVER['HTTP_HOST'];
$incoming_webhook_url = sprintf('%s://%s/webhook_receiver.php?token=%s', $scheme, $host, $settings['webhook_token']);
?>
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 text-white mb-0">Settings</h1>
<a href="webhook_docs.php" class="btn btn-info">View Webhook Documentation</a>
</div>
<?php if ($success_message): ?>
<div class="alert alert-success"><?php echo $success_message; ?></div>
<?php endif; ?>
<div class="row">
<div class="col-lg-8">
<div class="card bg-dark-surface mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Outgoing Webhooks <a href="webhook_docs.php#outgoing" class="small">(See Docs)</a></h5>
</div>
<div class="card-body">
<p class="card-text text-muted mb-4">
Configure webhooks to send notifications to external services based on events in the application.
Enter the full URL for each endpoint.
</p>
<form method="POST">
<div class="mb-4">
<label for="webhook_new_post" class="form-label">New Post Scheduled</label>
<input type="url" class="form-control" id="webhook_new_post" name="webhook_new_post"
placeholder="https://your-service.com/webhook/new-post"
value="<?php echo htmlspecialchars($settings['webhook_new_post']); ?>">
<div class="form-text">This webhook will be triggered when a new post is added to the scheduler.</div>
</div>
<div class="mb-4">
<label for="webhook_post_sent" class="form-label">Post Successfully Sent</label>
<input type="url" class="form-control" id="webhook_post_sent" name="webhook_post_sent"
placeholder="https://your-service.com/webhook/post-sent"
value="<?php echo htmlspecialchars($settings['webhook_post_sent']); ?>">
<div class="form-text">This webhook will be triggered when a scheduled post is successfully sent. (Not yet implemented)</div>
</div>
<div class="mb-4">
<label for="webhook_new_channel" class="form-label">New Channel Added</label>
<input type="url" class="form-control" id="webhook_new_channel" name="webhook_new_channel"
placeholder="https://your-service.com/webhook/new-channel"
value="<?php echo htmlspecialchars($settings['webhook_new_channel']); ?>">
<div class="form-text">This webhook will be triggered when a new channel is added to the system. (Not yet implemented)</div>
</div>
<button type="submit" name="save_settings" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card bg-dark-surface">
<div class="card-header">
<h5 class="card-title mb-0">Incoming Webhook URL <a href="webhook_docs.php#incoming" class="small">(See Docs)</a></h5>
</div>
<div class="card-body">
<p class="text-muted">
Use this URL in your external services (like n8n) to send data back to this application.
</p>
<div class="input-group mb-3">
<input type="text" id="webhook-url-input" class="form-control" value="<?php echo htmlspecialchars($incoming_webhook_url); ?>" readonly>
<button class="btn btn-secondary" type="button" onclick="copyToClipboard()">Copy</button>
</div>
<form method="POST" class="d-inline">
<button type="submit" name="regenerate_token" class="btn btn-warning">Regenerate Token</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
function copyToClipboard() {
var copyText = document.getElementById("webhook-url-input");
copyText.select();
copyText.setSelectionRange(0, 99999); // For mobile devices
document.execCommand("copy");
alert("Copied the URL: " + copyText.value);
}
</script>
<?php
require_once 'layout_footer.php';
?>

93
webhook_docs.php Normal file
View File

@ -0,0 +1,93 @@
<?php
require_once 'db/config.php';
require_once 'layout_header.php';
?>
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h1 class="card-title">Webhook Documentation</h1>
<p class="card-subtitle mb-2 text-muted">Everything you need to integrate with external services like n8n.</p>
</div>
<div class="card-body">
<p>Webhooks are a powerful way for your application to send and receive information from other services in real-time. Heres how they work in your CRM.</p>
<hr>
<h2 class="mt-4">Outgoing Webhooks</h2>
<p>Your application sends these webhooks when certain events happen. You can set the destination URL in the <a href="settings.php">Settings</a> page.</p>
<div class="card mt-3">
<div class="card-header bg-light">
<strong>Event: New Post Scheduled</strong>
</div>
<div class="card-body">
<p>This webhook is sent every time you schedule a new post in the <a href="scheduler.php">Scheduler</a>. It tells your external service (e.g., n8n) that a new post needs to be generated and published.</p>
<p><strong>Method:</strong> <code>POST</code></p>
<p><strong>Data Format:</strong> <code>application/json</code></p>
<h6>Example Payload:</h6>
<pre class="code-block-dark p-3 rounded"><code>{
"event": "new_post_scheduled",
"channel_identifier": "@my_telegram_channel",
"post_text_idea": "A post about morning coffee",
"scheduled_at_utc": "2025-10-28 14:30:00",
"post_id": 123
}</code></pre>
<h6>Field Descriptions:</h6>
<ul>
<li><code>event</code>: The name of the event (always "new_post_scheduled").</li>
<li><code>channel_identifier</code>: The public URL or @name of the target channel (e.g., "@my_channel" or "t.me/joinchat/...").</li>
<li><code>post_text_idea</code>: The initial text or idea you entered in the scheduler. Your n8n workflow should use this as a prompt for AI text generation.</li>
<li><code>scheduled_at_utc</code>: The planned publication time in UTC.</li>
<li><code>post_id</code>: The unique ID of the post in this CRM, which can be used for status updates.</li>
</ul>
</div>
</div>
<hr class="my-5">
<h2 class="mt-4">Incoming Webhooks</h2>
<p>Your application can also receive webhooks from other services. This is how your n8n workflow reports back on the result of a publication.</p>
<p>The unique and secure URL for incoming webhooks is available on the <a href="settings.php">Settings</a> page.</p>
<div class="alert alert-warning" role="alert">
<strong>Important:</strong> The payload for incoming webhooks is different from the outgoing ones. You must send a status update, not the original event data.
</div>
<div class="card mt-3">
<div class="card-header bg-light">
<strong>Purpose: Report Publication Status</strong>
</div>
<div class="card-body">
<p>After your n8n workflow attempts to publish a post, it **must** send a webhook back to your CRM to report the outcome. This allows the system to update the post's status from "pending" to "published" or "failed".</p>
<p><strong>Method:</strong> <code>POST</code></p>
<p><strong>Data Format:</strong> <code>application/json</code></p>
<h6 class="mt-4">Required Fields:</h6>
<ul>
<li><code>post_id</code> (integer): The ID of the post this update refers to. <strong>This is mandatory.</strong></li>
<li><code>status</code> (string): The outcome. Use <code>published</code> for success or <code>failed</code> for an error.</li>
</ul>
<h6 class="mt-4">Example Payload (Success):</h6>
<p>This is the minimal payload you need to send for a successful publication.</p>
<pre class="code-block-dark p-3 rounded"><code>{
"post_id": 123,
"status": "published"
}</code></pre>
<h6 class="mt-4">Example Payload (Error):</h6>
<p>If something goes wrong, send this payload. You can include an optional <code>details</code> field for error messages.</p>
<pre class="code-block-dark p-3 rounded"><code>{
"post_id": 123,
"status": "failed",
"details": "Telegram API error: Chat not found."
}</code></pre>
</div>
</div>
</div>
</div>
</div>
<?php require_once 'layout_footer.php'; ?>

82
webhook_receiver.php Normal file
View File

@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/db/config.php';
header('Content-Type: application/json');
// --- Security Check ---
$provided_token = $_GET['token'] ?? null;
if (!$provided_token) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Security token not provided.']);
exit;
}
$pdo = db();
$stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = 'webhook_token'");
$stmt->execute();
$correct_token = $stmt->fetchColumn();
if (!$correct_token || !hash_equals($correct_token, $provided_token)) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid security token.']);
exit;
}
// --- Payload Processing ---
$raw_payload = file_get_contents('php://input');
$payload = json_decode($raw_payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid JSON payload.']);
exit;
}
// --- Data Validation ---
$post_id = $payload['post_id'] ?? null;
$status = $payload['status'] ?? null; // e.g., 'success', 'error', 'published', 'failed'
$message = $payload['message'] ?? 'No message provided.';
if (!$post_id || !$status) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Required fields \'post_id\' and \'status\' are missing.']);
exit;
}
// --- Database Update ---
try {
$pdo->beginTransaction();
// 1. Log the incoming event
$log_stmt = $pdo->prepare(
"INSERT INTO webhook_events (post_id, status, message, raw_payload) VALUES (?, ?, ?, ?)"
);
$log_stmt->execute([$post_id, $status, $message, $raw_payload]);
// 2. Update the status of the scheduled post
// We map various possible statuses to a simplified set for our internal state.
$internal_status = 'pending';
if (in_array($status, ['success', 'published'])) {
$internal_status = 'published';
} elseif (in_array($status, ['error', 'failed'])) {
$internal_status = 'failed';
}
if ($internal_status !== 'pending') {
$update_stmt = $pdo->prepare("UPDATE scheduled_posts SET status = ? WHERE id = ?");
$update_stmt->execute([$internal_status, $post_id]);
}
$pdo->commit();
echo json_encode(['status' => 'success', 'message' => 'Webhook processed and status updated.']);
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
// In a real app, you would log this error to a file instead of echoing it.
echo json_encode(['status' => 'error', 'message' => 'Database error: ' . $e->getMessage()]);
}