updates and fixes
This commit is contained in:
parent
785c7471c0
commit
ee1f6fbf5c
BIN
data/campaign.db
BIN
data/campaign.db
Binary file not shown.
583
docs/instruct.md
583
docs/instruct.md
@ -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
30
fix-database.sh
Executable 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"
|
||||
@ -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"
|
||||
@ -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")
|
||||
@ -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()
|
||||
@ -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.
446
src/app.py
446
src/app.py
@ -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"""
|
||||
|
||||
@ -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();
|
||||
@ -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.');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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
@ -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
5
test-contacts.csv
Normal file
@ -0,0 +1,5 @@
|
||||
name,phone
|
||||
John Doe,7801234567
|
||||
Jane Smith,7801234568
|
||||
Bob Johnson,7801234569
|
||||
Alice Brown,7801234570
|
||||
|
Loading…
x
Reference in New Issue
Block a user