updates and fixes

This commit is contained in:
admin 2025-08-25 10:32:42 -06:00
parent 785c7471c0
commit ee1f6fbf5c
16 changed files with 1075 additions and 5261 deletions

Binary file not shown.

View File

@ -405,3 +405,586 @@ curl -X GET http://10.0.0.193:5001/api/device/battery
- **Connection Monitoring**: Real-time status via `/api/connections/status`
This instruction set ensures consistent, high-quality development aligned with your preferences for JavaScript, HTML, CSS frontend work, comfortable Python backend development, Ubuntu environments, Docker usage, and lightweight Android solutions with reliable remote development capabilities.
## Database Schema & API Endpoints Reference
### Database Architecture
The SMS Campaign Manager uses **SQLite** with **TRUNCATE journal mode** to avoid WAL file locking issues in Docker environments. The database is located at `./data/campaign.db` with proper permissions and backup strategies.
#### Core Tables Schema
```sql
-- Campaigns table - Main campaign tracking
CREATE TABLE campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- Campaign display name
template TEXT NOT NULL, -- Message template with {name} placeholders
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP, -- When campaign execution began
completed_at TIMESTAMP, -- When campaign finished
status TEXT DEFAULT 'draft', -- 'draft', 'pending', 'running', 'completed', 'paused'
total_recipients INTEGER DEFAULT 0, -- Expected recipient count
total_sent INTEGER DEFAULT 0 -- Actually sent message count
);
-- Recipients table - Individual campaign targets
CREATE TABLE recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER, -- Foreign key to campaigns
phone TEXT NOT NULL, -- Target phone number (e.g., '7801234567')
name TEXT, -- Contact name for {name} substitution
status TEXT DEFAULT 'pending', -- 'pending', 'sent', 'failed'
sent_at TIMESTAMP, -- When message was delivered
error_message TEXT, -- Error details if failed
FOREIGN KEY (campaign_id) REFERENCES campaigns (id)
);
-- Messages table - SMS tracking and conversation history
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL, -- Phone number
message TEXT NOT NULL, -- Actual message content sent
direction TEXT DEFAULT 'outbound', -- 'outbound' (sent) or 'inbound' (received)
status TEXT DEFAULT 'pending', -- 'pending', 'sent', 'delivered', 'failed'
campaign_id INTEGER, -- Optional: link to campaign
name TEXT, -- Contact name
timestamp REAL, -- Unix timestamp from phone
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_read INTEGER DEFAULT 0, -- Read status for conversations
conversation_id TEXT, -- Conversation grouping identifier
FOREIGN KEY (campaign_id) REFERENCES campaigns (id)
);
-- Contact lists table - Reusable contact groups
CREATE TABLE contact_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- List display name
filename TEXT, -- Original CSV filename
contacts TEXT, -- JSON array of contact objects
contact_count INTEGER DEFAULT 0, -- Cached count for performance
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### Database Connection Best Practices
```python
# Avoid WAL mode issues in Docker - use TRUNCATE mode
def get_db_connection(db_path):
"""Get database connection with proper settings to avoid WAL issues"""
conn = sqlite3.connect(db_path, timeout=30.0, isolation_level='DEFERRED')
# Use TRUNCATE journal mode instead of WAL to avoid file locking issues
conn.execute("PRAGMA journal_mode=TRUNCATE")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA temp_store=MEMORY")
conn.execute("PRAGMA busy_timeout=30000")
return conn
# Retry logic for database operations
def execute_with_retry(operation, max_retries=3):
"""Execute database operation with retry logic"""
for attempt in range(max_retries):
try:
return operation()
except sqlite3.OperationalError as e:
if "disk I/O error" in str(e) and attempt < max_retries - 1:
logger.warning(f"Database I/O error, retrying... (attempt {attempt + 1}/{max_retries})")
time.sleep(1)
continue
else:
raise
```
### API Endpoints Documentation
#### Campaign Management
**POST /api/campaign/upload**
```javascript
// Upload CSV file with contact preview
const formData = new FormData();
formData.append('file', csvFile);
const response = await fetch('/api/campaign/upload', {
method: 'POST',
body: formData
});
// Response format:
{
"success": true,
"total_contacts": 4,
"contacts": [
{"name": "John Doe", "phone": "7801234567"},
{"name": "Jane Smith", "phone": "7801234568"}
],
"preview": [/* First 10 contacts for UI display */],
"message": "Successfully loaded 4 contacts"
}
```
**POST /api/campaign/create**
```javascript
// Create new campaign with recipients
const campaignData = {
name: "Weekend Volunteer Outreach",
message: "Hi {name}! Hope all is well. Are you available this weekend?",
csv_data: [
{"name": "John Doe", "phone": "7801234567"},
{"name": "Jane Smith", "phone": "7801234568"}
]
};
const response = await fetch('/api/campaign/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(campaignData)
});
// Response with contact preview:
{
"success": true,
"campaign_id": 24,
"campaign_name": "Weekend Volunteer Outreach",
"total_recipients": 2,
"contacts_preview": [
{
"name": "John Doe",
"phone": "7801234567",
"preview_message": "Hi John Doe! Hope all is well. Are you available this weekend?"
}
],
"message": "Campaign created with 2 recipients"
}
```
**POST /api/campaign/start**
```javascript
// Start campaign execution (fetches recipients from database)
const startData = {
campaign_id: 24
};
const response = await fetch('/api/campaign/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(startData)
});
// Response:
{
"success": true,
"status": "started",
"total": 2
}
```
**GET /api/campaigns/recent**
```javascript
// Get recent campaign list with status
const response = await fetch('/api/campaigns/recent');
const campaigns = await response.json();
// Response format:
[
{
"id": 24,
"name": "Weekend Volunteer Outreach",
"total_recipients": 2,
"sent_count": 2, // Actual messages sent
"status": "completed",
"created_at": "2025-08-25 16:17:00.364348"
}
]
```
#### Connection & Status Monitoring
**GET /api/phone/status**
```javascript
// Check dual SMS connection status
const response = await fetch('/api/phone/status');
const status = await response.json();
// Response format:
{
"termux_connected": true, // Termux API health check
"adb_connected": true, // ADB connection status
"connected": true, // Either connection available
"ip": "10.0.0.193",
"port": "5555",
"prefer_termux": true // Primary connection preference
}
```
**GET /api/connections/status**
```javascript
// Detailed connection diagnostics
const response = await fetch('/api/connections/status');
const connections = await response.json();
// Response format:
{
"connections": {
"termux_api": {
"available": true,
"url": "http://10.0.0.193:5001",
"type": "primary",
"last_check": "2025-08-25T16:20:00Z"
},
"adb": {
"available": true,
"target": "10.0.0.193:5555",
"type": "fallback"
}
},
"optimal_connection": "termux_api"
}
```
#### Contact List Management
**GET /api/lists**
```javascript
// Get saved contact lists
const response = await fetch('/api/lists');
const lists = await response.json();
// Response format:
[
{
"id": 1,
"name": "volunteers_2025.csv_20250825_120000",
"contact_count": 15,
"created_at": "2025-08-25T12:00:00Z"
}
]
```
**GET /api/lists/:id**
```javascript
// Load specific contact list with full contact data
const response = await fetch(`/api/lists/${listId}`);
const list = await response.json();
// Response format:
{
"id": 1,
"name": "volunteers_2025.csv",
"contacts": [
{"name": "John Doe", "phone": "7801234567"},
{"name": "Jane Smith", "phone": "7801234568"}
],
"contact_count": 2
}
```
#### Analytics & Reporting
**GET /api/analytics**
```javascript
// Campaign performance metrics
const response = await fetch('/api/analytics');
const analytics = await response.json();
// Response format:
{
"total_sent": 14,
"total_responded": 3,
"total_opted_out": 0,
"follow_ups": 2,
"response_types": [
{"type": "positive", "count": 2},
{"type": "neutral", "count": 1}
],
"recent_campaigns": [/* Latest 5 campaigns */]
}
```
### Frontend Integration Patterns
#### Alpine.js Data Structure
```javascript
function campaignApp() {
return {
// Connection monitoring
phoneStatus: {
termux_connected: false,
adb_connected: false,
connected: false,
last_check: null
},
// Campaign creation
campaignName: '',
messageTemplate: '',
contactsPreview: [], // First 10 contacts for preview
totalContacts: 0, // Full contact count
uploadedContacts: [], // All uploaded contacts
campaignReady: false, // Upload validation flag
// Recent campaigns (auto-refreshed)
recentCampaigns: [],
// Auto-refresh intervals
async init() {
await this.checkConnectionStatus();
await this.loadRecentCampaigns();
// Periodic updates
setInterval(() => this.checkConnectionStatus(), 10000);
setInterval(() => this.loadRecentCampaigns(), 15000);
},
// File upload with preview
async handleFileUpload(event) {
const file = event.target.files[0];
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/campaign/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
this.contactsPreview = data.preview;
this.totalContacts = data.total_contacts;
this.uploadedContacts = data.contacts;
this.campaignReady = true;
// Store for campaign creation
window.campaignContacts = data.contacts;
}
}
}
}
```
#### Error Handling Patterns
```javascript
// Consistent error handling across API calls
async function apiCall(endpoint, options = {}) {
try {
const response = await fetch(endpoint, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error(`API call failed: ${endpoint}`, error);
// User-friendly error display
if (error.message.includes('Failed to fetch')) {
throw new Error('Connection lost. Please check your internet connection.');
}
throw error;
}
}
// Usage example
async function startCampaign() {
try {
const result = await apiCall('/api/campaign/create', {
method: 'POST',
body: JSON.stringify({
name: this.campaignName,
message: this.messageTemplate,
csv_data: this.uploadedContacts
})
});
if (result.success) {
alert(`Campaign "${result.campaign_name}" created with ${result.total_recipients} recipients!`);
// Start the campaign
const startResult = await apiCall('/api/campaign/start', {
method: 'POST',
body: JSON.stringify({campaign_id: result.campaign_id})
});
if (startResult.success) {
this.campaignState.status = 'running';
this.campaignState.total = startResult.total;
}
}
} catch (error) {
alert(`Failed to start campaign: ${error.message}`);
}
}
```
### Database Maintenance & Troubleshooting
#### Common Issues & Solutions
**WAL File Locking (Fixed)**
```bash
# Database cleanup script (./fix-database.sh)
#!/bin/bash
echo "🔧 Fixing database and permissions..."
# Stop container
docker compose down
# Remove WAL and SHM files
rm -f data/campaign.db-wal data/campaign.db-shm
# Convert to TRUNCATE mode
sqlite3 data/campaign.db << EOF
PRAGMA journal_mode=TRUNCATE;
VACUUM;
.exit
EOF
# Fix permissions
chmod 777 data/
chmod 666 data/campaign.db
# Restart
docker compose up -d
```
**Campaign Data Integrity**
```sql
-- Verify campaign consistency
SELECT
c.id,
c.name,
c.total_recipients as expected,
COUNT(r.id) as actual_recipients,
c.total_sent,
COUNT(CASE WHEN r.status = 'sent' THEN 1 END) as actual_sent
FROM campaigns c
LEFT JOIN recipients r ON c.id = r.campaign_id
GROUP BY c.id
HAVING expected != actual_recipients;
-- Fix recipient counts
UPDATE campaigns
SET total_recipients = (
SELECT COUNT(*) FROM recipients WHERE campaign_id = campaigns.id
);
```
#### Performance Optimization
**Database Indexing**
```sql
-- Add indexes for common queries
CREATE INDEX IF NOT EXISTS idx_recipients_campaign_status
ON recipients(campaign_id, status);
CREATE INDEX IF NOT EXISTS idx_messages_phone_timestamp
ON messages(phone, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_campaigns_status_created
ON campaigns(status, created_at DESC);
```
**Connection Pooling**
```python
# SQLite connection management
import sqlite3
from contextlib import contextmanager
@contextmanager
def get_db_transaction():
"""Context manager for database transactions with proper cleanup"""
conn = None
try:
conn = get_db_connection(app.config['DATABASE'])
conn.execute("BEGIN")
yield conn
conn.commit()
except Exception as e:
if conn:
conn.rollback()
raise
finally:
if conn:
conn.close()
# Usage
async def create_campaign_with_recipients(campaign_data, recipients):
with get_db_transaction() as conn:
cursor = conn.cursor()
# Create campaign
cursor.execute(
"INSERT INTO campaigns (name, template, total_recipients, status) VALUES (?, ?, ?, ?)",
(campaign_data['name'], campaign_data['message'], len(recipients), 'pending')
)
campaign_id = cursor.lastrowid
# Batch insert recipients
cursor.executemany(
"INSERT INTO recipients (campaign_id, phone, name, status) VALUES (?, ?, ?, ?)",
[(campaign_id, r.get('phone'), r.get('name'), 'pending') for r in recipients]
)
return campaign_id
```
### Testing Database Operations
```python
# Test campaign creation end-to-end
def test_campaign_workflow():
"""Test complete campaign creation and execution workflow"""
# Setup
test_contacts = [
{"name": "Test User 1", "phone": "7801234567"},
{"name": "Test User 2", "phone": "7801234568"}
]
# Test campaign creation
response = client.post('/api/campaign/create', json={
'name': 'Test Campaign',
'message': 'Hello {name}!',
'csv_data': test_contacts
})
assert response.json['success'] == True
campaign_id = response.json['campaign_id']
# Verify database state
with get_db_connection() as conn:
cursor = conn.cursor()
# Check campaign record
cursor.execute("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
campaign = cursor.fetchone()
assert campaign['total_recipients'] == 2
# Check recipient records
cursor.execute("SELECT * FROM recipients WHERE campaign_id = ?", (campaign_id,))
recipients = cursor.fetchall()
assert len(recipients) == 2
assert all(r['status'] == 'pending' for r in recipients)
# Test campaign start
response = client.post('/api/campaign/start', json={
'campaign_id': campaign_id
})
assert response.json['success'] == True
assert response.json['total'] == 2
```
This comprehensive database and API reference ensures consistent implementation patterns, proper error handling, and maintainable code architecture aligned with the project's lightweight, Docker-based approach.

30
fix-database.sh Executable file
View File

@ -0,0 +1,30 @@
#!/bin/bash
echo "🔧 Fixing database and permissions..."
# Stop the container
docker compose down
# Remove WAL and SHM files
rm -f data/campaign.db-wal
rm -f data/campaign.db-shm
# Convert database to non-WAL mode
if [ -f "data/campaign.db" ]; then
sqlite3 data/campaign.db << EOF
PRAGMA journal_mode=TRUNCATE;
VACUUM;
.exit
EOF
fi
# Fix permissions
chmod 777 data/ 2>/dev/null || true
chmod 666 data/campaign.db 2>/dev/null || true
echo "✅ Database fixed. The app should now work properly."
# Restart
docker compose up -d
echo "📋 Check logs with: docker compose logs -f"

View File

@ -1,148 +0,0 @@
#!/bin/bash
#
# Enhanced Conversations Deployment Script
# Deploys all enhanced conversation components
#
set -e
echo "🚀 Deploying Enhanced Conversations System..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check if we're in the project root
if [ ! -f "src/app.py" ]; then
echo -e "${RED}❌ Please run this script from the project root directory${NC}"
exit 1
fi
echo -e "${BLUE}📋 Step 1: Running database migration...${NC}"
python3 scripts/migrate_conversations_db.py
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ Database migration completed${NC}"
else
echo -e "${RED}❌ Database migration failed${NC}"
exit 1
fi
echo -e "${BLUE}📋 Step 2: Integrating with main application...${NC}"
python3 scripts/integrate_enhanced_conversations.py
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ Application integration completed${NC}"
else
echo -e "${RED}❌ Application integration failed${NC}"
exit 1
fi
echo -e "${BLUE}📋 Step 3: Installing Python dependencies...${NC}"
cd src
# Try different installation methods
if command -v pip3 >/dev/null 2>&1; then
echo "Installing with pip3..."
pip3 install -r requirements.txt --break-system-packages --user
if [ $? -ne 0 ]; then
echo -e "${YELLOW}⚠️ Pip installation failed, trying with virtual environment...${NC}"
# Try with virtual environment
if ! command -v python3-venv >/dev/null 2>&1; then
echo "Installing python3-venv..."
sudo apt-get update && sudo apt-get install -y python3-venv
fi
if [ ! -d "../venv" ]; then
echo "Creating virtual environment..."
python3 -m venv ../venv
fi
echo "Installing dependencies in virtual environment..."
../venv/bin/pip install -r requirements.txt
fi
else
echo -e "${YELLOW}⚠️ Pip not found, dependencies may need manual installation${NC}"
fi
echo -e "${BLUE}📋 Step 3: Skipping Python dependencies (using Docker Compose)...${NC}"
echo -e "${GREEN}✅ Dependencies handled by Docker${NC}"
echo -e "${BLUE}📋 Step 4: Updating Android Termux API server...${NC}"
PHONE_IP=${PHONE_IP:-"10.0.0.193"}
PHONE_PORT=${PHONE_PORT:-"8022"}
PHONE_USER=${PHONE_USER:-"android-dev"}
# Check if Android device is reachable
if ping -c 1 -W 1 $PHONE_IP > /dev/null 2>&1; then
echo -e "${GREEN}📱 Android device is reachable at $PHONE_IP${NC}"
# Deploy updated Termux API server
echo "Uploading enhanced Termux API server..."
scp -P $PHONE_PORT android/termux-sms-api-server.py $PHONE_USER@$PHONE_IP:~/projects/sms-campaign-manager/
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ Termux API server updated${NC}"
# Restart the server
echo "Restarting Termux API server..."
ssh -p $PHONE_PORT $PHONE_USER "pkill -f termux-sms-api-server.py; cd ~/projects/sms-campaign-manager && python termux-sms-api-server.py > /dev/null 2>&1 &"
sleep 2
# Test the server
if curl -s http://$PHONE_IP:5001/health > /dev/null; then
echo -e "${GREEN}✅ Termux API server is running${NC}"
else
echo -e "${YELLOW}⚠️ Termux API server may need manual restart${NC}"
fi
else
echo -e "${YELLOW}⚠️ Could not update Termux API server - manual update required${NC}"
fi
else
echo -e "${YELLOW}⚠️ Android device not reachable - manual Termux server update required${NC}"
echo "Manually copy android/termux-sms-api-server.py to your Android device"
fi
echo -e "${BLUE}📋 Step 5: Testing the system...${NC}"
# Test database
echo "Testing database schema..."
if sqlite3 data/campaign.db ".schema conversations" | grep -q "is_starred"; then
echo -e "${GREEN}✅ Database schema is correct${NC}"
else
echo -e "${RED}❌ Database schema appears incorrect${NC}"
fi
# Test API endpoints
echo "Testing local API endpoints..."
if curl -s http://localhost:5000/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Main application is responding${NC}"
else
echo -e "${YELLOW}⚠️ Main application is not running${NC}"
fi
echo -e "${GREEN}🎉 Enhanced Conversations Deployment Complete!${NC}"
echo ""
echo -e "${BLUE}📋 What's New:${NC}"
echo "• WhatsApp-style conversation interface"
echo "• Real-time message updates via WebSocket"
echo "• Bidirectional SMS sync with Android device"
echo "• Message status tracking (pending, sent, delivered, failed)"
echo "• Contact name resolution from phone"
echo "• Conversation starring and importance marking"
echo "• Scrollable message history with pagination"
echo "• Manual message sending from conversation view"
echo ""
echo -e "${BLUE}🚀 Next Steps:${NC}"
echo "1. Start the application: docker-compose up -d OR python src/app.py"
echo "2. Open http://localhost:5000 in your browser"
echo "3. Go to the Conversations tab"
echo "4. Test sending messages and real-time sync"
echo ""
echo -e "${BLUE}🔧 If you encounter issues:${NC}"
echo "• Check logs: docker-compose logs -f"
echo "• Verify Android device connectivity"
echo "• Test Termux API: curl http://$PHONE_IP:5001/health"
echo "• Check WebSocket connection in browser dev tools"

View File

@ -1,181 +0,0 @@
#!/usr/bin/env python3
"""
Integration Script - Update main app.py for enhanced conversations
"""
import os
def update_main_app():
"""Add the necessary imports and initialization for enhanced conversations"""
app_py_path = './src/app.py'
# Read current app.py
with open(app_py_path, 'r') as f:
content = f.read()
# Add imports at the beginning
imports_to_add = """
# Enhanced conversations imports
from services.termux_sync_service import TermuxSyncService
from services.websocket_service import WebSocketService
from routes.conversations_enhanced import conversations_enhanced_bp, set_services
import threading
import asyncio
import logging
"""
# Add initialization code before app.run
init_code = """
# Initialize enhanced conversation services
try:
# Get phone IP from environment
phone_ip = os.getenv('PHONE_IP', '10.0.0.193')
termux_port = os.getenv('TERMUX_API_PORT', '5001')
termux_api_url = f"http://{phone_ip}:{termux_port}"
# Initialize sync service
sync_service = TermuxSyncService(termux_api_url)
# Initialize WebSocket service
websocket_service = WebSocketService(app, sync_service)
sync_service.set_websocket_service(websocket_service)
# Set service references for enhanced routes
set_services(sync_service, websocket_service)
# Register enhanced conversations blueprint
app.register_blueprint(conversations_enhanced_bp)
# Start background sync service
def run_sync_service():
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(sync_service.start_sync_loop())
except Exception as e:
logging.error(f"Sync service error: {e}")
sync_thread = threading.Thread(target=run_sync_service, daemon=True)
sync_thread.start()
logging.info("🚀 Enhanced conversation services initialized")
except Exception as e:
logging.error(f"Failed to initialize enhanced conversation services: {e}")
"""
# Check if already integrated
if 'termux_sync_service' in content:
print("✅ App already appears to be integrated with enhanced conversations")
return
# Add imports after existing imports
import_insertion_point = content.find('from models.conversation import Conversation')
if import_insertion_point != -1:
content = content[:import_insertion_point] + imports_to_add + '\n' + content[import_insertion_point:]
# Add initialization before app.run
app_run_point = content.find('app.run(')
if app_run_point != -1:
content = content[:app_run_point] + init_code + '\n' + content[app_run_point:]
# Write updated content
with open(app_py_path, 'w') as f:
f.write(content)
print("✅ Successfully updated app.py with enhanced conversation support")
def update_dashboard_template():
"""Update dashboard template to include enhanced conversations"""
template_path = './src/templates/dashboard.html'
enhanced_template_path = './src/templates/conversations_enhanced.html'
# Read the enhanced template
with open(enhanced_template_path, 'r') as f:
enhanced_content = f.read()
# Read the main dashboard
with open(template_path, 'r') as f:
dashboard_content = f.read()
# Find and replace the conversations tab
start_marker = '<!-- Conversations Tab -->'
end_marker = '<!-- End Conversations Tab -->'
if start_marker not in dashboard_content:
# Look for the existing conversations tab
start_marker = '<div x-show="activeTab === \'conversations\'"'
end_marker = '</div>\n </div>'
start_pos = dashboard_content.find(start_marker)
if start_pos == -1:
print("❌ Could not find conversations tab in dashboard.html")
return
# Find the end of the conversations div
div_count = 0
end_pos = start_pos
in_div = False
for i, char in enumerate(dashboard_content[start_pos:], start_pos):
if char == '<':
if dashboard_content[i:i+4] == '<div':
div_count += 1
in_div = True
elif dashboard_content[i:i+6] == '</div>':
div_count -= 1
if div_count == 0 and in_div:
end_pos = i + 6
break
else:
start_pos = dashboard_content.find(start_marker)
end_pos = dashboard_content.find(end_marker, start_pos) + len(end_marker)
# Replace with enhanced template
if start_pos != -1 and end_pos != -1:
new_content = (dashboard_content[:start_pos] +
'<!-- Enhanced Conversations Tab -->\n' +
enhanced_content +
'\n<!-- End Enhanced Conversations Tab -->\n' +
dashboard_content[end_pos:])
# Add enhanced JavaScript import
if 'conversations_enhanced.js' not in new_content:
js_insertion_point = new_content.find('<script src="/static/js/conversations.js"></script>')
if js_insertion_point != -1:
new_content = (new_content[:js_insertion_point] +
'<script src="/static/js/conversations_enhanced.js"></script>\n' +
new_content[js_insertion_point:])
# Add Socket.IO CDN
if 'socket.io' not in new_content:
head_end = new_content.find('</head>')
if head_end != -1:
new_content = (new_content[:head_end] +
' <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>\n' +
new_content[head_end:])
with open(template_path, 'w') as f:
f.write(new_content)
print("✅ Successfully updated dashboard.html with enhanced conversations")
else:
print("❌ Could not locate conversations section in dashboard.html")
if __name__ == "__main__":
print("🔄 Integrating enhanced conversations...")
# Update main application
update_main_app()
# Update dashboard template
update_dashboard_template()
print("✅ Integration complete!")
print("\n📝 Next steps:")
print("1. Run database migration: python scripts/migrate_conversations_db.py")
print("2. Update Android Termux API server")
print("3. Restart the application")
print("4. Test the enhanced conversations interface")

View File

@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""
Database Migration Script - Enhance conversations schema for WhatsApp-style features
"""
import sqlite3
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
def migrate_conversations_schema(db_path='./data/campaign.db'):
"""Add new fields to support enhanced conversations"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
migrations = [
# Add new fields to conversations table
("ALTER TABLE conversations ADD COLUMN contact_name TEXT DEFAULT ''", "contact_name field"),
("ALTER TABLE conversations ADD COLUMN is_starred BOOLEAN DEFAULT FALSE", "is_starred field"),
("ALTER TABLE conversations ADD COLUMN last_sync_timestamp INTEGER DEFAULT 0", "last_sync_timestamp field"),
("ALTER TABLE conversations ADD COLUMN total_message_count INTEGER DEFAULT 0", "total_message_count field"),
# Add new fields to messages table for status tracking
("ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'delivered', 'failed'))", "message status field"),
("ALTER TABLE messages ADD COLUMN external_message_id TEXT", "external_message_id field"),
("ALTER TABLE messages ADD COLUMN sync_status TEXT DEFAULT 'local' CHECK(sync_status IN ('local', 'synced', 'pending_sync'))", "sync_status field"),
("ALTER TABLE messages ADD COLUMN direction TEXT DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound'))", "direction field"),
("ALTER TABLE messages ADD COLUMN timestamp INTEGER", "timestamp field"),
# Create indexes for performance
("CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)", "timestamp index"),
("CREATE INDEX IF NOT EXISTS idx_conversations_starred ON conversations(is_starred)", "starred conversations index"),
("CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status)", "message status index"),
("CREATE INDEX IF NOT EXISTS idx_messages_direction ON messages(direction)", "message direction index"),
("CREATE INDEX IF NOT EXISTS idx_messages_sync_status ON messages(sync_status)", "sync status index"),
]
for migration_sql, description in migrations:
try:
cursor.execute(migration_sql)
logger.info(f"✅ Applied: {description}")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e) or "already exists" in str(e):
logger.info(f"⏭️ Skipped: {description} (already exists)")
else:
logger.error(f"❌ Failed: {description} - {e}")
raise
# Update existing messages to have proper timestamp and direction
cursor.execute("""
UPDATE messages
SET timestamp = CAST(strftime('%s', sent_at) AS INTEGER)
WHERE timestamp IS NULL AND sent_at IS NOT NULL
""")
cursor.execute("""
UPDATE messages
SET timestamp = CAST(strftime('%s', 'now') AS INTEGER)
WHERE timestamp IS NULL
""")
cursor.execute("""
UPDATE messages
SET direction = CASE
WHEN response_text IS NOT NULL AND response_text != '' THEN 'inbound'
ELSE 'outbound'
END
WHERE direction IS NULL
""")
conn.commit()
conn.close()
logger.info("🚀 Database migration completed successfully!")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
migrate_conversations_schema()

View File

@ -1,278 +0,0 @@
#!/usr/bin/env python3
"""
Enhanced Conversations Test Suite
Test all components of the enhanced conversation system
"""
import requests
import sqlite3
import json
import time
import logging
from typing import Dict, List, Optional
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class EnhancedConversationTester:
"""Test suite for enhanced conversation features"""
def __init__(self, base_url='http://localhost:5000', phone_ip='10.0.0.193'):
self.base_url = base_url.rstrip('/')
self.phone_ip = phone_ip
self.phone_api_url = f'http://{phone_ip}:5001'
self.db_path = './data/campaign.db'
self.test_phone = '5551234567' # Test phone number
def run_all_tests(self):
"""Run comprehensive test suite"""
logger.info("🧪 Starting Enhanced Conversations Test Suite")
tests = [
("Database Schema", self.test_database_schema),
("API Endpoints", self.test_api_endpoints),
("WebSocket Connection", self.test_websocket_connection),
("Android API Connection", self.test_android_api_connection),
("Message Flow", self.test_message_flow),
("Conversation Management", self.test_conversation_management),
("Real-time Updates", self.test_realtime_updates)
]
passed = 0
failed = 0
for test_name, test_func in tests:
try:
logger.info(f"🔍 Testing: {test_name}")
test_func()
logger.info(f"{test_name}: PASSED")
passed += 1
except Exception as e:
logger.error(f"{test_name}: FAILED - {e}")
failed += 1
# Summary
total = passed + failed
success_rate = (passed / total * 100) if total > 0 else 0
logger.info(f"\n📊 Test Results: {passed}/{total} passed ({success_rate:.1f}%)")
if failed > 0:
logger.warning(f"⚠️ {failed} tests failed - check logs for details")
else:
logger.info("🎉 All tests passed!")
return failed == 0
def test_database_schema(self):
"""Test database schema has required fields"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# Check conversations table
cursor.execute("PRAGMA table_info(conversations)")
columns = [row[1] for row in cursor.fetchall()]
required_columns = ['is_starred', 'contact_name', 'last_sync_timestamp']
for col in required_columns:
if col not in columns:
raise Exception(f"Missing column '{col}' in conversations table")
# Check messages table
cursor.execute("PRAGMA table_info(messages)")
columns = [row[1] for row in cursor.fetchall()]
required_columns = ['status', 'direction', 'timestamp', 'sync_status']
for col in required_columns:
if col not in columns:
raise Exception(f"Missing column '{col}' in messages table")
conn.close()
def test_api_endpoints(self):
"""Test enhanced API endpoints"""
endpoints = [
'/api/conversations/enhanced',
'/api/conversations/stats',
]
for endpoint in endpoints:
response = requests.get(f"{self.base_url}{endpoint}")
if response.status_code != 200:
raise Exception(f"Endpoint {endpoint} returned {response.status_code}")
data = response.json()
if not data.get('success'):
raise Exception(f"Endpoint {endpoint} returned error: {data.get('error')}")
def test_websocket_connection(self):
"""Test WebSocket server is available"""
try:
# Test if Socket.IO endpoint responds
response = requests.get(f"{self.base_url}/socket.io/?EIO=4&transport=polling")
if response.status_code not in [200, 400]: # 400 is expected for wrong transport
raise Exception(f"WebSocket server not responding: {response.status_code}")
except requests.RequestException as e:
raise Exception(f"WebSocket server connection failed: {e}")
def test_android_api_connection(self):
"""Test Android Termux API connection"""
try:
response = requests.get(f"{self.phone_api_url}/health", timeout=5)
if response.status_code != 200:
raise Exception(f"Android API health check failed: {response.status_code}")
data = response.json()
if not data.get('success'):
raise Exception(f"Android API health check error: {data.get('error')}")
# Test enhanced endpoints
response = requests.get(f"{self.phone_api_url}/api/sms/history?limit=1", timeout=5)
if response.status_code != 200:
logger.warning("SMS history endpoint may not be available")
except requests.RequestException as e:
raise Exception(f"Android API connection failed: {e}")
def test_message_flow(self):
"""Test message creation and management"""
# Create a test conversation
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
conversation_id = f"test_conv_{int(time.time())}"
# Insert test conversation
cursor.execute("""
INSERT INTO conversations (id, phone, contact_name, status, created_at)
VALUES (?, ?, 'Test Contact', 'active', datetime('now'))
""", (conversation_id, self.test_phone))
# Insert test message
cursor.execute("""
INSERT INTO messages (conversation_id, phone, message, direction, status, timestamp)
VALUES (?, ?, 'Test message', 'outbound', 'pending', ?)
""", (conversation_id, self.test_phone, int(time.time())))
conn.commit()
# Test API retrieval
response = requests.get(f"{self.base_url}/api/conversations/{conversation_id}/messages")
if response.status_code != 200:
conn.close()
raise Exception(f"Failed to retrieve messages: {response.status_code}")
data = response.json()
if not data.get('success') or not data.get('messages'):
conn.close()
raise Exception("No messages returned from API")
# Clean up
cursor.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
cursor.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
conn.commit()
conn.close()
def test_conversation_management(self):
"""Test conversation CRUD operations"""
# Create test conversation
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
conversation_id = f"test_conv_mgmt_{int(time.time())}"
cursor.execute("""
INSERT INTO conversations (id, phone, contact_name, status, is_starred, created_at)
VALUES (?, ?, 'Test Management', 'active', 0, datetime('now'))
""", (conversation_id, self.test_phone))
conn.commit()
try:
# Test starring
response = requests.put(f"{self.base_url}/api/conversations/{conversation_id}/star")
if response.status_code != 200:
raise Exception(f"Failed to toggle star: {response.status_code}")
data = response.json()
if not data.get('success'):
raise Exception(f"Star toggle error: {data.get('error')}")
# Verify starring worked
cursor.execute("SELECT is_starred FROM conversations WHERE id = ?", (conversation_id,))
row = cursor.fetchone()
if not row or not row[0]:
raise Exception("Conversation was not starred")
# Test mark as read
response = requests.put(f"{self.base_url}/api/conversations/{conversation_id}/mark-read")
if response.status_code != 200:
raise Exception(f"Failed to mark as read: {response.status_code}")
finally:
# Clean up
cursor.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
cursor.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
conn.commit()
conn.close()
def test_realtime_updates(self):
"""Test real-time update mechanisms"""
# This is a basic test - full WebSocket testing would require a client
try:
# Test sync endpoints
response = requests.post(f"{self.base_url}/api/conversations/sync-all")
if response.status_code not in [200, 503]: # 503 if sync service unavailable
raise Exception(f"Sync all endpoint failed: {response.status_code}")
if response.status_code == 200:
data = response.json()
if not data.get('success'):
raise Exception(f"Sync all error: {data.get('error')}")
except requests.RequestException as e:
raise Exception(f"Real-time update test failed: {e}")
def test_data_integrity(self):
"""Test data integrity and constraints"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# Test that we can't create invalid status values
try:
cursor.execute("""
INSERT INTO messages (phone, message, status, direction)
VALUES (?, 'test', 'invalid_status', 'outbound')
""", (self.test_phone,))
conn.commit()
raise Exception("Database allowed invalid status value")
except sqlite3.IntegrityError:
# This is expected
pass
conn.close()
def main():
"""Run the test suite"""
import argparse
parser = argparse.ArgumentParser(description='Test Enhanced Conversations System')
parser.add_argument('--base-url', default='http://localhost:5000',
help='Base URL for the main application')
parser.add_argument('--phone-ip', default='10.0.0.193',
help='IP address of Android device')
parser.add_argument('--verbose', action='store_true',
help='Enable verbose logging')
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
tester = EnhancedConversationTester(args.base_url, args.phone_ip)
success = tester.run_all_tests()
return 0 if success else 1
if __name__ == "__main__":
exit(main())

Binary file not shown.

View File

@ -580,96 +580,89 @@ def is_termux_api_available():
"""Check if Termux API is available"""
return sms_manager.connection_status.get(ConnectionType.TERMUX_API, False)
def init_db():
"""Initialize SQLite database with schema"""
db_path = Path(app.config['DATABASE'])
db_path.parent.mkdir(parents=True, exist_ok=True)
"""Initialize database with proper error handling"""
max_retries = 3
for attempt in range(max_retries):
try:
# Ensure database directory exists
os.makedirs(os.path.dirname(app.config['DATABASE']), exist_ok=True)
conn = sqlite3.connect(app.config['DATABASE'], timeout=30.0)
# Use TRUNCATE journal mode instead of WAL to avoid file locking issues in Docker
conn.execute("PRAGMA journal_mode=TRUNCATE")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA temp_store=MEMORY")
conn.execute("PRAGMA busy_timeout=30000")
cursor = conn.cursor()
# Create tables if they don't exist
cursor.execute('''
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
message_template TEXT,
total_recipients INTEGER DEFAULT 0,
sent_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER,
phone TEXT NOT NULL,
name TEXT,
status TEXT DEFAULT 'pending',
sent_at TIMESTAMP,
error_message TEXT,
FOREIGN KEY (campaign_id) REFERENCES campaigns (id)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
message TEXT NOT NULL,
direction TEXT DEFAULT 'outbound',
status TEXT DEFAULT 'pending',
campaign_id INTEGER,
name TEXT,
timestamp REAL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_read INTEGER DEFAULT 0,
conversation_id TEXT,
FOREIGN KEY (campaign_id) REFERENCES campaigns (id)
)
''')
conn.commit()
conn.close()
logger.info("Database initialized successfully with TRUNCATE journal mode")
return True
except sqlite3.OperationalError as e:
if "disk I/O error" in str(e) and attempt < max_retries - 1:
logger.warning(f"Database initialization I/O error, retrying... (attempt {attempt + 1}/{max_retries})")
time.sleep(2)
continue
else:
logger.error(f"Failed to initialize database after {max_retries} attempts: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error initializing database: {e}")
raise
conn = sqlite3.connect(str(db_path))
c = conn.cursor()
# Campaigns table
c.execute('''
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
template TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT DEFAULT 'draft',
total_recipients INTEGER DEFAULT 0,
total_sent INTEGER DEFAULT 0,
total_responded INTEGER DEFAULT 0
)
''')
# Messages table
c.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER,
phone TEXT NOT NULL,
name TEXT,
message TEXT,
sent_at TIMESTAMP,
responded BOOLEAN DEFAULT 0,
response_text TEXT,
response_type TEXT,
response_received_at TIMESTAMP,
needs_followup_after TIMESTAMP,
followup_sent BOOLEAN DEFAULT 0,
connection_type TEXT DEFAULT 'adb',
FOREIGN KEY (campaign_id) REFERENCES campaigns (id)
)
''')
# Add connection_type column to existing messages table if it doesn't exist
try:
c.execute("ALTER TABLE messages ADD COLUMN connection_type TEXT DEFAULT 'adb'")
except sqlite3.OperationalError:
# Column already exists
pass
# Connection status table for monitoring
c.execute('''
CREATE TABLE IF NOT EXISTS connection_status (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
connection_type TEXT NOT NULL,
status TEXT NOT NULL,
details TEXT
)
''')
# Templates table
c.execute('''
CREATE TABLE IF NOT EXISTS templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
content TEXT NOT NULL,
variables TEXT,
times_used INTEGER DEFAULT 0,
avg_response_rate REAL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Response analytics table
c.execute('''
CREATE TABLE IF NOT EXISTS response_analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
total_messages_sent INTEGER DEFAULT 1,
total_responses INTEGER DEFAULT 0,
last_response_at TIMESTAMP,
opted_out BOOLEAN DEFAULT 0,
opted_out_at TIMESTAMP
)
''')
conn.commit()
conn.close()
logger.info("Database initialized")
return False
# Initialize conversation model and ensure schema
conversation_model = Conversation(app.config['DATABASE'])
@ -677,9 +670,9 @@ conversation_model.ensure_schema()
# Database helper functions
def get_db():
"""Get database connection with proper timeout and WAL mode"""
"""Get database connection with proper timeout and TRUNCATE mode"""
conn = sqlite3.connect(app.config['DATABASE'], timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA journal_mode=TRUNCATE")
conn.execute("PRAGMA busy_timeout=30000")
conn.row_factory = sqlite3.Row
return conn
@ -1198,8 +1191,32 @@ def index():
@app.route('/api/phone/status')
def phone_status():
"""Check phone connection status"""
connected = check_phone_connection()
return jsonify({"connected": connected, "ip": PHONE_IP, "port": ADB_PORT})
# Check both Termux API and ADB connections
termux_connected = False
adb_connected = False
# Check Termux API
try:
response = requests.get(f'http://{PHONE_IP}:5001/health', timeout=2)
termux_connected = response.status_code == 200
except:
termux_connected = False
# Check ADB connection
try:
result = subprocess.run(['adb', 'devices'], capture_output=True, text=True, timeout=5)
adb_connected = f'{PHONE_IP}:5555' in result.stdout and 'device' in result.stdout
except:
adb_connected = False
return jsonify({
"termux_connected": termux_connected,
"adb_connected": adb_connected,
"connected": termux_connected or adb_connected,
"ip": PHONE_IP,
"port": ADB_PORT,
"prefer_termux": True
})
@app.route('/api/phone/connect', methods=['POST'])
def connect_phone():
@ -1213,25 +1230,112 @@ def connect_phone():
@app.route('/api/campaign/create', methods=['POST'])
def create_campaign():
"""Create new campaign"""
data = request.json
name = data.get('name', f'Campaign {datetime.now().strftime("%Y-%m-%d %H:%M")}')
template = data.get('template', '')
if not template:
return jsonify({"error": "Message template required"}), 400
conn = get_db()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO campaigns (name, template) VALUES (?, ?)",
(name, template)
)
campaign_id = cursor.lastrowid
conn.commit()
conn.close()
return jsonify({"id": campaign_id, "name": name})
"""Create a new campaign with contact preview"""
try:
data = request.get_json()
campaign_name = data.get('name', f"Campaign {datetime.now().strftime('%Y%m%d_%H%M%S')}")
message_template = data.get('message', '')
csv_data = data.get('csv_data', [])
list_id = data.get('list_id')
if not csv_data and not list_id:
return jsonify({'success': False, 'error': 'No contacts provided'}), 400
# Prepare contacts with preview
contacts_preview = []
for contact in csv_data[:10]: # Show first 10 contacts as preview
phone = contact.get('phone', '').strip()
name = contact.get('name', '').strip()
if phone:
contacts_preview.append({
'phone': phone,
'name': name,
'preview_message': message_template.replace('{name}', name) if name else message_template
})
# Use a more robust database connection with retries
max_retries = 3
for attempt in range(max_retries):
try:
conn = sqlite3.connect(app.config['DATABASE'], timeout=30.0)
conn.execute("PRAGMA journal_mode=TRUNCATE")
conn.execute("PRAGMA busy_timeout=30000")
cursor = conn.cursor()
# Create campaign
cursor.execute(
"INSERT INTO campaigns (name, template, total_recipients, status, created_at) VALUES (?, ?, ?, ?, ?)",
(campaign_name, message_template, len(csv_data), 'pending', datetime.now())
)
campaign_id = cursor.lastrowid
# Store recipients
for contact in csv_data:
phone = contact.get('phone', '').strip()
name = contact.get('name', '').strip()
if phone:
cursor.execute(
"INSERT INTO recipients (campaign_id, phone, name, status) VALUES (?, ?, ?, ?)",
(campaign_id, phone, name, 'pending')
)
conn.commit()
conn.close()
logger.info(f"Campaign created: {campaign_name} with {len(csv_data)} recipients")
return jsonify({
'success': True,
'campaign_id': campaign_id,
'campaign_name': campaign_name,
'total_recipients': len(csv_data),
'contacts_preview': contacts_preview,
'message': f'Campaign created with {len(csv_data)} recipients'
})
except sqlite3.OperationalError as e:
if "disk I/O error" in str(e) and attempt < max_retries - 1:
logger.warning(f"Database I/O error, retrying... (attempt {attempt + 1}/{max_retries})")
time.sleep(1)
continue
else:
raise
except Exception as e:
logger.error(f"Error creating campaign: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/campaigns/recent')
def get_recent_campaigns():
"""Get recent campaigns"""
try:
conn = get_db()
cursor = conn.cursor()
cursor.execute("""
SELECT id, name, total_recipients, total_sent, status, created_at
FROM campaigns
ORDER BY created_at DESC
LIMIT 5
""")
campaigns = []
for row in cursor.fetchall():
campaigns.append({
'id': row[0],
'name': row[1],
'total_recipients': row[2] or 0,
'sent_count': row[3] or 0,
'status': row[4],
'created_at': row[5]
})
conn.close()
return jsonify(campaigns)
except Exception as e:
logger.error(f"Error getting recent campaigns: {e}")
return jsonify([])
@app.route('/api/campaign/start', methods=['POST'])
def start_campaign():
@ -1248,26 +1352,53 @@ def start_campaign():
data = request.json
logger.info(f"Campaign start request data: {data}")
campaign_id = data.get('campaign_id')
recipients = data.get('recipients', [])
logger.info(f"Campaign ID: {campaign_id}, Recipients count: {len(recipients) if recipients else 0}")
if not campaign_id:
logger.info("Campaign start failed: No campaign ID provided")
return jsonify({"error": "No campaign ID provided"}), 400
if not recipients:
logger.info("Campaign start failed: No recipients provided")
return jsonify({"error": "No recipients provided"}), 400
# Update campaign with recipient count
execute_db(
"UPDATE campaigns SET total_recipients = ? WHERE id = ?",
(len(recipients), campaign_id)
)
# Start campaign in background thread
thread = Thread(target=run_campaign, args=(campaign_id, recipients))
thread.daemon = True
thread.start()
return jsonify({"status": "started", "total": len(recipients)})
# Fetch recipients from database
try:
conn = get_db()
cursor = conn.cursor()
cursor.execute("""
SELECT phone, name FROM recipients
WHERE campaign_id = ? AND status = 'pending'
""", (campaign_id,))
recipients_data = cursor.fetchall()
recipients = []
for row in recipients_data:
recipients.append({
'phone': row[0],
'name': row[1] or ''
})
conn.close()
logger.info(f"Campaign ID: {campaign_id}, Recipients count: {len(recipients)}")
if not recipients:
logger.info("Campaign start failed: No pending recipients found in database")
return jsonify({"error": "No pending recipients found"}), 400
# Update campaign status
execute_db(
"UPDATE campaigns SET status = 'running', started_at = ? WHERE id = ?",
(datetime.now(), campaign_id)
)
# Start campaign in background thread
thread = Thread(target=run_campaign, args=(campaign_id, recipients))
thread.daemon = True
thread.start()
return jsonify({"success": True, "status": "started", "total": len(recipients)})
except Exception as e:
logger.error(f"Error starting campaign: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/campaign/pause', methods=['POST'])
def pause_campaign():
@ -1392,6 +1523,61 @@ def upload_csv():
return jsonify({"error": "Invalid file type"}), 400
@app.route('/api/campaign/upload', methods=['POST'])
def upload_campaign_csv():
"""Handle CSV file upload with preview for campaigns"""
try:
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'error': 'No file selected'}), 400
if not file.filename.lower().endswith('.csv'):
return jsonify({'success': False, 'error': 'Only CSV files are allowed'}), 400
# Read and parse CSV
content = file.read().decode('utf-8')
csv_reader = csv.DictReader(content.splitlines())
contacts = []
preview_contacts = []
for i, row in enumerate(csv_reader):
# Normalize field names
normalized_row = {}
for key, value in row.items():
if key and value:
normalized_key = key.lower().strip()
if 'phone' in normalized_key or 'number' in normalized_key:
normalized_row['phone'] = value.strip()
elif 'name' in normalized_key:
normalized_row['name'] = value.strip()
elif 'message' in normalized_key:
normalized_row['message'] = value.strip()
if 'phone' in normalized_row:
contacts.append(normalized_row)
# Add to preview (first 10)
if i < 10:
preview_contacts.append(normalized_row)
if not contacts:
return jsonify({'success': False, 'error': 'No valid contacts found in CSV'}), 400
return jsonify({
'success': True,
'total_contacts': len(contacts),
'contacts': contacts,
'preview': preview_contacts,
'message': f'Successfully loaded {len(contacts)} contacts'
})
except Exception as e:
logger.error(f"Error uploading CSV: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/responses/sync', methods=['POST'])
def sync_responses_endpoint():
"""Manually trigger response sync"""

View File

@ -1,505 +0,0 @@
/**
* Conversations Manager - Frontend JavaScript for SMS conversation threading
* Integrates with the SMS Campaign Manager dashboard for real-time conversation management
*/
class ConversationManager {
constructor() {
this.conversations = [];
this.activeConversation = null;
this.filters = {
status: 'all',
campaign: 'all',
search: ''
};
this.stats = {};
// Bind methods
this.init = this.init.bind(this);
this.loadConversations = this.loadConversations.bind(this);
this.loadConversation = this.loadConversation.bind(this);
this.markAsRead = this.markAsRead.bind(this);
this.updateNotes = this.updateNotes.bind(this);
this.manageTags = this.manageTags.bind(this);
this.searchConversations = this.searchConversations.bind(this);
this.renderThreadList = this.renderThreadList.bind(this);
this.renderMessages = this.renderMessages.bind(this);
this.renderStats = this.renderStats.bind(this);
}
async init() {
console.log('Initializing Conversation Manager...');
try {
console.log('Loading conversation stats...');
await this.loadStats();
console.log('Loading conversations...');
await this.loadConversations();
console.log('Rendering stats and thread list...');
this.renderStats();
this.renderThreadList();
console.log('Conversation Manager initialized successfully');
// Auto-refresh every 30 seconds
setInterval(() => {
console.log('Auto-refreshing conversations...');
this.loadStats();
this.loadConversations();
}, 30000);
} catch (error) {
console.error('Error initializing conversations:', error);
}
}
async loadStats() {
try {
console.log('Loading conversation stats...');
const response = await fetch('/api/conversations/stats');
const data = await response.json();
console.log('Stats API response:', data);
if (data.success) {
this.stats = data.stats;
console.log('Loaded stats:', this.stats);
} else {
console.error('Failed to load stats:', data.error);
}
} catch (error) {
console.error('Error loading conversation stats:', error);
}
}
async loadConversations() {
try {
console.log('Loading conversations with filters:', this.filters);
const params = new URLSearchParams();
if (this.filters.status !== 'all') {
params.append('status', this.filters.status);
}
if (this.filters.campaign !== 'all') {
params.append('campaign_id', this.filters.campaign);
}
const url = `/api/conversations/?${params.toString()}`;
console.log('Fetching conversations from:', url);
const response = await fetch(url);
const data = await response.json();
console.log('Conversation API response:', data);
if (data.success) {
this.conversations = data.conversations;
console.log(`Loaded ${this.conversations.length} conversations`);
this.renderThreadList();
// Auto-select first conversation if none selected
if (!this.activeConversation && this.conversations.length > 0) {
console.log('Auto-selecting first conversation:', this.conversations[0].id);
await this.selectConversation(this.conversations[0].id);
}
} else {
console.error('Failed to load conversations:', data.error);
}
} catch (error) {
console.error('Error loading conversations:', error);
this.conversations = [];
this.renderThreadList();
}
}
async loadConversation(conversationId) {
try {
const response = await fetch(`/api/conversations/${conversationId}`);
const data = await response.json();
if (data.success) {
this.activeConversation = data.conversation;
this.renderMessages();
return this.activeConversation;
} else {
throw new Error(data.error || 'Failed to load conversation');
}
} catch (error) {
console.error('Error loading conversation:', error);
this.activeConversation = null;
this.renderMessages();
}
}
async selectConversation(conversationId) {
await this.loadConversation(conversationId);
// Update UI selection
document.querySelectorAll('.thread-item').forEach(item => {
item.classList.remove('selected');
});
const selectedItem = document.querySelector(`[data-conversation-id="${conversationId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
}
}
async markAsRead(conversationId) {
try {
const response = await fetch(`/api/conversations/${conversationId}/read`, {
method: 'PUT'
});
const data = await response.json();
if (data.success) {
// Update local data
const conv = this.conversations.find(c => c.id === conversationId);
if (conv) {
conv.unread_count = 0;
}
if (this.activeConversation && this.activeConversation.id === conversationId) {
this.activeConversation.unread_count = 0;
this.activeConversation.messages.forEach(msg => {
if (msg.responded) msg.is_read = true;
});
this.renderMessages();
}
this.renderThreadList();
await this.loadStats();
this.renderStats();
}
} catch (error) {
console.error('Error marking conversation as read:', error);
}
}
async updateNotes(conversationId, notes) {
try {
const response = await fetch(`/api/conversations/${conversationId}/notes`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes })
});
const data = await response.json();
if (data.success) {
// Update local data
const conv = this.conversations.find(c => c.id === conversationId);
if (conv) {
conv.notes = notes;
}
if (this.activeConversation && this.activeConversation.id === conversationId) {
this.activeConversation.notes = notes;
}
this.renderThreadList();
this.renderMessages();
return true;
}
} catch (error) {
console.error('Error updating notes:', error);
}
return false;
}
async manageTags(conversationId, tags, action = 'set') {
try {
const response = await fetch(`/api/conversations/${conversationId}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags, action })
});
const data = await response.json();
if (data.success) {
// Reload conversation to get updated tags
if (this.activeConversation && this.activeConversation.id === conversationId) {
await this.loadConversation(conversationId);
}
await this.loadConversations();
return true;
}
} catch (error) {
console.error('Error managing tags:', error);
}
return false;
}
async searchConversations(query) {
if (!query.trim()) {
this.filters.search = '';
await this.loadConversations();
return;
}
try {
const response = await fetch(`/api/conversations/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.success) {
this.conversations = data.conversations;
this.filters.search = query;
this.renderThreadList();
}
} catch (error) {
console.error('Error searching conversations:', error);
}
}
async migrateExistingMessages() {
try {
const response = await fetch('/api/conversations/migrate', { method: 'POST' });
const data = await response.json();
if (data.success) {
console.log(data.message);
await this.loadConversations();
await this.loadStats();
this.renderStats();
return true;
}
} catch (error) {
console.error('Error migrating messages:', error);
}
return false;
}
renderStats() {
const statsContainer = document.querySelector('#conversation-stats');
if (!statsContainer) return;
statsContainer.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">${this.stats.total_conversations || 0}</div>
<div class="text-sm text-gray-600">Total Conversations</div>
</div>
<div class="bg-orange-50 rounded-lg p-4">
<div class="text-2xl font-bold text-orange-600">${this.stats.total_unread || 0}</div>
<div class="text-sm text-gray-600">Unread Messages</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">${this.stats.active_conversations || 0}</div>
<div class="text-sm text-gray-600">Active Threads</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600">${this.stats.avg_response_rate || 0}%</div>
<div class="text-sm text-gray-600">Avg Response Rate</div>
</div>
</div>
`;
}
renderThreadList() {
const container = document.querySelector('#conversation-threads');
if (!container) return;
if (this.conversations.length === 0) {
container.innerHTML = `
<div class="text-center py-8 text-gray-500">
<div class="text-lg mb-2">No conversations found</div>
<div class="text-sm">Messages will appear here after campaigns are sent</div>
<button onclick="conversationManager.migrateExistingMessages()"
class="mt-3 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
Migrate Existing Messages
</button>
</div>
`;
return;
}
const threadsHTML = this.conversations.map(conv => {
const lastActivity = new Date(conv.last_message_at);
const isUnread = conv.unread_count > 0;
const tagsList = conv.tags.length > 0 ?
conv.tags.map(tag => `<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${tag}</span>`).join(' ') : '';
return `
<div class="thread-item border-b border-gray-200 p-4 cursor-pointer hover:bg-gray-50 ${isUnread ? 'bg-blue-50' : ''}"
data-conversation-id="${conv.id}"
onclick="conversationManager.selectConversation('${conv.id}')">
<div class="flex justify-between items-start mb-2">
<div class="flex-1">
<div class="font-semibold text-gray-900 flex items-center">
${conv.name || conv.phone}
${isUnread ? `<span class="ml-2 bg-red-500 text-white text-xs rounded-full px-2 py-1">${conv.unread_count}</span>` : ''}
</div>
<div class="text-sm text-gray-500">${conv.phone}</div>
${conv.campaign_name ? `<div class="text-xs text-purple-600">Campaign: ${conv.campaign_name}</div>` : ''}
</div>
<div class="text-xs text-gray-400">${lastActivity.toLocaleDateString()}</div>
</div>
<div class="text-sm text-gray-600 mb-2">
📤 ${conv.total_messages} sent 💬 ${conv.total_responses} replies
</div>
${tagsList ? `<div class="mb-2">${tagsList}</div>` : ''}
${conv.notes ? `<div class="text-xs text-gray-500 italic">📝 ${conv.notes.substring(0, 50)}${conv.notes.length > 50 ? '...' : ''}</div>` : ''}
</div>
`;
}).join('');
container.innerHTML = threadsHTML;
}
renderMessages() {
const container = document.querySelector('#conversation-messages');
if (!container) return;
if (!this.activeConversation) {
container.innerHTML = `
<div class="text-center py-8 text-gray-500">
<div class="text-lg">Select a conversation to view messages</div>
<div class="text-sm">Choose a thread from the left panel</div>
</div>
`;
return;
}
const conv = this.activeConversation;
const messagesHTML = conv.messages.map(msg => {
const sentTime = new Date(msg.sent_at);
const isResponse = msg.responded && msg.response_text;
const isUnread = isResponse && !msg.is_read;
return `
<div class="message-item mb-4 ${isUnread ? 'bg-blue-50' : ''} p-4 rounded-lg">
<!-- Sent Message -->
<div class="flex justify-end mb-2">
<div class="bg-blue-500 text-white px-4 py-2 rounded-lg max-w-xs lg:max-w-md">
<div class="text-sm">${msg.message}</div>
<div class="text-xs opacity-75 mt-1">
${sentTime.toLocaleString()} ${msg.connection_type}
</div>
</div>
</div>
<!-- Response Message -->
${isResponse ? `
<div class="flex justify-start">
<div class="bg-gray-200 text-gray-900 px-4 py-2 rounded-lg max-w-xs lg:max-w-md">
<div class="text-sm">${msg.response_text}</div>
<div class="text-xs text-gray-600 mt-1">
${new Date(msg.response_received_at).toLocaleString()}
${msg.response_type ? `${msg.response_type}` : ''}
${isUnread ? ' • <strong>NEW</strong>' : ''}
</div>
</div>
</div>
` : ''}
${msg.notes ? `<div class="text-xs text-gray-500 mt-2 italic">Note: ${msg.notes}</div>` : ''}
</div>
`;
}).join('');
const tagsHTML = conv.tags.map(tag =>
`<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded mr-1">${tag}</span>`
).join('');
container.innerHTML = `
<!-- Conversation Header -->
<div class="border-b border-gray-200 p-4 bg-white sticky top-0">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="text-lg font-semibold">${conv.name || conv.phone}</h3>
<p class="text-sm text-gray-500">${conv.phone}</p>
${conv.campaign_name ? `<p class="text-xs text-purple-600">Campaign: ${conv.campaign_name}</p>` : ''}
</div>
<div class="text-right text-xs text-gray-400">
<div>📤 ${conv.total_messages} sent</div>
<div>💬 ${conv.total_responses} replies</div>
${conv.unread_count > 0 ? `<div class="text-red-600 font-semibold">${conv.unread_count} unread</div>` : ''}
</div>
</div>
<!-- Tags -->
<div class="mb-3">
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-600">Tags:</span>
${tagsHTML}
<button onclick="conversationManager.showTagEditor('${conv.id}')"
class="text-blue-500 hover:text-blue-700 text-sm">+ Add</button>
</div>
</div>
<!-- Notes -->
<div class="mb-3">
<div class="flex items-start gap-2">
<span class="text-sm text-gray-600 mt-1">Notes:</span>
<textarea id="notes-${conv.id}" class="flex-1 text-sm border border-gray-300 rounded px-2 py-1 resize-none"
rows="2" placeholder="Add notes about this conversation..."
onblur="conversationManager.saveNotes('${conv.id}', this.value)">${conv.notes || ''}</textarea>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2">
${conv.unread_count > 0 ? `
<button onclick="conversationManager.markAsRead('${conv.id}')"
class="bg-blue-500 text-white px-3 py-1 text-sm rounded hover:bg-blue-600">
Mark as Read
</button>
` : ''}
<button onclick="conversationManager.refreshConversation('${conv.id}')"
class="bg-gray-500 text-white px-3 py-1 text-sm rounded hover:bg-gray-600">
Refresh
</button>
</div>
</div>
<!-- Messages -->
<div class="p-4 max-h-96 overflow-y-auto">
${messagesHTML}
</div>
`;
}
async refreshConversation(conversationId) {
console.log('Refreshing conversation:', conversationId);
await this.loadConversation(conversationId);
}
async saveNotes(conversationId, notes) {
await this.updateNotes(conversationId, notes);
}
showTagEditor(conversationId) {
const tags = this.activeConversation ? this.activeConversation.tags : [];
const currentTags = tags.join(', ');
const newTags = prompt('Enter tags (comma-separated):', currentTags);
if (newTags !== null) {
const tagList = newTags.split(',').map(t => t.trim()).filter(t => t);
this.manageTags(conversationId, tagList, 'set');
}
}
// Utility methods for integration with main dashboard
getUnreadCount() {
return this.stats.total_unread || 0;
}
hasActiveConversations() {
return (this.stats.active_conversations || 0) > 0;
}
getConversationSummary() {
return {
total: this.stats.total_conversations || 0,
unread: this.stats.total_unread || 0,
active: this.stats.active_conversations || 0,
responseRate: this.stats.avg_response_rate || 0
};
}
}
// Global instance for dashboard integration
window.conversationManager = new ConversationManager();

View File

@ -21,6 +21,11 @@ function campaignApp() {
savedLists: [],
campaignReady: false,
// Contact preview variables
contactsPreview: [],
totalContacts: 0,
uploadedContacts: [],
// Campaign state
campaignState: {
status: 'idle',
@ -76,6 +81,7 @@ function campaignApp() {
setInterval(() => this.checkConnectionStatus(), 10000); // Check every 10 seconds
setInterval(() => this.updateStatus(), 2000); // Campaign status updates
setInterval(() => this.loadAnalytics(), 10000); // Analytics updates
setInterval(() => this.loadRecentCampaigns(), 15000); // Recent campaigns updates every 15 seconds
// Listen for saved list loads from the ListManager UI
document.addEventListener('saved-list-loaded', (e) => {
@ -140,11 +146,12 @@ function campaignApp() {
async loadRecentCampaigns() {
try {
const response = await fetch('/api/analytics');
const data = await response.json();
this.recentCampaigns = data.recent_campaigns || [];
const response = await fetch('/api/campaigns/recent');
const campaigns = await response.json();
this.recentCampaigns = campaigns || [];
console.log('Recent campaigns loaded:', this.recentCampaigns.length);
} catch (error) {
console.error('Failed to load campaigns:', error);
console.error('Failed to load recent campaigns:', error);
this.recentCampaigns = []; // Set empty array on error
}
},
@ -166,78 +173,138 @@ function campaignApp() {
formData.append('file', file);
try {
const response = await fetch('/api/csv/upload', {
const response = await fetch('/api/campaign/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
this.uploadedFile = data.filename;
this.recipients = data.recipients || [];
this.uploadedFile = file.name;
this.uploadedContacts = data.contacts || [];
this.contactsPreview = data.preview || data.contacts.slice(0, 10) || [];
this.totalContacts = data.total_contacts || data.contacts.length || 0;
this.campaignReady = true;
// Store globally for campaign creation
window.campaignContacts = data.contacts;
console.log(`Loaded ${this.totalContacts} contacts`);
} else {
alert('Error uploading file: ' + data.error);
this.resetContactData();
}
} catch (error) {
console.error('Upload failed:', error);
alert('Upload failed: ' + error.message);
this.resetContactData();
}
}
},
// Reset contact data
resetContactData() {
this.uploadedFile = null;
this.uploadedContacts = [];
this.contactsPreview = [];
this.totalContacts = 0;
this.campaignReady = false;
window.campaignContacts = [];
},
// Load saved list
async loadSavedList(listId) {
if (!listId) {
this.resetContactData();
return;
}
try {
const response = await fetch(`/api/lists/${listId}`);
const list = await response.json();
if (list && list.contacts) {
this.uploadedContacts = list.contacts;
this.contactsPreview = list.contacts.slice(0, 10);
this.totalContacts = list.contacts.length;
this.campaignReady = true;
this.uploadedFile = `${list.name} (saved list)`;
// Store globally for campaign creation
window.campaignContacts = list.contacts;
console.log(`Loaded saved list: ${list.name} with ${this.totalContacts} contacts`);
} else {
alert('Error loading saved list');
this.resetContactData();
}
} catch (error) {
console.error('Error loading saved list:', error);
alert('Failed to load saved list');
this.resetContactData();
}
},
// Campaign management
async startCampaign() {
if (!this.messageTemplate || this.recipients.length === 0) {
alert('Please provide a message template and recipients');
if (!this.campaignReady || !this.messageTemplate.trim()) {
alert('Please upload contacts and enter a message template');
return;
}
// Check if we have any connections available
await this.loadConnectionStatus();
if (!this.connectionStatus.optimal_connection) {
alert('❌ No SMS connections available!\\n\\nPlease check:\\n• Termux API server is running\\n• ADB connection is active\\n\\nRun connection tests to diagnose.');
return;
}
const connectionType = this.connectionStatus.optimal_connection === 'termux_api' ? 'Termux API' : 'ADB';
if (!confirm(`Start campaign using ${connectionType} connection?\\n\\nRecipients: ${this.recipients.length}\\nConnection: ${connectionType}`)) {
return;
}
// Create campaign with contact data
try {
// Create campaign
const createResponse = await fetch('/api/campaign/create', {
const contactData = window.campaignContacts || this.uploadedContacts || [];
if (contactData.length === 0) {
alert('No contacts loaded. Please upload a CSV file or select a saved list.');
return;
}
const response = await fetch('/api/campaign/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.campaignName || `Campaign ${new Date().toLocaleString()}`,
template: this.messageTemplate
name: this.campaignName || `Campaign ${new Date().toISOString().split('T')[0]}`,
message: this.messageTemplate,
csv_data: contactData,
list_id: this.selectedList
})
});
const campaign = await createResponse.json();
this.currentCampaignId = campaign.id;
// Start campaign
const startResponse = await fetch('/api/campaign/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaign_id: campaign.id,
recipients: this.recipients
})
});
const result = await startResponse.json();
if (result.error) {
alert('Error: ' + result.error);
const result = await response.json();
if (result.success) {
this.currentCampaignId = result.campaign_id;
alert(`Campaign "${result.campaign_name}" created with ${result.total_recipients} recipients!`);
// Start the campaign
const startResponse = await fetch('/api/campaign/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ campaign_id: this.currentCampaignId })
});
const startResult = await startResponse.json();
if (startResult.success) {
this.campaignState.status = 'running';
this.campaignState.total = startResult.total || result.total_recipients;
console.log(`Campaign started successfully with ${startResult.total} recipients`);
alert(`Campaign started successfully! Sending to ${startResult.total} recipients.`);
} else {
console.error('Failed to start campaign:', startResult.error);
alert(`Failed to start campaign: ${startResult.error}`);
}
// Reload recent campaigns
await this.loadRecentCampaigns();
} else {
this.campaignState.status = 'running';
this.campaignState.total = result.total;
alert('Campaign started successfully!');
alert(`Failed to create campaign: ${result.error}`);
}
} catch (error) {
alert('Error starting campaign: ' + error.message);
console.error('Error starting campaign:', error);
alert('Failed to start campaign. Check console for details.');
}
},

View File

@ -1,114 +0,0 @@
<!-- Enhanced Conversations Component -->
<div x-data="conversationData()" x-init="init()" class="h-full">
<div class="flex h-[calc(100vh-300px)]">
<!-- Left Panel: Conversation List -->
<div class="w-1/3 border-r border-gray-200 overflow-hidden flex flex-col">
<!-- Search and Filter -->
<div class="p-4 border-b border-gray-200">
<input type="text"
x-model="conversationSearch"
@input="searchConversations()"
placeholder="Search conversations..."
class="w-full px-3 py-2 border rounded-lg">
<div class="flex gap-2 mt-2">
<button @click="setFilter('all')"
:class="conversationFilter === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-200'"
class="px-3 py-1 rounded text-sm">All</button>
<button @click="setFilter('unread')"
:class="conversationFilter === 'unread' ? 'bg-blue-500 text-white' : 'bg-gray-200'"
class="px-3 py-1 rounded text-sm">Unread</button>
<button @click="setFilter('starred')"
:class="conversationFilter === 'starred' ? 'bg-blue-500 text-white' : 'bg-gray-200'"
class="px-3 py-1 rounded text-sm">Starred</button>
</div>
</div>
<!-- Conversation List -->
<div class="flex-1 overflow-y-auto">
<template x-for="conversation in filteredConversations" :key="conversation.id">
<div @click="selectConversation(conversation.id)"
:class="selectedConversation?.id === conversation.id ? 'bg-blue-50' : 'hover:bg-gray-50'"
class="p-4 border-b cursor-pointer">
<div class="flex items-center justify-between">
<div class="flex items-center flex-1">
<div class="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
<span x-text="getInitials(conversation)"></span>
</div>
<div class="flex-1">
<div class="font-medium" x-text="conversation.contact_name || formatPhone(conversation.phone)"></div>
<div class="text-sm text-gray-600 truncate" x-text="formatLastMessage(conversation)"></div>
</div>
</div>
<div class="text-xs text-gray-500" x-text="formatTime(conversation.last_message_time)"></div>
</div>
</div>
</template>
</div>
</div>
<!-- Right Panel: Message View -->
<div class="flex-1 flex flex-col">
<template x-if="selectedConversation">
<div class="flex flex-col h-full">
<!-- Header -->
<div class="p-4 border-b border-gray-200 bg-white">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold" x-text="selectedConversation.contact_name || formatPhone(selectedConversation.phone)"></h3>
<p class="text-sm text-gray-600" x-text="selectedConversation.phone"></p>
</div>
<div class="flex gap-2">
<button @click="toggleStar(selectedConversation.id)"
class="p-2 hover:bg-gray-100 rounded">
<span x-text="selectedConversation.is_starred ? '⭐' : '☆'"></span>
</button>
<button @click="syncConversation(selectedConversation.id)"
:disabled="syncing"
class="p-2 hover:bg-gray-100 rounded">
🔄
</button>
</div>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-2">
<template x-for="message in messages" :key="message.id">
<div :class="message.direction === 'outbound' ? 'flex justify-end' : 'flex justify-start'">
<div :class="message.direction === 'outbound' ? 'bg-blue-500 text-white' : 'bg-gray-200'"
class="max-w-xs px-4 py-2 rounded-lg">
<p x-text="message.message"></p>
<p class="text-xs mt-1 opacity-75" x-text="formatMessageTime(message.timestamp)"></p>
</div>
</div>
</template>
</div>
<!-- Input -->
<div class="p-4 border-t border-gray-200">
<div class="flex gap-2">
<input type="text"
x-model="newMessage"
@keyup.enter="sendMessage()"
placeholder="Type a message..."
class="flex-1 px-3 py-2 border rounded-lg">
<button @click="sendMessage()"
:disabled="sendingMessage || !newMessage"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50">
Send
</button>
</div>
</div>
</div>
</template>
<!-- Empty State -->
<template x-if="!selectedConversation">
<div class="flex items-center justify-center h-full text-gray-500">
Select a conversation to view messages
</div>
</template>
</div>
</div>
</div>

View File

@ -101,12 +101,34 @@
</div>
</div>
<!-- Contact Preview Section -->
<div x-show="contactsPreview.length > 0" class="border rounded-lg p-4 bg-blue-50">
<h3 class="font-semibold text-blue-800 mb-2">📋 Contacts Preview</h3>
<div class="text-sm text-blue-600 mb-2">
Total: <span x-text="totalContacts"></span> contacts loaded
</div>
<div class="max-h-40 overflow-y-auto space-y-1">
<template x-for="(contact, index) in contactsPreview" :key="`preview-${index}`">
<div class="text-sm bg-white p-2 rounded border">
<span class="font-medium" x-text="contact.name || 'No Name'"></span> -
<span class="text-gray-600" x-text="contact.phone"></span>
<div x-show="contact.preview_message" class="text-xs text-gray-500 mt-1">
Preview: <span x-text="(contact.preview_message || '').substring(0, 50) + '...'"></span>
</div>
</div>
</template>
</div>
<div x-show="totalContacts > contactsPreview.length" class="text-xs text-blue-500 mt-2">
... and <span x-text="totalContacts - contactsPreview.length"></span> more contacts
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Use Saved List</label>
<select x-model="selectedList" class="w-full px-3 py-2 border rounded-lg">
<select x-model="selectedList" @change="loadSavedList($event.target.value)" class="w-full px-3 py-2 border rounded-lg">
<option value="">-- Select a saved list --</option>
<template x-for="list in savedLists" :key="list.id">
<option :value="list.id" x-text="`${list.name} (${list.count} contacts)`"></option>
<template x-for="(list, index) in savedLists" :key="`list-${index}-${list.id || ''}`">
<option :value="list.id" x-text="`${list.name} (${list.count || list.contact_count || 0} contacts)`"></option>
</template>
</select>
</div>
@ -177,11 +199,11 @@
No recent campaigns
</div>
<div class="space-y-2">
<template x-for="campaign in recentCampaigns" :key="campaign.id">
<template x-for="(campaign, index) in recentCampaigns" :key="`campaign-${index}-${campaign.id || ''}`">
<div class="border-b pb-2">
<div class="font-medium" x-text="campaign.name"></div>
<div class="text-sm text-gray-600">
<span x-text="campaign.sent_count"></span> sent •
<span x-text="campaign.sent_count || 0"></span> sent •
<span x-text="formatDate(campaign.created_at)"></span>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,311 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SMS Campaign Manager</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/dashboard.css">
</head>
<body class="bg-gray-50">
<div x-data="campaignApp()" x-init="init()" x-cloak class="container mx-auto px-4 py-8 max-w-7xl">
<!-- Header with Connection Status -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-800">📱 SMS Campaign Manager</h1>
<p class="text-gray-600 mt-1">Homelab Campaign Management Interface</p>
</div>
<div class="flex flex-col space-y-2">
<!-- Termux API Status -->
<div class="flex items-center">
<span class="text-sm font-medium text-gray-600 mr-2 w-20">Termux:</span>
<span x-show="phoneStatus.termux_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
🟢 Online
</span>
<span x-show="!phoneStatus.termux_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
🔴 Offline
</span>
</div>
<!-- ADB Status -->
<div class="flex items-center">
<span class="text-sm font-medium text-gray-600 mr-2 w-20">ADB:</span>
<span x-show="phoneStatus.adb_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
🟢 Online
</span>
<span x-show="!phoneStatus.adb_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
🔴 Offline
</span>
</div>
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="bg-white rounded-t-lg shadow-sm border-b">
<nav class="flex space-x-1 p-1">
<button @click="activeTab = 'campaigns'"
:class="activeTab === 'campaigns' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
class="px-4 py-2 rounded-lg font-medium transition-colors">
📋 Campaigns
</button>
<button @click="activeTab = 'conversations'; loadConversations()"
:class="activeTab === 'conversations' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
class="px-4 py-2 rounded-lg font-medium transition-colors">
💬 Conversations
</button>
<button @click="activeTab = 'testing'"
:class="activeTab === 'testing' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
class="px-4 py-2 rounded-lg font-medium transition-colors">
🧪 System Testing
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="bg-white rounded-b-lg shadow-sm min-h-[600px]">
<!-- Campaigns Tab (Preserving existing functionality) -->
<div x-show="activeTab === 'campaigns'" class="p-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Create Campaign Section -->
<div class="border rounded-lg p-4">
<h2 class="text-xl font-semibold mb-4">Create Campaign</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Campaign Name</label>
<input type="text" x-model="campaignName"
placeholder="e.g., Weekend Volunteer Outreach"
class="w-full px-3 py-2 border rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Message Template <span class="text-gray-500">(Use {name}, {phone}, {date}, {time} for variables)</span>
</label>
<textarea x-model="messageTemplate"
placeholder="Hi {name}! Hope all is well. I am wondering if you got my last email..."
class="w-full px-3 py-2 border rounded-lg h-24"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Recipients CSV</label>
<input type="file" @change="handleFileUpload($event)"
accept=".csv"
class="w-full px-3 py-2 border rounded-lg">
<div x-show="uploadedFile" class="mt-2 text-sm text-green-600">
<span x-text="uploadedFile"></span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Use Saved List</label>
<select x-model="selectedList" class="w-full px-3 py-2 border rounded-lg">
<option value="">-- Select a saved list --</option>
<template x-for="list in savedLists" :key="list.id">
<option :value="list.id" x-text="`${list.name} (${list.count} contacts)`"></option>
</template>
</select>
</div>
<div class="flex gap-2">
<button @click="saveTemplate()"
class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600">
Save Template
</button>
<button @click="testSMS()"
class="bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600">
Test SMS
</button>
<button @click="startCampaign()"
:disabled="!campaignReady"
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 disabled:opacity-50">
Start Campaign
</button>
</div>
</div>
</div>
<!-- Campaign Status Section -->
<div class="space-y-4">
<!-- Analytics -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-3">Campaign Analytics</h3>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-2xl font-bold text-blue-600" x-text="analytics.total_sent || 0"></div>
<div class="text-sm text-gray-600">Total Sent</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600" x-text="analytics.responses || 0"></div>
<div class="text-sm text-gray-600">Responses</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-yellow-600" x-text="analytics.follow_ups || 0"></div>
<div class="text-sm text-gray-600">Follow-ups Needed</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-purple-600" x-text="analytics.opt_outs || 0"></div>
<div class="text-sm text-gray-600">Opt-outs</div>
</div>
</div>
</div>
<!-- Response Types -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-3">Response Types</h3>
<div x-show="!responseTypes.length" class="text-gray-500 text-sm">
No responses yet
</div>
<div class="space-y-2">
<template x-for="type in responseTypes" :key="type.type">
<div class="flex justify-between items-center">
<span x-text="type.type" class="text-sm"></span>
<span x-text="type.count" class="font-medium"></span>
</div>
</template>
</div>
</div>
<!-- Recent Campaigns -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-3">Recent Campaigns</h3>
<div x-show="!recentCampaigns.length" class="text-gray-500 text-sm">
No recent campaigns
</div>
<div class="space-y-2">
<template x-for="campaign in recentCampaigns" :key="campaign.id">
<div class="border-b pb-2">
<div class="font-medium" x-text="campaign.name"></div>
<div class="text-sm text-gray-600">
<span x-text="campaign.sent_count"></span> sent •
<span x-text="formatDate(campaign.created_at)"></span>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Conversations Tab -->
<div x-show="activeTab === 'conversations'" class="p-6">
<div id="conversations-container">
<!-- Enhanced conversations component will be loaded here -->
<include src="conversations_enhanced_component.html"></include>
</div>
</div>
<!-- System Testing Tab -->
<div x-show="activeTab === 'testing'" class="p-6">
<h2 class="text-xl font-semibold mb-4">🧪 System Testing & Diagnostics</h2>
<!-- Connection Tests -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- Termux API Test -->
<div class="border rounded-lg p-4">
<h3 class="font-medium mb-3">📡 Termux API Test</h3>
<div class="text-sm text-gray-600 mb-3">
Endpoint: <code class="bg-gray-100 px-1">http://{{ phoneIP }}:5001</code>
</div>
<button @click="testTermuxConnection()"
:disabled="testingTermux"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50 transition-colors">
<span x-show="!testingTermux">Test Termux API</span>
<span x-show="testingTermux">Testing...</span>
</button>
<div x-show="termuxTestResult" class="mt-3 p-3 rounded text-sm"
:class="termuxTestResult?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'">
<pre x-text="JSON.stringify(termuxTestResult, null, 2)"></pre>
</div>
</div>
<!-- ADB Connection Test -->
<div class="border rounded-lg p-4">
<h3 class="font-medium mb-3">🔌 ADB Connection Test</h3>
<div class="text-sm text-gray-600 mb-3">
Device: <code class="bg-gray-100 px-1">{{ phoneIP }}:5555</code>
</div>
<button @click="testAdbConnection()"
:disabled="testingAdb"
class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 disabled:opacity-50 transition-colors">
<span x-show="!testingAdb">Test ADB</span>
<span x-show="testingAdb">Testing...</span>
</button>
<div x-show="adbTestResult" class="mt-3 p-3 rounded text-sm"
:class="adbTestResult?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'">
<pre x-text="JSON.stringify(adbTestResult, null, 2)"></pre>
</div>
</div>
</div>
<!-- Test SMS Send -->
<div class="border rounded-lg p-4 mb-6">
<h3 class="font-medium mb-3">📨 Test SMS Send</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="tel" x-model="testPhone"
placeholder="Phone number (e.g., 7801234567)"
class="border rounded px-3 py-2">
<input type="text" x-model="testMessage"
placeholder="Test message"
class="border rounded px-3 py-2">
</div>
<div class="mt-4 flex gap-2">
<button @click="sendTestSms('termux')"
:disabled="!testPhone || sendingTest"
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 disabled:opacity-50 transition-colors">
Send via Termux
</button>
<button @click="sendTestSms('adb')"
:disabled="!testPhone || sendingTest"
class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 disabled:opacity-50 transition-colors">
Send via ADB
</button>
<button @click="sendTestSms('auto')"
:disabled="!testPhone || sendingTest"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50 transition-colors">
Auto (Best Available)
</button>
</div>
<div x-show="testSmsResult" class="mt-3 p-3 rounded text-sm"
:class="testSmsResult?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'">
<pre x-text="JSON.stringify(testSmsResult, null, 2)"></pre>
</div>
</div>
<!-- System Information -->
<div class="border rounded-lg p-4">
<h3 class="font-medium mb-3"> System Information</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium">Phone IP:</span>
<span x-text="phoneIP"></span>
</div>
<div>
<span class="font-medium">Preferred Method:</span>
<span x-text="phoneStatus.prefer_termux ? 'Termux API' : 'ADB'"></span>
</div>
<div>
<span class="font-medium">Last Check:</span>
<span x-text="formatTime(phoneStatus.last_check)"></span>
</div>
<div>
<span class="font-medium">Active Connection:</span>
<span x-text="phoneStatus.termux_connected ? 'Termux' : (phoneStatus.adb_connected ? 'ADB' : 'None')"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Load JavaScript files -->
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/lists.js"></script>
<script src="/static/js/conversations_enhanced.js"></script>
</body>
</html>

5
test-contacts.csv Normal file
View File

@ -0,0 +1,5 @@
name,phone
John Doe,7801234567
Jane Smith,7801234568
Bob Johnson,7801234569
Alice Brown,7801234570
1 name phone
2 John Doe 7801234567
3 Jane Smith 7801234568
4 Bob Johnson 7801234569
5 Alice Brown 7801234570