mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-02 05:46:08 +00:00
a2a121b4e7
- Added 'X-Requested-With' header to various API requests in channel_operations.js, cache.html, config.html, contacts.html, feeds.html, greeter.html, mesh.html, radio.html, and other templates to improve request handling and prevent potential issues with cross-origin requests. - Ensured consistent header usage across all relevant fetch calls to enhance security and compatibility. These changes improve the robustness of API interactions within the web viewer.
544 lines
20 KiB
HTML
544 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Greeter - MeshCore Bot Data Viewer{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<h1 class="mb-4">
|
|
<i class="fas fa-hand-sparkles"></i> Greeter Management
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Rollout Status -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<i class="fas fa-clock"></i> Onboarding Period Status
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="rollout-status">
|
|
<div class="loading">Loading rollout status...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Greeter Settings -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<i class="fas fa-cog"></i> Current Settings
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="greeter-settings">
|
|
<div class="loading">Loading settings...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<i class="fas fa-comment"></i> Sample Greeting
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="sample-greeting">
|
|
<div class="loading">Loading sample greeting...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Greeted Users -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="fas fa-users"></i> Greeted Users</span>
|
|
<span class="badge bg-primary" id="total-greeted-badge">0</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<div class="input-group">
|
|
<span class="input-group-text">
|
|
<i class="fas fa-search"></i>
|
|
</span>
|
|
<input type="text" class="form-control" id="greeted-users-search"
|
|
placeholder="Search by username, channel, date, or last seen...">
|
|
<button class="btn btn-outline-secondary" type="button" id="clear-search-btn">
|
|
<i class="fas fa-times"></i> Clear
|
|
</button>
|
|
</div>
|
|
<small class="text-muted" id="search-results-count"></small>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th>Username</th>
|
|
<th>Channel</th>
|
|
<th>Greeted At</th>
|
|
<th>Last Seen</th>
|
|
<th>Rollout Marked</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="greeted-users-table">
|
|
<tr>
|
|
<td colspan="5" class="text-center">
|
|
<div class="loading">Loading greeted users...</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
class GreeterManager {
|
|
constructor() {
|
|
this.greeterData = null;
|
|
this.allUsers = []; // Store all users for filtering
|
|
this.filteredUsers = []; // Store filtered users
|
|
this.initializeGreeter();
|
|
}
|
|
|
|
async initializeGreeter() {
|
|
await this.loadGreeterData();
|
|
this.setupEventHandlers();
|
|
this.renderRolloutStatus();
|
|
this.renderSettings();
|
|
this.renderSampleGreeting();
|
|
this.renderGreetedUsers();
|
|
|
|
// Auto-refresh every 30 seconds
|
|
setInterval(() => {
|
|
const searchInput = document.getElementById('greeted-users-search');
|
|
const currentSearch = searchInput ? searchInput.value : '';
|
|
|
|
this.loadGreeterData().then(() => {
|
|
this.renderRolloutStatus();
|
|
this.renderSettings();
|
|
this.renderSampleGreeting();
|
|
this.renderGreetedUsers();
|
|
// Reapply search filter if one was active
|
|
if (currentSearch) {
|
|
this.filterUsers(currentSearch);
|
|
}
|
|
});
|
|
}, 30000);
|
|
}
|
|
|
|
async loadGreeterData() {
|
|
try {
|
|
const response = await fetch('/api/greeter');
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
this.showError('Failed to load greeter data: ' + data.error);
|
|
return;
|
|
}
|
|
|
|
this.greeterData = data;
|
|
|
|
} catch (error) {
|
|
console.error('Error loading greeter data:', error);
|
|
this.showError('Failed to load greeter data: ' + error.message);
|
|
}
|
|
}
|
|
|
|
setupEventHandlers() {
|
|
// End rollout button handler will be set up in renderRolloutStatus
|
|
|
|
// Search box handler
|
|
const searchInput = document.getElementById('greeted-users-search');
|
|
const clearBtn = document.getElementById('clear-search-btn');
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', (e) => {
|
|
this.filterUsers(e.target.value);
|
|
});
|
|
}
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => {
|
|
searchInput.value = '';
|
|
this.filterUsers('');
|
|
});
|
|
}
|
|
}
|
|
|
|
renderRolloutStatus() {
|
|
const statusDiv = document.getElementById('rollout-status');
|
|
|
|
if (!this.greeterData) {
|
|
statusDiv.innerHTML = '<div class="loading">Loading...</div>';
|
|
return;
|
|
}
|
|
|
|
if (!this.greeterData.enabled) {
|
|
statusDiv.innerHTML = `
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle"></i> Greeter is currently disabled.
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
if (this.greeterData.rollout_active) {
|
|
const timeRemaining = this.greeterData.time_remaining;
|
|
const timeStr = this.formatTimeRemaining(timeRemaining);
|
|
const endDate = new Date(this.greeterData.rollout_data.end_date);
|
|
|
|
statusDiv.innerHTML = `
|
|
<div class="alert alert-info">
|
|
<h5><i class="fas fa-hourglass-half"></i> Onboarding Period Active</h5>
|
|
<p class="mb-2"><strong>Time Remaining:</strong> <span class="badge bg-primary" style="font-size: 1.1em;">${timeStr}</span></p>
|
|
<p class="mb-2"><strong>Started:</strong> ${new Date(this.greeterData.rollout_data.started_at).toLocaleString()}</p>
|
|
<p class="mb-2"><strong>Ends:</strong> ${endDate.toLocaleString()}</p>
|
|
<p class="mb-0"><strong>Duration:</strong> ${this.greeterData.rollout_data.days} days</p>
|
|
</div>
|
|
<div class="mt-3">
|
|
<button class="btn btn-danger" id="end-rollout-btn">
|
|
<i class="fas fa-stop-circle"></i> End Onboarding Period
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Setup end rollout button
|
|
document.getElementById('end-rollout-btn').addEventListener('click', () => {
|
|
this.endRollout();
|
|
});
|
|
} else {
|
|
statusDiv.innerHTML = `
|
|
<div class="alert alert-success">
|
|
<i class="fas fa-check-circle"></i> No active onboarding period. New users will be greeted immediately.
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
renderSettings() {
|
|
const settingsDiv = document.getElementById('greeter-settings');
|
|
|
|
if (!this.greeterData) {
|
|
settingsDiv.innerHTML = '<div class="loading">Loading...</div>';
|
|
return;
|
|
}
|
|
|
|
const settings = this.greeterData.settings;
|
|
settingsDiv.innerHTML = `
|
|
<ul class="list-unstyled">
|
|
<li><strong>Enabled:</strong> <span class="badge bg-${settings.enabled ? 'success' : 'secondary'}">${settings.enabled ? 'Yes' : 'No'}</span></li>
|
|
<li><strong>Rollout Days:</strong> ${settings.rollout_days}</li>
|
|
<li><strong>Per-Channel Greetings:</strong> <span class="badge bg-${settings.per_channel_greetings ? 'info' : 'secondary'}">${settings.per_channel_greetings ? 'Yes' : 'No'}</span></li>
|
|
<li><strong>Include Mesh Info:</strong> <span class="badge bg-${settings.include_mesh_info ? 'info' : 'secondary'}">${settings.include_mesh_info ? 'Yes' : 'No'}</span></li>
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
renderSampleGreeting() {
|
|
const greetingDiv = document.getElementById('sample-greeting');
|
|
|
|
if (!this.greeterData) {
|
|
greetingDiv.innerHTML = '<div class="loading">Loading...</div>';
|
|
return;
|
|
}
|
|
|
|
greetingDiv.innerHTML = `
|
|
<div class="alert alert-light" style="white-space: pre-wrap; font-family: monospace;">
|
|
${this.escapeHtml(this.greeterData.sample_greeting)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderGreetedUsers(usersToRender = null) {
|
|
const tableBody = document.getElementById('greeted-users-table');
|
|
const badge = document.getElementById('total-greeted-badge');
|
|
|
|
if (!this.greeterData) {
|
|
tableBody.innerHTML = '<tr><td colspan="6" class="text-center"><div class="loading">Loading...</div></td></tr>';
|
|
return;
|
|
}
|
|
|
|
// Store all users for filtering
|
|
this.allUsers = this.greeterData.greeted_users || [];
|
|
|
|
// Use provided users or all users
|
|
const users = usersToRender || this.allUsers;
|
|
|
|
if (badge) {
|
|
badge.textContent = this.allUsers.length || 0;
|
|
}
|
|
|
|
if (users.length === 0) {
|
|
const searchInput = document.getElementById('greeted-users-search');
|
|
const hasSearch = searchInput && searchInput.value.trim();
|
|
tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${hasSearch ? 'No users match your search.' : 'No users have been greeted yet.'}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tableBody.innerHTML = users.map(user => {
|
|
// greeted_at is a SQLite timestamp string (ISO format like "2025-12-03 02:52:38")
|
|
// SQLite stores timestamps in UTC, but without timezone indicator
|
|
// JavaScript Date will interpret it as local time, so we need to be explicit
|
|
let greetedDate;
|
|
if (user.greeted_at && user.greeted_at.includes('T')) {
|
|
// Already has ISO format with T separator
|
|
greetedDate = new Date(user.greeted_at);
|
|
} else if (user.greeted_at) {
|
|
// SQLite format: "YYYY-MM-DD HH:MM:SS" - treat as UTC and add Z
|
|
greetedDate = new Date(user.greeted_at + 'Z');
|
|
} else {
|
|
greetedDate = new Date();
|
|
}
|
|
|
|
// last_seen is a Unix timestamp (seconds since epoch) from message_stats
|
|
// Convert to milliseconds for JavaScript Date constructor
|
|
// Unix timestamps are always UTC
|
|
let lastSeenDate = null;
|
|
if (user.last_seen) {
|
|
const timestamp = user.last_seen;
|
|
// message_stats stores timestamps as INTEGER (seconds since epoch)
|
|
// Convert to milliseconds
|
|
lastSeenDate = new Date(timestamp * 1000);
|
|
}
|
|
const lastSeenStr = lastSeenDate ? lastSeenDate.toLocaleString() : '<span class="text-muted">Never</span>';
|
|
const rolloutBadge = user.rollout_marked
|
|
? '<span class="badge bg-warning">Rollout</span>'
|
|
: '<span class="badge bg-success">Manual</span>';
|
|
|
|
return `
|
|
<tr>
|
|
<td><code>${this.escapeHtml(user.sender_id)}</code></td>
|
|
<td>${this.escapeHtml(user.channel)}</td>
|
|
<td>${greetedDate.toLocaleString()}</td>
|
|
<td>${lastSeenStr}</td>
|
|
<td>${rolloutBadge}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-danger" onclick="greeterManager.ungreetUser('${this.escapeHtml(user.sender_id)}', '${this.escapeHtml(user.channel)}')">
|
|
<i class="fas fa-undo"></i> Mark Ungreeted
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
filterUsers(searchTerm) {
|
|
if (!this.allUsers || this.allUsers.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const term = searchTerm.toLowerCase().trim();
|
|
|
|
if (!term) {
|
|
this.filteredUsers = this.allUsers;
|
|
this.renderGreetedUsers(this.allUsers);
|
|
this.updateSearchResultsCount(this.allUsers.length, this.allUsers.length);
|
|
return;
|
|
}
|
|
|
|
// Filter by username, channel, date, or last seen
|
|
this.filteredUsers = this.allUsers.filter(user => {
|
|
const senderId = (user.sender_id || '').toLowerCase();
|
|
const channel = (user.channel || '').toLowerCase();
|
|
const greetedAt = new Date(user.greeted_at).toLocaleString().toLowerCase();
|
|
const lastSeen = user.last_seen ? new Date(user.last_seen * 1000).toLocaleString().toLowerCase() : 'never';
|
|
|
|
return senderId.includes(term) ||
|
|
channel.includes(term) ||
|
|
greetedAt.includes(term) ||
|
|
lastSeen.includes(term);
|
|
});
|
|
|
|
this.renderGreetedUsers(this.filteredUsers);
|
|
this.updateSearchResultsCount(this.filteredUsers.length, this.allUsers.length);
|
|
}
|
|
|
|
updateSearchResultsCount(filtered, total) {
|
|
const countElement = document.getElementById('search-results-count');
|
|
if (countElement) {
|
|
if (filtered === total) {
|
|
countElement.textContent = '';
|
|
} else {
|
|
countElement.textContent = `Showing ${filtered} of ${total} users`;
|
|
}
|
|
}
|
|
}
|
|
|
|
formatTimeRemaining(timeRemaining) {
|
|
if (!timeRemaining) return 'N/A';
|
|
|
|
const parts = [];
|
|
if (timeRemaining.days > 0) parts.push(`${timeRemaining.days}d`);
|
|
if (timeRemaining.hours > 0) parts.push(`${timeRemaining.hours}h`);
|
|
if (timeRemaining.minutes > 0) parts.push(`${timeRemaining.minutes}m`);
|
|
if (timeRemaining.seconds > 0 && parts.length === 0) parts.push(`${timeRemaining.seconds}s`);
|
|
|
|
return parts.join(' ') || 'Less than 1 minute';
|
|
}
|
|
|
|
async endRollout() {
|
|
if (!confirm('Are you sure you want to end the onboarding period? This will allow new users to be greeted immediately.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/greeter/end-rollout', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.showSuccess(data.message || 'Onboarding period ended successfully');
|
|
// Reload data
|
|
await this.loadGreeterData();
|
|
this.renderRolloutStatus();
|
|
} else {
|
|
this.showError(data.error || 'Failed to end onboarding period');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error ending rollout:', error);
|
|
this.showError('Failed to end onboarding period: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async ungreetUser(senderId, channel) {
|
|
const channelText = channel === '(global)' ? 'globally' : `on channel ${channel}`;
|
|
if (!confirm(`Are you sure you want to mark ${senderId} as ungreeted ${channelText}? They will be greeted again on their next message.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/greeter/ungreet', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
body: JSON.stringify({
|
|
sender_id: senderId,
|
|
channel: channel === '(global)' ? null : channel
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.showSuccess(data.message || 'User marked as ungreeted');
|
|
// Reload data
|
|
const searchInput = document.getElementById('greeted-users-search');
|
|
const currentSearch = searchInput ? searchInput.value : '';
|
|
|
|
await this.loadGreeterData();
|
|
this.renderGreetedUsers();
|
|
// Reapply search filter if one was active
|
|
if (currentSearch) {
|
|
this.filterUsers(currentSearch);
|
|
}
|
|
} else {
|
|
this.showError(data.error || 'Failed to mark user as ungreeted');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error ungreeting user:', error);
|
|
this.showError('Failed to mark user as ungreeted: ' + error.message);
|
|
}
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
showError(message) {
|
|
if (window.connectionManager) {
|
|
window.connectionManager.showNotification(message, 'danger');
|
|
} else {
|
|
alert('Error: ' + message);
|
|
}
|
|
}
|
|
|
|
showSuccess(message) {
|
|
if (window.connectionManager) {
|
|
window.connectionManager.showNotification(message, 'success');
|
|
} else {
|
|
alert('Success: ' + message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize greeter manager when page loads
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.greeterManager = new GreeterManager();
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
/* Dark mode table styling with better contrast - matching contacts page */
|
|
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
|
|
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
|
|
}
|
|
|
|
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
|
|
background-color: #2d2d2d; /* Darker gray (card-bg) */
|
|
}
|
|
|
|
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
|
|
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
|
|
}
|
|
|
|
[data-theme="dark"] .table-dark {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .table-dark th {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .table td {
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
/* Ensure all text in table cells is light in dark mode */
|
|
[data-theme="dark"] .table td,
|
|
[data-theme="dark"] .table td * {
|
|
color: var(--text-color) !important;
|
|
}
|
|
|
|
/* Ensure small text is also light */
|
|
[data-theme="dark"] .table td small {
|
|
color: var(--text-muted) !important;
|
|
}
|
|
|
|
/* Code elements in tables should also be light */
|
|
[data-theme="dark"] .table td code {
|
|
color: var(--text-color) !important;
|
|
background-color: var(--bg-tertiary) !important;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|