6 Commits

Author SHA1 Message Date
f0806d7e67 Add comprehensive Android tablet debugging guide
All checks were successful
Create Release / build (push) Successful in 4s
Added detailed debugging documentation for troubleshooting browser phone
issues on Android tablets. Covers USB debugging setup, Chrome DevTools
connection, common issues, error codes, and testing procedures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-14 08:37:26 -08:00
61beadcd06 Fix browser phone connection and audio issues on Android tablets
All checks were successful
Create Release / build (push) Successful in 6s
Resolves issues where browser phone PWA failed to connect and calls would
immediately hang up when answered on Android tablets. Adds proper mobile
audio handling, device connection monitoring, and PWA notifications for
incoming calls.

Key changes:
- Add AudioContext initialization with mobile unlock for autoplay support
- Add Android-specific WebRTC constraints (echo cancellation, ICE restart)
- Add device connection state monitoring and auto-reconnection
- Add incoming call ringtone with vibration fallback
- Add PWA service worker notifications for background calls
- Add Page Visibility API for background call detection
- Improve call answer handler with connection state validation
- Add touch event support for mobile dialpad

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-12 13:21:29 -08:00
026edde33b Fix update-version workflow to update TWP_VERSION constant
All checks were successful
Create Release / build (push) Successful in 3s
The workflow was only updating the Version comment but not the TWP_VERSION constant, causing the local repository to show the placeholder while releases showed the actual version.

Now updates both:
- Version: header comment
- TWP_VERSION constant

This matches the release.yml workflow and ensures version consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:46:46 -08:00
a3345ed854 Use version placeholder for auto-deployment
All checks were successful
Create Release / build (push) Successful in 3s
Replace hardcoded version with {auto_update_value_on_deploy} placeholder that gets replaced during the Gitea workflow build process.

Changes:
- Updated Version comment to use placeholder
- Updated TWP_VERSION constant to use placeholder
- Modified release workflow to replace both instances of the placeholder

This matches the pattern used in the fourthwall plugin and ensures the version is automatically set during the release build process.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:32:42 -08:00
384ad5e265 Add Gitea CI/CD workflows for automated releases
All checks were successful
Create Release / build (push) Successful in 2s
Add automated release creation and versioning workflows similar to the fourthwall plugin.

Features:
- Automatically creates a release on every push to main branch
- Generates release notes from commit messages since last tag
- Updates plugin version using timestamp-based versioning (YYYY.MM.DD-HHMM)
- Creates and uploads plugin zip file to release
- Supports manual release creation/editing to update version

Workflows:
- .gitea/workflows/release.yml - Triggers on push to main
- .gitea/workflows/update-version.yml - Triggers on release creation/edit

The release zip will be automatically picked up by the Gitea auto-updater for WordPress.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:13:42 -08:00
86dd477d4f Add mobile app infrastructure and Gitea auto-update support
This commit adds comprehensive mobile app support to enable a native Android app that won't timeout or sleep when the screen goes dark.

New Features:
- JWT-based authentication system (no WordPress session dependency)
- REST API endpoints for mobile app (agent status, queue management, call control)
- Server-Sent Events (SSE) for real-time updates to mobile app
- Firebase Cloud Messaging (FCM) integration for push notifications
- Gitea-based automatic plugin updates
- Mobile app admin settings page

New Files:
- includes/class-twp-mobile-auth.php - JWT authentication with login/refresh/logout
- includes/class-twp-mobile-api.php - REST API endpoints under /twilio-mobile/v1
- includes/class-twp-mobile-sse.php - Real-time event streaming
- includes/class-twp-fcm.php - Push notification handling
- includes/class-twp-auto-updater.php - Gitea-based auto-updates
- admin/mobile-app-settings.php - Admin configuration page

Modified Files:
- includes/class-twp-activator.php - Added twp_mobile_sessions table
- includes/class-twp-core.php - Load and initialize mobile classes
- admin/class-twp-admin.php - Added Mobile App menu item and settings page

Database Changes:
- New table: twp_mobile_sessions (stores JWT refresh tokens and FCM tokens)

API Endpoints:
- POST /twilio-mobile/v1/auth/login
- POST /twilio-mobile/v1/auth/refresh
- POST /twilio-mobile/v1/auth/logout
- GET/POST /twilio-mobile/v1/agent/status
- GET /twilio-mobile/v1/queues/state
- POST /twilio-mobile/v1/calls/{call_sid}/accept
- GET /twilio-mobile/v1/stream/events (SSE)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 15:43:14 -08:00
16 changed files with 3404 additions and 30 deletions

View File

@@ -0,0 +1,82 @@
name: Create Release
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_TOKEN }}
fetch-depth: 0 # Important: Fetch all history for commit messages
- name: Get version
id: get_version
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=$(date +'%Y.%m.%d-%H%M')" >> $GITHUB_OUTPUT
fi
- name: Generate release notes
id: release_notes
run: |
# Find the most recent tag
LATEST_TAG=$(git describe --tags --abbrev=0 --always 2>/dev/null || echo "none")
if [ "$LATEST_TAG" = "none" ]; then
# If no previous tag exists, get all commits
COMMITS=$(git log --pretty=format:"* %s (%h)" --no-merges)
else
# Get commits since the last tag
COMMITS=$(git log --pretty=format:"* %s (%h)" ${LATEST_TAG}..HEAD --no-merges)
fi
# Create release notes with header (without encoding newlines)
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "## What's New in ${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT
echo "" >> $GITHUB_OUTPUT
echo "$COMMITS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Update plugin version
run: |
# Replace version placeholder with actual version
sed -i "s/Version: {auto_update_value_on_deploy}/Version: ${{ steps.get_version.outputs.version }}/" twilio-wp-plugin.php
sed -i "s/TWP_VERSION', '{auto_update_value_on_deploy}/TWP_VERSION', '${{ steps.get_version.outputs.version }}/" twilio-wp-plugin.php
# Verify the changes were made
grep "Version:" twilio-wp-plugin.php
grep "TWP_VERSION" twilio-wp-plugin.php
- name: Create ZIP archive
run: |
# Create a temp directory with the correct plugin folder name
mkdir -p /tmp/twilio-wp-plugin
# Copy files to the temp directory (excluding git and other unnecessary files)
cp -r * /tmp/twilio-wp-plugin/ 2>/dev/null || true
# Exclude .git and .gitea directories
rm -rf /tmp/twilio-wp-plugin/.git /tmp/twilio-wp-plugin/.gitea 2>/dev/null || true
# Create the ZIP file with the proper structure
cd /tmp
zip -r $GITHUB_WORKSPACE/twilio-wp-plugin.zip twilio-wp-plugin
- name: Create Release
uses: softprops/action-gh-release@v2
with:
token: "${{ secrets.REPO_TOKEN }}"
title: "Twilio WP Plugin Release ${{ steps.get_version.outputs.version }}"
tag_name: ${{ steps.get_version.outputs.version }}
body: "${{ steps.release_notes.outputs.notes }}"
files: |
twilio-wp-plugin.zip

View File

@@ -0,0 +1,53 @@
name: Update Plugin Version
on:
release:
types: [created, edited]
jobs:
update-version:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get release tag
id: get_tag
run: echo "TAG=${GITEA_REF#refs/tags/}" >> $GITEA_ENV
- name: Update version in plugin file
run: |
# Replace version in main plugin file (both header and constant)
sed -i "s/Version: {auto_update_value_on_deploy}/Version: ${{ env.TAG }}/" twilio-wp-plugin.php
sed -i "s/TWP_VERSION', '{auto_update_value_on_deploy}/TWP_VERSION', '${{ env.TAG }}/" twilio-wp-plugin.php
# Verify changes
grep "Version:" twilio-wp-plugin.php
grep "TWP_VERSION" twilio-wp-plugin.php
- name: Commit changes
run: |
git config --local user.email "action@gitea.com"
git config --local user.name "Gitea Action"
git add twilio-wp-plugin.php
git commit -m "Update version to ${{ env.TAG }}" || echo "No changes to commit"
git push || echo "Nothing to push"
- name: Create plugin zip
run: |
mkdir -p /tmp/twilio-wp-plugin
rsync -av --exclude=".git" --exclude=".gitea" --exclude="build" . /tmp/twilio-wp-plugin/
cd /tmp
zip -r $GITEA_WORK_DIR/twilio-wp-plugin.zip twilio-wp-plugin
- name: Upload zip to release
uses: actions/upload-release-asset@v1
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
with:
upload_url: ${{ gitea.event.release.upload_url }}
asset_path: twilio-wp-plugin.zip
asset_name: twilio-wp-plugin.zip
asset_content_type: application/zip

427
DEBUGGING-TABLET.md Normal file
View File

@@ -0,0 +1,427 @@
# Debugging Browser Phone on Android Tablet
This guide explains how to debug the Twilio WordPress Plugin browser phone on Android tablets (Samsung and other devices).
## Prerequisites
- Android tablet with Chrome browser
- USB cable to connect tablet to computer
- Computer with Chrome browser installed
- USB debugging enabled on tablet
---
## Part 1: Enable USB Debugging on Android Tablet
### Step 1: Enable Developer Options
1. Open **Settings** on your Android tablet
2. Scroll down to **About tablet** (or **About device**)
3. Find **Build number** (may be under "Software information")
4. Tap **Build number** 7 times rapidly
5. You'll see a message: "You are now a developer!"
### Step 2: Enable USB Debugging
1. Go back to **Settings**
2. Scroll down to **Developer options** (newly appeared)
3. Toggle **Developer options** to ON
4. Find **USB debugging** in the list
5. Toggle **USB debugging** to ON
6. Confirm the prompt if asked
### Step 3: Trust Your Computer
1. Connect tablet to computer via USB cable
2. Unlock your tablet screen
3. A popup will appear: "Allow USB debugging?"
4. Check **Always allow from this computer**
5. Tap **OK** or **Allow**
---
## Part 2: Connect Chrome DevTools
### On Your Computer
1. Open **Chrome browser** on your computer
2. Navigate to: `chrome://inspect`
3. You should see your tablet device listed under "Remote Target"
4. Wait a few seconds for the device to appear
### Inspect the Browser Phone Page
1. On your tablet, open Chrome and navigate to:
```
https://phone.cloud-hosting.io/wp-admin/admin.php?page=twilio-wp-browser-phone
```
2. On your computer's Chrome DevTools (`chrome://inspect`), you'll see the page listed
3. Click **inspect** next to the browser phone page
4. A DevTools window will open showing your tablet's browser
---
## Part 3: Debug Browser Phone Issues
### Check Console Logs
In the **Console** tab, look for key messages:
#### Successful Connection
```
Twilio SDK loaded successfully
Setting up Twilio Device...
Device detection - Android: true, Mobile: true
AudioContext created, state: running
Device registered successfully
Device connection state: connected
```
#### Connection Issues
```
Device not connected, state: disconnected
Failed to register device
Connection error: 31005
```
#### Call Issues
```
Answer button clicked
Device connection state: connected
Accepting call...
Call accepted and connected
```
### Monitor Network Requests
1. Switch to **Network** tab in DevTools
2. Filter by: `twp_` or `twilio`
3. Check for failed requests (red status codes)
4. Look for:
- `twp_generate_capability_token` - Should return 200
- `twp_get_phone_numbers` - Should return 200
- WebSocket connections to Twilio
### Check Device Registration
In the **Console** tab, type:
```javascript
device
```
This shows the current Twilio Device object. Check:
- `device.state` - Should be "registered"
- `device.token` - Should exist (long string)
### Check AudioContext State
In the **Console** tab, type:
```javascript
audioContext
```
Check:
- Should exist (not null)
- `audioContext.state` - Should be "running" (not "suspended")
---
## Part 4: Common Issues & Solutions
### Issue 1: "Device not connected" Error
**Symptoms**: Status shows "Disconnected", calls hang up immediately
**Debugging**:
```javascript
// In console, check:
deviceConnectionState
// Should be: "connected"
```
**Solutions**:
1. Check network connection (try WiFi vs cellular)
2. Refresh the page with cache cleared
3. Check console for token errors
4. Verify Twilio credentials in plugin settings
### Issue 2: Call Hangs Up Immediately When Answered
**Symptoms**: Click "Answer" button, call disconnects instantly
**Debugging**:
```javascript
// Check device state before answering:
deviceConnectionState
// Check for errors in call handler
```
**Look for console errors**:
- `31005` - Connection/network error
- `31201` - ICE connection failure
- `31208` - Media connection error
**Solutions**:
1. Grant microphone permissions (Settings > Site settings > Microphone)
2. Check AudioContext is running: `audioContext.state`
3. Try switching between WiFi and cellular data
4. Clear Chrome cache and reload
### Issue 3: No Sound/Ringtone on Incoming Call
**Symptoms**: Call arrives but no audio plays, no vibration
**Debugging**:
```javascript
// Check audio setup:
ringtoneAudio
audioContext.state
```
**Solutions**:
1. Tap screen to unlock AudioContext (mobile restriction)
2. Check if ringtone file exists (optional, vibration is fallback)
3. Verify device supports Vibration API: `'vibrate' in navigator`
4. Check browser volume settings
### Issue 4: No Browser Notifications
**Symptoms**: No notification when call arrives in background
**Debugging**:
```javascript
// Check notification permission:
Notification.permission
// Should be: "granted"
// Check service worker:
navigator.serviceWorker.controller
// Should exist
```
**Solutions**:
1. Grant notification permission: Settings > Site settings > Notifications
2. Check service worker registration in **Application** tab of DevTools
3. Look for service worker logs in console
4. Reinstall PWA if installed
### Issue 5: Microphone Permission Denied
**Symptoms**: Error message about microphone access
**Solutions**:
1. Chrome Settings > Site settings > Microphone
2. Find `phone.cloud-hosting.io`
3. Change permission to **Allow**
4. Refresh the browser phone page
---
## Part 5: Tablet-Specific Checks
### Check User Agent
In console:
```javascript
navigator.userAgent
```
Should include "Android" and "Chrome"
### Check WebRTC Support
```javascript
// Check getUserMedia support:
navigator.mediaDevices.getUserMedia
// Should be: function
// Check Notification support:
'Notification' in window
// Should be: true
// Check Service Worker support:
'serviceWorker' in navigator
// Should be: true
```
### Check Audio Constraints
Look in console for:
```
Twilio Device created with audio constraints: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
googEchoCancellation: true,
...
}
```
### Test Vibration
In console:
```javascript
navigator.vibrate([300, 200, 300])
```
Tablet should vibrate if supported.
---
## Part 6: Advanced Debugging
### Monitor Twilio Device Events
In console, add event listeners:
```javascript
device.on('registered', () => console.log('Device registered!'));
device.on('error', (error) => console.error('Device error:', error));
device.on('incoming', (call) => console.log('Incoming call:', call));
device.on('unregistered', () => console.log('Device unregistered'));
```
### Check Call State During Active Call
```javascript
// When in a call:
currentCall
currentCall.status()
currentCall.parameters
```
### Test Audio Context Resume
```javascript
// Try to resume manually:
audioContext.resume().then(() => {
console.log('AudioContext state:', audioContext.state);
});
```
### Check Token Expiry
```javascript
// See when token expires:
new Date(tokenExpiry)
```
---
## Part 7: Logging Important Information
### Collect Debugging Info
Run this in console to get a debug report:
```javascript
console.log('=== Browser Phone Debug Report ===');
console.log('User Agent:', navigator.userAgent);
console.log('Device State:', deviceConnectionState);
console.log('Device Registered:', device ? device.state : 'no device');
console.log('AudioContext:', audioContext ? audioContext.state : 'no context');
console.log('Notification Permission:', Notification.permission);
console.log('Service Worker:', navigator.serviceWorker.controller ? 'active' : 'inactive');
console.log('Current Call:', currentCall ? 'in call' : 'no call');
console.log('Token Expiry:', tokenExpiry ? new Date(tokenExpiry) : 'unknown');
```
### Check WordPress AJAX Responses
In **Network** tab:
1. Filter: `admin-ajax.php`
2. Click on request
3. Check **Preview** tab for response
4. Look for `success: true` or error messages
---
## Part 8: Testing Checklist
After fixing issues, test these scenarios:
- [ ] Device shows "Connected" status
- [ ] Can make outbound call successfully
- [ ] Can receive incoming call (see notification)
- [ ] Can answer incoming call without hang-up
- [ ] Ringtone plays or tablet vibrates
- [ ] Call audio is clear (echo cancellation working)
- [ ] Can switch between WiFi and cellular during call
- [ ] Browser notification appears when app in background
- [ ] Service worker logs appear in console
- [ ] No errors in console during call lifecycle
- [ ] Can put call on hold
- [ ] Can transfer call
- [ ] Call timer updates correctly
---
## Troubleshooting Specific Error Codes
### Twilio Error 31005
**Meaning**: WebSocket connection failed
**Causes**:
- Network connectivity issues
- Firewall blocking WebSocket
- Mobile network switching (WiFi ↔ cellular)
**Solutions**:
- Check internet connection
- Try different network
- Wait for ICE restart (enabled in config)
### Twilio Error 31201
**Meaning**: ICE connection failed
**Causes**:
- Restrictive NAT/firewall
- Mobile network issues
**Solutions**:
- Try different network
- Check WebRTC connectivity
- Enable mobile data if on WiFi only
### Twilio Error 31204
**Meaning**: Connection error
**Causes**:
- Media connection setup failed
- Signaling timeout
**Solutions**:
- Refresh page
- Check microphone permissions
- Verify audio constraints applied
### Twilio Error 31208
**Meaning**: Media connection failed
**Causes**:
- Microphone access denied
- Audio device issues
**Solutions**:
- Grant microphone permission
- Check device audio settings
- Restart browser
---
## Additional Resources
- Twilio Voice SDK Errors: https://www.twilio.com/docs/voice/sdks/javascript/errors
- Chrome DevTools Remote Debugging: https://developer.chrome.com/docs/devtools/remote-debugging/
- WebRTC Troubleshooting: https://webrtc.github.io/samples/
- Service Worker Debugging: https://developer.chrome.com/docs/workbox/
---
## Contact & Support
If issues persist after following this guide:
1. Collect debug report (see Part 7)
2. Take screenshots of console errors
3. Note tablet model and Android version
4. Report issue with collected information
**Important Files**:
- Browser phone implementation: `admin/class-twp-admin.php` (lines 7400-8300)
- Service worker: `assets/js/twp-service-worker.js`
- CLAUDE.md: Quick reference guide

View File

@@ -119,7 +119,16 @@ class TWP_Admin {
'twilio-wp-settings', 'twilio-wp-settings',
array($this, 'display_plugin_settings') array($this, 'display_plugin_settings')
); );
add_submenu_page(
'twilio-wp-plugin',
'Mobile App',
'Mobile App',
'manage_options',
'twilio-wp-mobile-app',
array($this, 'display_mobile_app_settings')
);
add_submenu_page( add_submenu_page(
'twilio-wp-plugin', 'twilio-wp-plugin',
'Phone Schedules', 'Phone Schedules',
@@ -6987,6 +6996,7 @@ class TWP_Admin {
<div class="phone-interface"> <div class="phone-interface">
<div class="phone-display"> <div class="phone-display">
<div id="phone-status">Ready</div> <div id="phone-status">Ready</div>
<div id="device-connection-status" style="font-size: 12px; color: #999; margin-top: 5px;">Connecting...</div>
<div id="phone-number-display"></div> <div id="phone-number-display"></div>
<div id="call-timer" style="display: none;">00:00</div> <div id="call-timer" style="display: none;">00:00</div>
</div> </div>
@@ -7425,7 +7435,211 @@ class TWP_Admin {
var callStartTime = null; var callStartTime = null;
var tokenRefreshTimer = null; var tokenRefreshTimer = null;
var tokenExpiry = null; var tokenExpiry = null;
var audioContext = null;
var ringtoneAudio = null;
var isPageVisible = true;
var deviceConnectionState = 'disconnected'; // disconnected, connecting, connected
var serviceWorkerRegistration = null;
// Initialize AudioContext for mobile audio playback
function initializeAudioContext() {
try {
if (!audioContext) {
// Create AudioContext with compatibility
var AudioContextClass = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContextClass();
console.log('AudioContext created, state:', audioContext.state);
}
// Resume AudioContext if suspended (required on mobile)
if (audioContext.state === 'suspended') {
audioContext.resume().then(function() {
console.log('AudioContext resumed successfully');
}).catch(function(err) {
console.error('Failed to resume AudioContext:', err);
});
}
return true;
} catch (error) {
console.error('Failed to initialize AudioContext:', error);
return false;
}
}
// Create and setup ringtone audio element
function setupRingtone() {
if (!ringtoneAudio) {
ringtoneAudio = new Audio();
// Use a simple sine wave tone or default ringtone
// For now, we'll use a data URI for a simple beep tone
ringtoneAudio.loop = true;
ringtoneAudio.volume = 0.7;
// Create a simple ringtone using Web Audio API
createRingtone();
}
}
// Create ringtone using Web Audio API for better mobile support
function createRingtone() {
// Use a simple base64-encoded beep tone (short MP3)
// This is a simple 1-second beep at 800Hz
// You can replace this with a custom ringtone file URL if you upload one
// For now, use a simple approach: HTML5 Audio with error fallback
// Note: On mobile, audio playback may be restricted, so we rely heavily on vibration
var ringtoneUrl = '<?php echo plugins_url('assets/sounds/ringtone.mp3', dirname(__FILE__)); ?>';
// Try to load the ringtone file
ringtoneAudio.src = ringtoneUrl;
// Fallback: if ringtone file fails to load, we'll just use vibration
ringtoneAudio.addEventListener('error', function(e) {
console.log('Ringtone file not found (this is normal), using vibration only for mobile');
// Don't show error - vibration is sufficient for mobile
}, { once: true });
// Try to preload
ringtoneAudio.load();
}
// Play ringtone for incoming call
function playRingtone() {
try {
// Initialize AudioContext on user interaction
initializeAudioContext();
if (ringtoneAudio) {
var playPromise = ringtoneAudio.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Ringtone playing');
}).catch(function(error) {
console.error('Ringtone play failed:', error);
// Fallback: vibrate on mobile
vibrateDevice([300, 200, 300, 200, 300]);
});
}
}
// Always vibrate on mobile for better notification
vibrateDevice([300, 200, 300, 200, 300]);
} catch (error) {
console.error('Error playing ringtone:', error);
}
}
// Stop ringtone
function stopRingtone() {
try {
if (ringtoneAudio) {
ringtoneAudio.pause();
ringtoneAudio.currentTime = 0;
}
} catch (error) {
console.error('Error stopping ringtone:', error);
}
}
// Vibrate device (mobile)
function vibrateDevice(pattern) {
if ('vibrate' in navigator) {
navigator.vibrate(pattern);
}
}
// Register service worker for PWA notifications
function registerServiceWorker() {
if ('serviceWorker' in navigator) {
var swPath = '<?php echo plugins_url('assets/js/twp-service-worker.js', dirname(__FILE__)); ?>';
navigator.serviceWorker.register(swPath)
.then(function(registration) {
console.log('Service Worker registered:', registration);
serviceWorkerRegistration = registration;
// Request notification permission
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(function(permission) {
console.log('Notification permission:', permission);
});
}
})
.catch(function(error) {
console.error('Service Worker registration failed:', error);
});
}
}
// Send notification via service worker
function sendIncomingCallNotification(callerNumber) {
// Try browser notification first
if ('Notification' in window && Notification.permission === 'granted') {
if (serviceWorkerRegistration && serviceWorkerRegistration.active) {
serviceWorkerRegistration.active.postMessage({
type: 'SHOW_NOTIFICATION',
title: 'Incoming Call',
body: 'Call from ' + (callerNumber || 'Unknown Number'),
icon: '<?php echo plugins_url('assets/images/phone-icon.png', dirname(__FILE__)); ?>',
tag: 'incoming-call',
requireInteraction: true
});
} else {
// Fallback: show notification directly
new Notification('Incoming Call', {
body: 'Call from ' + (callerNumber || 'Unknown Number'),
icon: '<?php echo plugins_url('assets/images/phone-icon.png', dirname(__FILE__)); ?>',
tag: 'incoming-call',
requireInteraction: true
});
}
}
}
// Monitor page visibility for background call handling
function setupPageVisibility() {
document.addEventListener('visibilitychange', function() {
isPageVisible = !document.hidden;
console.log('Page visibility changed:', isPageVisible ? 'visible' : 'hidden');
// If page becomes visible, resume audio context
if (isPageVisible && audioContext) {
initializeAudioContext();
}
});
}
// Update device connection status in UI
function updateConnectionStatus(state) {
deviceConnectionState = state;
var statusText = '';
var statusColor = '';
switch(state) {
case 'connected':
statusText = 'Connected';
statusColor = '#4CAF50';
break;
case 'connecting':
statusText = 'Connecting...';
statusColor = '#FF9800';
break;
case 'disconnected':
statusText = 'Disconnected';
statusColor = '#f44336';
break;
default:
statusText = 'Unknown';
statusColor = '#999';
}
// Update status indicator (we'll add this to the UI)
$('#device-connection-status').text(statusText).css('color', statusColor);
}
// Wait for SDK to load // Wait for SDK to load
function waitForTwilioSDK(callback) { function waitForTwilioSDK(callback) {
if (typeof Twilio !== 'undefined' && Twilio.Device) { if (typeof Twilio !== 'undefined' && Twilio.Device) {
@@ -7441,7 +7655,19 @@ class TWP_Admin {
// Initialize the browser phone // Initialize the browser phone
function initializeBrowserPhone() { function initializeBrowserPhone() {
$('#phone-status').text('Initializing...'); $('#phone-status').text('Initializing...');
updateConnectionStatus('connecting');
// Initialize audio and PWA features
setupRingtone();
registerServiceWorker();
setupPageVisibility();
// Initialize AudioContext on first user interaction
$(document).one('click touchstart', function() {
console.log('User interaction detected, initializing AudioContext');
initializeAudioContext();
});
// Wait for SDK before proceeding // Wait for SDK before proceeding
waitForTwilioSDK(function() { waitForTwilioSDK(function() {
// Get capability token (access token for v2) // Get capability token (access token for v2)
@@ -7459,9 +7685,11 @@ class TWP_Admin {
// WordPress wp_send_json_error sends the error message as response.data // WordPress wp_send_json_error sends the error message as response.data
var errorMsg = response.data || response.error || 'Unknown error'; var errorMsg = response.data || response.error || 'Unknown error';
showError('Failed to initialize: ' + errorMsg); showError('Failed to initialize: ' + errorMsg);
updateConnectionStatus('disconnected');
} }
}).fail(function() { }).fail(function() {
showError('Failed to connect to server'); showError('Failed to connect to server');
updateConnectionStatus('disconnected');
}); });
}); });
} }
@@ -7508,25 +7736,57 @@ class TWP_Admin {
if (typeof Twilio === 'undefined' || !Twilio.Device) { if (typeof Twilio === 'undefined' || !Twilio.Device) {
throw new Error('Twilio Voice SDK not loaded'); throw new Error('Twilio Voice SDK not loaded');
} }
console.log('Setting up Twilio Device...');
updateConnectionStatus('connecting');
// Request media permissions before setting up device // Request media permissions before setting up device
const hasPermissions = await requestMediaPermissions(); const hasPermissions = await requestMediaPermissions();
if (!hasPermissions) { if (!hasPermissions) {
updateConnectionStatus('disconnected');
return; // Stop setup if permissions denied return; // Stop setup if permissions denied
} }
// Clean up existing device if any // Clean up existing device if any
if (device) { if (device) {
console.log('Destroying existing device');
await device.destroy(); await device.destroy();
} }
// Detect if we're on Android/mobile for specific settings
var isAndroid = /Android/i.test(navigator.userAgent);
var isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
console.log('Device detection - Android:', isAndroid, 'Mobile:', isMobile);
// Android-specific audio constraints for better WebRTC performance
var audioConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
};
// Additional Android-specific settings
if (isAndroid) {
audioConstraints.googEchoCancellation = true;
audioConstraints.googNoiseSuppression = true;
audioConstraints.googAutoGainControl = true;
audioConstraints.googHighpassFilter = true;
}
// Setup Twilio Voice SDK v2 Device // Setup Twilio Voice SDK v2 Device
// Note: Voice SDK v2 uses Twilio.Device directly, not Twilio.Voice.Device // Note: Voice SDK v2 uses Twilio.Device directly, not Twilio.Voice.Device
device = new Twilio.Device(token, { device = new Twilio.Device(token, {
logLevel: 1, // 0 = TRACE, 1 = DEBUG logLevel: 1, // 0 = TRACE, 1 = DEBUG
codecPreferences: ['opus', 'pcmu'], codecPreferences: ['opus', 'pcmu'],
edge: 'sydney' // Or closest edge location edge: 'sydney', // Or closest edge location
enableIceRestart: true, // Important for mobile network switching
audioConstraints: audioConstraints,
maxCallSignalingTimeoutMs: 30000, // 30 seconds timeout for mobile
closeProtection: true // Warn before closing during call
}); });
console.log('Twilio Device created with audio constraints:', audioConstraints);
// Set up event handlers BEFORE registering // Set up event handlers BEFORE registering
// Device registered and ready // Device registered and ready
@@ -7534,14 +7794,23 @@ class TWP_Admin {
console.log('Device registered successfully'); console.log('Device registered successfully');
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
$('#call-btn').prop('disabled', false); $('#call-btn').prop('disabled', false);
updateConnectionStatus('connected');
}); });
// Device unregistered
device.on('unregistered', function() {
console.log('Device unregistered');
updateConnectionStatus('disconnected');
});
// Handle errors // Handle errors
device.on('error', function(error) { device.on('error', function(error) {
console.error('Twilio Device Error:', error); console.error('Twilio Device Error:', error);
console.error('Error code:', error.code, 'Message:', error.message);
updateConnectionStatus('disconnected');
var errorMsg = error.message || error.toString(); var errorMsg = error.message || error.toString();
// Provide specific help for common errors // Provide specific help for common errors
if (errorMsg.includes('valid callerId must be provided')) { if (errorMsg.includes('valid callerId must be provided')) {
errorMsg = 'Caller ID error: Make sure you select a verified Twilio phone number as Caller ID. The number must be purchased through your Twilio account.'; errorMsg = 'Caller ID error: Make sure you select a verified Twilio phone number as Caller ID. The number must be purchased through your Twilio account.';
@@ -7551,23 +7820,48 @@ class TWP_Admin {
errorMsg = 'Token error: ' + errorMsg + ' - The page will automatically try to refresh the token.'; errorMsg = 'Token error: ' + errorMsg + ' - The page will automatically try to refresh the token.';
// Try to reinitialize after token error // Try to reinitialize after token error
setTimeout(initializeBrowserPhone, 5000); setTimeout(initializeBrowserPhone, 5000);
} else if (errorMsg.includes('31005') || errorMsg.includes('Connection error')) {
errorMsg = 'Connection error: Check your internet connection. If on mobile, try switching between WiFi and cellular data.';
// Retry connection
setTimeout(function() {
if (device) {
console.log('Attempting to reconnect device...');
device.register();
}
}, 3000);
} }
showError(errorMsg); showError(errorMsg);
}); });
// Handle incoming calls // Handle incoming calls
device.on('incoming', function(call) { device.on('incoming', function(call) {
console.log('Incoming call from:', call.parameters.From);
console.log('Call SID:', call.parameters.CallSid);
console.log('Device connection state:', deviceConnectionState);
currentCall = call; currentCall = call;
var callerNumber = call.parameters.From || 'Unknown Number';
$('#phone-status').text('Incoming Call').css('color', '#FF9800'); $('#phone-status').text('Incoming Call').css('color', '#FF9800');
$('#phone-number-display').text(call.parameters.From || 'Unknown Number'); $('#phone-number-display').text(callerNumber);
$('#call-btn').hide(); $('#call-btn').hide();
$('#answer-btn').show(); $('#answer-btn').show();
// Play ringtone and show notification
playRingtone();
// If page is in background, send notification
if (!isPageVisible) {
console.log('Page in background, sending notification');
sendIncomingCallNotification(callerNumber);
}
// Setup call event handlers // Setup call event handlers
setupCallHandlers(call); setupCallHandlers(call);
if ($('#auto-answer').is(':checked')) { if ($('#auto-answer').is(':checked')) {
console.log('Auto-answer enabled, accepting call');
call.accept(); call.accept();
} }
}); });
@@ -7590,6 +7884,8 @@ class TWP_Admin {
function setupCallHandlers(call) { function setupCallHandlers(call) {
// Call accepted/connected // Call accepted/connected
call.on('accept', function() { call.on('accept', function() {
console.log('Call accepted and connected');
stopRingtone();
$('#phone-status').text('Connected').css('color', '#2196F3'); $('#phone-status').text('Connected').css('color', '#2196F3');
$('#call-btn').hide(); $('#call-btn').hide();
$('#answer-btn').hide(); $('#answer-btn').hide();
@@ -7598,9 +7894,11 @@ class TWP_Admin {
$('#admin-call-controls-panel').show(); $('#admin-call-controls-panel').show();
startCallTimer(); startCallTimer();
}); });
// Call disconnected // Call disconnected
call.on('disconnect', function() { call.on('disconnect', function() {
console.log('Call disconnected');
stopRingtone();
currentCall = null; currentCall = null;
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
$('#hangup-btn').hide(); $('#hangup-btn').hide();
@@ -7610,22 +7908,26 @@ class TWP_Admin {
$('#admin-call-controls-panel').hide(); $('#admin-call-controls-panel').hide();
$('#call-timer').hide(); $('#call-timer').hide();
stopCallTimer(); stopCallTimer();
// Reset button states // Reset button states
$('#admin-hold-btn').text('Hold').removeClass('btn-active'); $('#admin-hold-btn').text('Hold').removeClass('btn-active');
$('#admin-record-btn').text('Record').removeClass('btn-active'); $('#admin-record-btn').text('Record').removeClass('btn-active');
}); });
// Call rejected // Call rejected
call.on('reject', function() { call.on('reject', function() {
console.log('Call rejected');
stopRingtone();
currentCall = null; currentCall = null;
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
$('#answer-btn').hide(); $('#answer-btn').hide();
$('#call-btn').show(); $('#call-btn').show();
}); });
// Call cancelled (by caller before answer) // Call cancelled (by caller before answer)
call.on('cancel', function() { call.on('cancel', function() {
console.log('Call cancelled by caller');
stopRingtone();
currentCall = null; currentCall = null;
$('#phone-status').text('Missed Call').css('color', '#FF9800'); $('#phone-status').text('Missed Call').css('color', '#FF9800');
$('#answer-btn').hide(); $('#answer-btn').hide();
@@ -7634,6 +7936,26 @@ class TWP_Admin {
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
}, 3000); }, 3000);
}); });
// Call error
call.on('error', function(error) {
console.error('Call error:', error);
console.error('Error code:', error.code, 'Message:', error.message);
stopRingtone();
var errorMsg = error.message || error.toString();
// Specific error handling for Android/mobile
if (error.code === 31005) {
errorMsg = 'Connection failed: Check your network connection. Try switching between WiFi and cellular data.';
} else if (error.code === 31201 || error.code === 31204) {
errorMsg = 'Call setup failed: Please try again. If the problem persists, refresh the page.';
} else if (error.code === 31208) {
errorMsg = 'Media connection failed: Check microphone permissions and try again.';
}
showError('Call error: ' + errorMsg);
});
} }
function refreshToken() { function refreshToken() {
@@ -7744,11 +8066,15 @@ class TWP_Admin {
$('#caller-id-select').html('<option value="">Error loading numbers</option>'); $('#caller-id-select').html('<option value="">Error loading numbers</option>');
}); });
// Dialpad functionality // Dialpad functionality (support both click and touch events)
$('.dialpad-btn').on('click', function() { $('.dialpad-btn').on('click touchend', function(e) {
e.preventDefault(); // Prevent duplicate events
var digit = $(this).data('digit'); var digit = $(this).data('digit');
var currentVal = $('#phone-number-input').val(); var currentVal = $('#phone-number-input').val();
$('#phone-number-input').val(currentVal + digit); $('#phone-number-input').val(currentVal + digit);
// Initialize AudioContext on user interaction (mobile requirement)
initializeAudioContext();
}); });
// Call button // Call button
@@ -7809,8 +8135,45 @@ class TWP_Admin {
// Answer button // Answer button
$('#answer-btn').on('click', function() { $('#answer-btn').on('click', function() {
if (currentCall) { console.log('Answer button clicked');
console.log('Device connection state:', deviceConnectionState);
console.log('Current call:', currentCall);
if (!currentCall) {
console.error('No current call to answer');
showError('No incoming call to answer');
return;
}
// Check device connection state
if (deviceConnectionState !== 'connected') {
console.error('Device not connected, state:', deviceConnectionState);
showError('Phone not connected. Reconnecting...');
// Try to reconnect
if (device) {
device.register().then(function() {
console.log('Device reconnected, answering call');
if (currentCall) {
currentCall.accept();
}
}).catch(function(err) {
console.error('Failed to reconnect device:', err);
showError('Failed to reconnect. Please refresh the page.');
});
}
return;
}
// Initialize AudioContext before accepting (important for mobile)
initializeAudioContext();
try {
console.log('Accepting call...');
currentCall.accept(); currentCall.accept();
} catch (error) {
console.error('Error accepting call:', error);
showError('Failed to answer call: ' + error.message);
} }
}); });
@@ -10049,5 +10412,12 @@ class TWP_Admin {
wp_send_json_error('Failed to accept transfer: ' . $e->getMessage()); wp_send_json_error('Failed to accept transfer: ' . $e->getMessage());
} }
} }
/**
* Display mobile app settings page
*/
public function display_mobile_app_settings() {
require_once TWP_PLUGIN_DIR . 'admin/mobile-app-settings.php';
}
} }

View File

@@ -0,0 +1,353 @@
<?php
/**
* Mobile App Settings Page
*/
// Prevent direct access
if (!defined('WPINC')) {
die;
}
// Check user capabilities
if (!current_user_can('manage_options')) {
wp_die(__('You do not have sufficient permissions to access this page.'));
}
// Handle manual update check
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_mobile_settings')) {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
$update_result = $updater->manual_check_for_updates();
}
// Handle test notification
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
$fcm = new TWP_FCM();
$test_user_id = get_current_user_id();
$notification_sent = $fcm->send_test_notification($test_user_id);
if ($notification_sent) {
$notification_result = array('success' => true, 'message' => 'Test notification sent successfully!');
} else {
$notification_result = array('success' => false, 'message' => 'Failed to send test notification. Check FCM configuration.');
}
}
// Save settings
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) {
update_option('twp_fcm_server_key', sanitize_text_field($_POST['twp_fcm_server_key']));
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
$settings_saved = true;
}
// Get current settings
$fcm_server_key = get_option('twp_fcm_server_key', '');
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', '');
// Get update status
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
$update_status = $updater->get_update_status();
// Get mobile app statistics
global $wpdb;
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
$active_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table WHERE is_active = 1 AND expires_at > NOW()");
$total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<?php if (isset($settings_saved)): ?>
<div class="notice notice-success is-dismissible">
<p><strong>Settings saved successfully!</strong></p>
</div>
<?php endif; ?>
<?php if (isset($update_result)): ?>
<div class="notice notice-<?php echo $update_result['update_available'] ? 'warning' : 'success'; ?> is-dismissible">
<p><strong><?php echo esc_html($update_result['message']); ?></strong></p>
</div>
<?php endif; ?>
<?php if (isset($notification_result)): ?>
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
</div>
<?php endif; ?>
<div class="twp-mobile-settings">
<!-- Mobile App Overview -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Mobile App Overview</h2>
<table class="widefat">
<tbody>
<tr>
<td><strong>API Endpoint:</strong></td>
<td><code><?php echo esc_html(site_url('/wp-json/twilio-mobile/v1')); ?></code></td>
</tr>
<tr>
<td><strong>Active Sessions:</strong></td>
<td><?php echo esc_html($active_sessions); ?> active / <?php echo esc_html($total_sessions); ?> total</td>
</tr>
<tr>
<td><strong>Plugin Version:</strong></td>
<td><?php echo esc_html(TWP_VERSION); ?></td>
</tr>
</tbody>
</table>
</div>
<!-- Mobile App Settings Form -->
<form method="post" action="">
<?php wp_nonce_field('twp_mobile_settings'); ?>
<!-- FCM Configuration -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Firebase Cloud Messaging (FCM)</h2>
<p>Configure FCM to enable push notifications for the mobile app.</p>
<table class="form-table">
<tr>
<th scope="row">
<label for="twp_fcm_server_key">FCM Server Key</label>
</th>
<td>
<input type="text"
id="twp_fcm_server_key"
name="twp_fcm_server_key"
value="<?php echo esc_attr($fcm_server_key); ?>"
class="regular-text"
placeholder="AAAA...">
<p class="description">
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key
</p>
</td>
</tr>
</table>
<?php if (!empty($fcm_server_key)): ?>
<p>
<button type="submit" name="twp_test_notification" class="button">
Send Test Notification
</button>
<span class="description">Send a test notification to your devices</span>
</p>
<?php endif; ?>
</div>
<!-- Auto-Update Settings -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Automatic Updates</h2>
<table class="form-table">
<tr>
<th scope="row">Current Version</th>
<td>
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
<?php if ($update_status['update_available']): ?>
<span style="color: #d63638; margin-left: 10px;">
⚠ Update available: <?php echo esc_html($update_status['latest_version']); ?>
</span>
<?php else: ?>
<span style="color: #00a32a; margin-left: 10px;">✓ Up to date</span>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
</th>
<td>
<label>
<input type="checkbox"
id="twp_auto_update_enabled"
name="twp_auto_update_enabled"
value="1"
<?php checked($auto_update_enabled); ?>>
Automatically check for updates every 12 hours
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_repo">Gitea Repository</label>
</th>
<td>
<input type="text"
id="twp_gitea_repo"
name="twp_gitea_repo"
value="<?php echo esc_attr($gitea_repo); ?>"
class="regular-text"
placeholder="org/repo-name">
<p class="description">
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_token">Gitea Access Token</label>
</th>
<td>
<input type="password"
id="twp_gitea_token"
name="twp_gitea_token"
value="<?php echo esc_attr($gitea_token); ?>"
class="regular-text"
placeholder="">
<p class="description">
Optional. Required only for private repositories. Create token at:
<a href="https://repo.anhonesthost.net/user/settings/applications" target="_blank">Gitea Settings > Applications</a>
</p>
</td>
</tr>
<tr>
<th scope="row">Last Update Check</th>
<td>
<?php
$last_check = $update_status['last_check'];
if ($last_check > 0) {
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
} else {
echo 'Never';
}
?>
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
Check Now
</button>
</td>
</tr>
</table>
</div>
<!-- API Documentation -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>API Endpoints</h2>
<p>Available REST API endpoints for mobile app development:</p>
<table class="widefat striped">
<thead>
<tr>
<th>Endpoint</th>
<th>Method</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/twilio-mobile/v1/auth/login</code></td>
<td>POST</td>
<td>Authenticate and get JWT tokens</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/auth/refresh</code></td>
<td>POST</td>
<td>Refresh access token</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/agent/status</code></td>
<td>GET/POST</td>
<td>Get or update agent status</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/queues/state</code></td>
<td>GET</td>
<td>Get all queue states</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/calls/{call_sid}/accept</code></td>
<td>POST</td>
<td>Accept a queued call</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/stream/events</code></td>
<td>GET</td>
<td>Server-Sent Events stream for real-time updates</td>
</tr>
</tbody>
</table>
<p style="margin-top: 15px;">
<strong>Authentication:</strong> All endpoints (except login/refresh) require
<code>Authorization: Bearer &lt;access_token&gt;</code> header.
</p>
</div>
<p class="submit">
<button type="submit" name="twp_save_mobile_settings" class="button button-primary">
Save Settings
</button>
</p>
</form>
<!-- Active Sessions -->
<?php if ($active_sessions > 0): ?>
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Active Mobile Sessions</h2>
<?php
$sessions = $wpdb->get_results("
SELECT s.user_id, s.device_info, s.logged_in_at, s.last_used, u.user_login, u.display_name
FROM $sessions_table s
JOIN {$wpdb->users} u ON s.user_id = u.ID
WHERE s.is_active = 1 AND s.expires_at > NOW()
ORDER BY s.last_used DESC
LIMIT 20
");
?>
<table class="widefat striped">
<thead>
<tr>
<th>User</th>
<th>Device</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
<?php foreach ($sessions as $session): ?>
<tr>
<td><?php echo esc_html($session->display_name ?: $session->user_login); ?></td>
<td><?php echo esc_html($session->device_info ?: 'Unknown device'); ?></td>
<td><?php echo esc_html(human_time_diff(strtotime($session->last_used), current_time('timestamp')) . ' ago'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<style>
.twp-mobile-settings .card {
padding: 20px;
background: #fff;
border: 1px solid #ccd0d4;
box-shadow: 0 1px 1px rgba(0,0,0,.04);
}
.twp-mobile-settings .card h2 {
margin-top: 0;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f1;
}
.twp-mobile-settings code {
background: #f0f0f1;
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
.twp-mobile-settings table.widefat td code {
background: #f6f7f7;
}
</style>

42
assets/images/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Browser Phone Images
## Required Images for PWA Notifications
Place the following image files in this directory for browser push notifications:
### phone-icon.png
- **Purpose**: Main notification icon
- **Size**: 192x192 pixels (recommended)
- **Format**: PNG with transparency
- **Usage**: Shown in browser notifications for incoming calls
### phone-badge.png
- **Purpose**: Small badge icon for notifications
- **Size**: 96x96 pixels or 72x72 pixels
- **Format**: PNG with transparency
- **Usage**: Displayed in the notification badge area (Android)
## Fallback Behavior
If these images are not present, the browser will:
- Use a default browser notification icon
- Still display text-based notifications
- Functionality will not be affected
## Image Creation Tips
- Use a simple, recognizable phone icon
- Ensure good contrast for visibility
- Test on both light and dark backgrounds
- Transparent backgrounds work best
- Use a consistent color scheme with your brand
## Example Resources
- Google Material Icons: https://fonts.google.com/icons
- Font Awesome: https://fontawesome.com/
- Flaticon: https://www.flaticon.com/
- Or create custom icons using tools like:
- Figma
- Adobe Illustrator
- Inkscape (free)

View File

@@ -72,7 +72,23 @@ self.addEventListener('push', function(event) {
// Handle messages from the main script // Handle messages from the main script
self.addEventListener('message', function(event) { self.addEventListener('message', function(event) {
console.log('TWP Service Worker: Message received', event.data);
if (event.data && event.data.type === 'SHOW_NOTIFICATION') { if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
self.registration.showNotification(event.data.title, event.data.options); const options = {
body: event.data.body || 'You have a new call waiting',
icon: event.data.icon || '/wp-content/plugins/twilio-wp-plugin/assets/images/phone-icon.png',
badge: '/wp-content/plugins/twilio-wp-plugin/assets/images/phone-badge.png',
vibrate: [300, 200, 300, 200, 300],
tag: event.data.tag || 'incoming-call',
requireInteraction: event.data.requireInteraction !== undefined ? event.data.requireInteraction : true,
data: event.data.data || {}
};
console.log('TWP Service Worker: Showing notification', event.data.title, options);
event.waitUntil(
self.registration.showNotification(event.data.title || 'Incoming Call', options)
);
} }
}); });

36
assets/sounds/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Ringtone Audio Files
## Custom Ringtone
To add a custom ringtone for incoming calls in the browser phone:
1. Place your ringtone audio file in this directory
2. Name it: `ringtone.mp3`
3. Recommended format: MP3, under 5 seconds, loopable
## Fallback Behavior
If no ringtone file is present, the browser phone will:
- Use device vibration on mobile devices (Android, iOS)
- Rely on browser notifications to alert users
- Display visual indicators in the browser interface
## Supported Audio Formats
- **MP3** (recommended) - Best compatibility across browsers
- **OGG** - Good for Firefox, Chrome
- **WAV** - Larger file size but universal support
## File Requirements
- Maximum file size: 100KB recommended
- Duration: 1-5 seconds (will loop)
- Sample rate: 44.1kHz or 48kHz
- Bitrate: 128kbps or higher
## Example Ringtone Sources
You can find free ringtone files at:
- https://freesound.org/
- https://incompetech.com/
- Or create your own using Audacity or similar tools

View File

@@ -52,7 +52,8 @@ class TWP_Activator {
'twp_callbacks', 'twp_callbacks',
'twp_call_recordings', 'twp_call_recordings',
'twp_user_extensions', 'twp_user_extensions',
'twp_queue_assignments' 'twp_queue_assignments',
'twp_mobile_sessions'
); );
$missing_tables = array(); $missing_tables = array();
@@ -361,7 +362,25 @@ class TWP_Activator {
KEY agent_id (agent_id), KEY agent_id (agent_id),
KEY started_at (started_at) KEY started_at (started_at)
) $charset_collate;"; ) $charset_collate;";
// Mobile sessions table
$table_mobile_sessions = $wpdb->prefix . 'twp_mobile_sessions';
$sql_mobile_sessions = "CREATE TABLE $table_mobile_sessions (
id int(11) NOT NULL AUTO_INCREMENT,
user_id bigint(20) NOT NULL,
refresh_token varchar(500) NOT NULL,
fcm_token text,
device_info text,
created_at datetime DEFAULT CURRENT_TIMESTAMP,
expires_at datetime NOT NULL,
last_used datetime DEFAULT CURRENT_TIMESTAMP,
is_active tinyint(1) DEFAULT 1,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY is_active (is_active),
KEY expires_at (expires_at)
) $charset_collate;";
dbDelta($sql_schedules); dbDelta($sql_schedules);
dbDelta($sql_queues); dbDelta($sql_queues);
dbDelta($sql_queued_calls); dbDelta($sql_queued_calls);
@@ -377,7 +396,8 @@ class TWP_Activator {
dbDelta($sql_recordings); dbDelta($sql_recordings);
dbDelta($sql_user_extensions); dbDelta($sql_user_extensions);
dbDelta($sql_queue_assignments); dbDelta($sql_queue_assignments);
dbDelta($sql_mobile_sessions);
// Add missing columns for existing installations // Add missing columns for existing installations
self::add_missing_columns(); self::add_missing_columns();
} }

View File

@@ -0,0 +1,291 @@
<?php
/**
* Automatic Plugin Updater
*
* Checks for updates from Gitea repository and installs them automatically
*/
class TWP_Auto_Updater {
private $plugin_slug = 'twilio-wp-plugin';
private $plugin_basename;
private $gitea_repo = 'wp-plugins/twilio-wp-plugin';
private $gitea_api_url;
private $current_version;
private $gitea_base_url = 'https://repo.anhonesthost.net';
/**
* Constructor
*/
public function __construct() {
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
$this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0';
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
}
/**
* Initialize updater hooks
*/
public function init() {
// Hook into WordPress update checks
add_filter('pre_set_site_transient_update_plugins', array($this, 'check_for_update'));
add_filter('plugins_api', array($this, 'plugin_info'), 10, 3);
// Add settings page for manual check
add_action('admin_init', array($this, 'register_settings'));
// Add update check to admin notices
if (get_option('twp_auto_update_enabled', '1') === '1') {
add_action('admin_init', array($this, 'maybe_auto_check_updates'));
}
}
/**
* Register auto-update settings
*/
public function register_settings() {
register_setting('twp_settings', 'twp_auto_update_enabled');
register_setting('twp_settings', 'twp_gitea_repo');
register_setting('twp_settings', 'twp_gitea_token'); // Optional for private repos
}
/**
* Check for updates periodically
*/
public function maybe_auto_check_updates() {
$last_check = get_option('twp_last_update_check', 0);
$check_interval = 12 * HOUR_IN_SECONDS; // Check every 12 hours
if (time() - $last_check > $check_interval) {
update_option('twp_last_update_check', time());
// Force WordPress to check for updates
wp_clean_plugins_cache();
}
}
/**
* Check for plugin updates
*/
public function check_for_update($transient) {
if (empty($transient->checked)) {
return $transient;
}
// Get Gitea repo from settings if available
$custom_repo = get_option('twp_gitea_repo', '');
if (!empty($custom_repo)) {
$this->gitea_repo = $custom_repo;
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
}
$update_info = $this->get_latest_release();
if ($update_info && version_compare($this->current_version, $update_info->version, '<')) {
$plugin_data = array(
'id' => 'twilio-wp-plugin',
'slug' => $this->plugin_slug,
'plugin' => $this->plugin_basename,
'new_version' => $update_info->version,
'url' => $update_info->homepage,
'package' => $update_info->download_url,
'tested' => '6.8',
'requires' => '5.8',
'requires_php' => '7.4',
'icons' => array(),
'banners' => array(),
'compatibility' => new stdClass(),
);
$transient->response[$this->plugin_basename] = (object) $plugin_data;
error_log("TWP Auto-Updater: New version {$update_info->version} available (current: {$this->current_version})");
} else {
error_log("TWP Auto-Updater: No updates available (current: {$this->current_version})");
}
return $transient;
}
/**
* Get plugin information for update screen
*/
public function plugin_info($false, $action, $args) {
if ($action !== 'plugin_information') {
return $false;
}
if (!isset($args->slug) || $args->slug !== $this->plugin_slug) {
return $false;
}
$update_info = $this->get_latest_release();
if (!$update_info) {
return $false;
}
$plugin_info = new stdClass();
$plugin_info->name = 'Twilio WP Plugin';
$plugin_info->slug = $this->plugin_slug;
$plugin_info->version = $update_info->version;
$plugin_info->author = '<a href="https://cybercove.io/">Joshua Knapp</a>';
$plugin_info->homepage = $update_info->homepage;
$plugin_info->download_link = $update_info->download_url;
$plugin_info->requires = '5.8';
$plugin_info->tested = '6.8';
$plugin_info->requires_php = '7.4';
$plugin_info->last_updated = $update_info->release_date;
$plugin_info->downloaded = 10;
$plugin_info->sections = array(
'description' => '<p>Twilio WordPress Plugin for call management and mobile app support.</p>',
'changelog' => '<pre>' . esc_html($update_info->changelog) . '</pre>',
'installation' => '<p>Upload the plugin to your WordPress site and activate it.</p>'
);
return $plugin_info;
}
/**
* Get latest release information from Gitea
*/
private function get_latest_release() {
// Check cache first (1 hour)
$cache_key = 'twp_latest_release';
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
// Use cURL for Gitea API
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->gitea_api_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Accept: application/json'));
curl_setopt($ch, CURLOPT_USERAGENT, 'WordPress/Twilio-WP-Plugin-Updater');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
// Add Gitea token if configured (for private repos)
$gitea_token = get_option('twp_gitea_token', '');
if (!empty($gitea_token)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Accept: application/json',
'Authorization: token ' . $gitea_token
));
}
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$response || $http_code !== 200) {
error_log("TWP Auto-Updater: Gitea API returned status $http_code");
return false;
}
$release = json_decode($response);
if (!$release || !isset($release->tag_name)) {
error_log('TWP Auto-Updater: Invalid release data from Gitea');
return false;
}
// Parse release information
$version = ltrim($release->tag_name, 'v'); // Remove 'v' prefix if present
$download_url = null;
// Find the zip asset
if (isset($release->assets) && is_array($release->assets)) {
foreach ($release->assets as $asset) {
if (strpos($asset->name, '.zip') !== false) {
$download_url = $asset->browser_download_url;
break;
}
}
}
// Fallback to zipball if no asset found
if (!$download_url) {
$download_url = $release->zipball_url;
}
// Format changelog
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';
// Handle empty changelog
if (empty(trim($changelog))) {
$changelog = "Version " . $version . "\n\n" .
"Released on " . date('F j, Y', strtotime($release->published_at)) . "\n\n" .
"* Updated plugin files";
}
$update_info = (object) array(
'version' => $version,
'download_url' => $download_url,
'homepage' => $this->gitea_base_url . '/' . $this->gitea_repo,
'release_date' => $release->published_at,
'description' => $changelog,
'changelog' => $changelog
);
// Cache for 1 hour
set_transient($cache_key, $update_info, HOUR_IN_SECONDS);
return $update_info;
}
/**
* Manual update check (for admin page)
*/
public function manual_check_for_updates() {
// Clear cache
delete_transient('twp_latest_release');
update_option('twp_last_update_check', 0);
// Force WordPress to check
wp_clean_plugins_cache();
delete_site_transient('update_plugins');
$update_info = $this->get_latest_release();
if (!$update_info) {
return array(
'success' => false,
'message' => 'Failed to check for updates. Please check your internet connection and Gitea repository settings.'
);
}
if (version_compare($this->current_version, $update_info->version, '<')) {
return array(
'success' => true,
'update_available' => true,
'current_version' => $this->current_version,
'latest_version' => $update_info->version,
'message' => "Update available: Version {$update_info->version}. Go to Plugins page to update."
);
} else {
return array(
'success' => true,
'update_available' => false,
'current_version' => $this->current_version,
'message' => 'You are running the latest version.'
);
}
}
/**
* Get current update status
*/
public function get_update_status() {
$update_info = $this->get_latest_release();
return array(
'current_version' => $this->current_version,
'latest_version' => $update_info ? $update_info->version : 'Unknown',
'update_available' => $update_info && version_compare($this->current_version, $update_info->version, '<'),
'last_check' => get_option('twp_last_update_check', 0),
'auto_update_enabled' => get_option('twp_auto_update_enabled', '1') === '1'
);
}
}

View File

@@ -33,7 +33,14 @@ class TWP_Core {
// API classes // API classes
require_once TWP_PLUGIN_DIR . 'includes/class-twp-twilio-api.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-twilio-api.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-elevenlabs-api.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-elevenlabs-api.php';
// Mobile app classes
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-auth.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
// Feature classes // Feature classes
require_once TWP_PLUGIN_DIR . 'includes/class-twp-scheduler.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-scheduler.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-call-queue.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-call-queue.php';
@@ -318,6 +325,20 @@ class TWP_Core {
// Initialize webhooks // Initialize webhooks
$webhooks = new TWP_Webhooks(); $webhooks = new TWP_Webhooks();
$webhooks->register_endpoints(); $webhooks->register_endpoints();
// Initialize mobile app endpoints
$mobile_auth = new TWP_Mobile_Auth();
$mobile_auth->register_endpoints();
$mobile_api = new TWP_Mobile_API();
$mobile_api->register_endpoints();
$mobile_sse = new TWP_Mobile_SSE();
$mobile_sse->register_endpoints();
// Initialize auto-updater
$updater = new TWP_Auto_Updater();
$updater->init();
// Add custom cron schedules // Add custom cron schedules
add_filter('cron_schedules', function($schedules) { add_filter('cron_schedules', function($schedules) {

214
includes/class-twp-fcm.php Normal file
View File

@@ -0,0 +1,214 @@
<?php
/**
* Firebase Cloud Messaging (FCM) Integration
*
* Handles push notifications to mobile devices via FCM
*/
class TWP_FCM {
private $server_key;
private $fcm_url = 'https://fcm.googleapis.com/fcm/send';
/**
* Constructor
*/
public function __construct() {
$this->server_key = get_option('twp_fcm_server_key', '');
}
/**
* Send push notification to user's devices
*/
public function send_notification($user_id, $title, $body, $data = array()) {
if (empty($this->server_key)) {
error_log('TWP FCM: Server key not configured');
return false;
}
// Get user's FCM tokens
$tokens = $this->get_user_tokens($user_id);
if (empty($tokens)) {
error_log("TWP FCM: No tokens found for user $user_id");
return false;
}
$success_count = 0;
$failed_tokens = array();
foreach ($tokens as $token) {
$result = $this->send_to_token($token, $title, $body, $data);
if ($result['success']) {
$success_count++;
} else {
$failed_tokens[] = $token;
// If token is invalid, remove it from database
if ($result['error'] === 'invalid_token') {
$this->remove_invalid_token($token);
}
}
}
error_log("TWP FCM: Sent notification to $success_count/" . count($tokens) . " devices for user $user_id");
return $success_count > 0;
}
/**
* Send notification to specific token
*/
private function send_to_token($token, $title, $body, $data = array()) {
$notification = array(
'title' => $title,
'body' => $body,
'sound' => 'default',
'priority' => 'high',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
);
$payload = array(
'to' => $token,
'notification' => $notification,
'data' => array_merge($data, array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
);
$headers = array(
'Authorization: key=' . $this->server_key,
'Content-Type: application/json'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->fcm_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code !== 200) {
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
// Check if token is invalid
$response_data = json_decode($response, true);
if (isset($response_data['results'][0]['error']) &&
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) {
return array('success' => false, 'error' => 'invalid_token');
}
return array('success' => false, 'error' => 'http_error');
}
return array('success' => true);
}
/**
* Get all active FCM tokens for a user
*/
private function get_user_tokens($user_id) {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
return $wpdb->get_col($wpdb->prepare(
"SELECT fcm_token FROM $table
WHERE user_id = %d
AND is_active = 1
AND fcm_token IS NOT NULL
AND fcm_token != ''
AND expires_at > NOW()",
$user_id
));
}
/**
* Remove invalid FCM token from database
*/
private function remove_invalid_token($token) {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
$wpdb->update(
$table,
array('fcm_token' => null),
array('fcm_token' => $token),
array('%s'),
array('%s')
);
error_log("TWP FCM: Removed invalid token from database");
}
/**
* Send incoming call notification
*/
public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Incoming Call';
$body = "Call from $from_number in $queue_name queue";
$data = array(
'type' => 'incoming_call',
'call_sid' => $call_sid,
'from_number' => $from_number,
'queue_name' => $queue_name
);
return $this->send_notification($user_id, $title, $body, $data);
}
/**
* Send queue timeout notification
*/
public function notify_queue_timeout($user_id, $queue_name, $waiting_count) {
$title = 'Queue Alert';
$body = "$queue_name has $waiting_count waiting call" . ($waiting_count > 1 ? 's' : '');
$data = array(
'type' => 'queue_timeout',
'queue_name' => $queue_name,
'waiting_count' => $waiting_count
);
return $this->send_notification($user_id, $title, $body, $data);
}
/**
* Send agent status change notification
*/
public function notify_status_change($user_id, $old_status, $new_status) {
$title = 'Status Changed';
$body = "Your status changed from $old_status to $new_status";
$data = array(
'type' => 'status_change',
'old_status' => $old_status,
'new_status' => $new_status
);
return $this->send_notification($user_id, $title, $body, $data);
}
/**
* Test notification (for settings page)
*/
public function send_test_notification($user_id) {
$title = 'Test Notification';
$body = 'This is a test notification from Twilio WordPress Plugin';
$data = array(
'type' => 'test',
'test' => true
);
return $this->send_notification($user_id, $title, $body, $data);
}
}

View File

@@ -0,0 +1,684 @@
<?php
/**
* Mobile App REST API Endpoints
*
* Provides REST API endpoints for mobile app functionality
*/
class TWP_Mobile_API {
private $auth;
/**
* Constructor
*/
public function __construct() {
// Initialize auth handler
require_once plugin_dir_path(__FILE__) . 'class-twp-mobile-auth.php';
$this->auth = new TWP_Mobile_Auth();
}
/**
* Register REST API endpoints
*/
public function register_endpoints() {
add_action('rest_api_init', function() {
// Agent status endpoints
register_rest_route('twilio-mobile/v1', '/agent/status', array(
'methods' => 'GET',
'callback' => array($this, 'get_agent_status'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/agent/status', array(
'methods' => 'POST',
'callback' => array($this, 'update_agent_status'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Queue state endpoint
register_rest_route('twilio-mobile/v1', '/queues/state', array(
'methods' => 'GET',
'callback' => array($this, 'get_queue_state'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Queue calls (specific queue)
register_rest_route('twilio-mobile/v1', '/queues/(?P<id>\d+)/calls', array(
'methods' => 'GET',
'callback' => array($this, 'get_queue_calls'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Call control endpoints
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/accept', array(
'methods' => 'POST',
'callback' => array($this, 'accept_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/reject', array(
'methods' => 'POST',
'callback' => array($this, 'reject_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/hold', array(
'methods' => 'POST',
'callback' => array($this, 'hold_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/unhold', array(
'methods' => 'POST',
'callback' => array($this, 'unhold_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/transfer', array(
'methods' => 'POST',
'callback' => array($this, 'transfer_call'),
'permission_callback' => array($this->auth, 'verify_token')
));
// FCM token registration
register_rest_route('twilio-mobile/v1', '/fcm/register', array(
'methods' => 'POST',
'callback' => array($this, 'register_fcm_token'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Agent phone number
register_rest_route('twilio-mobile/v1', '/agent/phone', array(
'methods' => 'GET',
'callback' => array($this, 'get_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/agent/phone', array(
'methods' => 'POST',
'callback' => array($this, 'update_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
/**
* Get agent status
*/
public function get_agent_status($request) {
$user_id = $this->auth->get_current_user_id();
global $wpdb;
$table = $wpdb->prefix . 'twp_agent_status';
$status = $wpdb->get_row($wpdb->prepare(
"SELECT status, is_logged_in, current_call_sid, last_activity, available_for_queues FROM $table WHERE user_id = %d",
$user_id
));
if (!$status) {
// Create default status
$wpdb->insert(
$table,
array('user_id' => $user_id, 'status' => 'offline', 'is_logged_in' => 0),
array('%d', '%s', '%d')
);
$status = (object) array(
'status' => 'offline',
'is_logged_in' => 0,
'current_call_sid' => null,
'last_activity' => current_time('mysql'),
'available_for_queues' => 1
);
}
return new WP_REST_Response(array(
'success' => true,
'status' => $status->status,
'is_logged_in' => (bool)$status->is_logged_in,
'current_call_sid' => $status->current_call_sid,
'last_activity' => $status->last_activity,
'available_for_queues' => (bool)$status->available_for_queues
), 200);
}
/**
* Update agent status
*/
public function update_agent_status($request) {
$user_id = $this->auth->get_current_user_id();
$new_status = $request->get_param('status');
$is_logged_in = $request->get_param('is_logged_in');
if (!in_array($new_status, array('available', 'busy', 'offline'))) {
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
}
global $wpdb;
$table = $wpdb->prefix . 'twp_agent_status';
// Check if status exists
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE user_id = %d",
$user_id
));
$data = array(
'status' => $new_status,
'last_activity' => current_time('mysql')
);
if ($is_logged_in !== null) {
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
if ($is_logged_in) {
$data['logged_in_at'] = current_time('mysql');
}
}
if ($exists) {
$wpdb->update(
$table,
$data,
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
} else {
$data['user_id'] = $user_id;
$wpdb->insert($table, $data);
}
return new WP_REST_Response(array(
'success' => true,
'message' => 'Status updated successfully'
), 200);
}
/**
* Get queue state (all queues user has access to)
*/
public function get_queue_state($request) {
$user_id = $this->auth->get_current_user_id();
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
// Get queues assigned to this user
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
$user_id
));
// Also include personal queues
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return new WP_REST_Response(array(
'success' => true,
'queues' => array()
), 200);
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
// Get queue information with call counts
$queues = $wpdb->get_results("
SELECT
q.id,
q.queue_name,
q.queue_type,
q.extension,
COUNT(c.id) as waiting_count
FROM $queues_table q
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str)
GROUP BY q.id
");
$result = array();
foreach ($queues as $queue) {
$result[] = array(
'id' => (int)$queue->id,
'name' => $queue->queue_name,
'type' => $queue->queue_type,
'extension' => $queue->extension,
'waiting_count' => (int)$queue->waiting_count
);
}
return new WP_REST_Response(array(
'success' => true,
'queues' => $result
), 200);
}
/**
* Get calls in a specific queue
*/
public function get_queue_calls($request) {
$user_id = $this->auth->get_current_user_id();
$queue_id = (int)$request['id'];
// Verify user has access to this queue
if (!$this->user_has_queue_access($user_id, $queue_id)) {
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
}
global $wpdb;
$table = $wpdb->prefix . 'twp_queued_calls';
$calls = $wpdb->get_results($wpdb->prepare(
"SELECT call_sid, from_number, to_number, position, status, joined_at, enqueued_at
FROM $table
WHERE queue_id = %d AND status = 'waiting'
ORDER BY position ASC",
$queue_id
));
$result = array();
foreach ($calls as $call) {
$result[] = array(
'call_sid' => $call->call_sid,
'from_number' => $call->from_number,
'to_number' => $call->to_number,
'position' => (int)$call->position,
'status' => $call->status,
'wait_time' => $this->calculate_wait_time($call->enqueued_at ?: $call->joined_at)
);
}
return new WP_REST_Response(array(
'success' => true,
'calls' => $result
), 200);
}
/**
* Accept a call (dequeue and connect to agent)
*/
public function accept_call($request) {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
// Get agent phone number
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
if (empty($agent_number)) {
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
}
// Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
// Get call info from queue
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$call = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $calls_table WHERE call_sid = %s AND status = 'waiting'",
$call_sid
));
if (!$call) {
return new WP_Error('call_not_found', 'Call not found or no longer waiting', array('status' => 404));
}
// Verify user has access to this queue
if (!$this->user_has_queue_access($user_id, $call->queue_id)) {
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
}
try {
// Connect agent to call
$agent_call = $twilio->create_call(
$agent_number,
$call->to_number,
array(
'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'),
'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'),
'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'),
'timeout' => 30
)
);
// Update call record
$wpdb->update(
$calls_table,
array(
'status' => 'connecting',
'agent_phone' => $agent_number,
'agent_call_sid' => $agent_call->sid
),
array('call_sid' => $call_sid),
array('%s', '%s', '%s'),
array('%s')
);
// Update agent status
$status_table = $wpdb->prefix . 'twp_agent_status';
$wpdb->update(
$status_table,
array('status' => 'busy', 'current_call_sid' => $call_sid),
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call accepted, connecting to agent',
'agent_call_sid' => $agent_call->sid
), 200);
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Reject a call (send to voicemail)
*/
public function reject_call($request) {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$call = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $calls_table WHERE call_sid = %s AND status = 'waiting'",
$call_sid
));
if (!$call) {
return new WP_Error('call_not_found', 'Call not found or no longer waiting', array('status' => 404));
}
// Verify user has access to this queue
if (!$this->user_has_queue_access($user_id, $call->queue_id)) {
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
}
try {
// Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
// Redirect call to voicemail
$twiml = new \Twilio\TwiML\VoiceResponse();
$twiml->say('The agent is unavailable. Please leave a message after the tone.');
$twiml->record(array(
'action' => site_url('/wp-json/twilio-webhook/v1/voicemail-complete'),
'maxLength' => 120,
'transcribe' => true
));
$twiml->say('We did not receive a recording. Goodbye.');
$twilio->update_call($call_sid, array('twiml' => $twiml->asXML()));
// Update call status
$wpdb->update(
$calls_table,
array('status' => 'voicemail', 'ended_at' => current_time('mysql')),
array('call_sid' => $call_sid),
array('%s', '%s'),
array('%s')
);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call sent to voicemail'
), 200);
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Hold a call
*/
public function hold_call($request) {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
$twilio = new TWP_Twilio_API();
// Find customer call leg
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
if (!$customer_call_sid) {
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
}
// Get user's hold queue
global $wpdb;
$ext_table = $wpdb->prefix . 'twp_user_extensions';
$queues_table = $wpdb->prefix . 'twp_call_queues';
$extension = $wpdb->get_row($wpdb->prepare(
"SELECT hold_queue_id FROM $ext_table WHERE user_id = %d",
$user_id
));
if (!$extension || !$extension->hold_queue_id) {
return new WP_Error('no_hold_queue', 'No hold queue configured', array('status' => 400));
}
$hold_queue = $wpdb->get_row($wpdb->prepare(
"SELECT queue_name, wait_music_url FROM $queues_table WHERE id = %d",
$extension->hold_queue_id
));
// Put call on hold
$twiml = new \Twilio\TwiML\VoiceResponse();
$twiml->say('Please hold while we transfer your call.');
$enqueue = $twiml->enqueue($hold_queue->queue_name, array(
'waitUrl' => $hold_queue->wait_music_url ?: site_url('/wp-json/twilio-webhook/v1/queue-wait')
));
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call placed on hold'
), 200);
} catch (Exception $e) {
return new WP_Error('hold_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Unhold a call (resume from hold queue)
*/
public function unhold_call($request) {
// Implementation would retrieve from hold queue and reconnect
return new WP_REST_Response(array(
'success' => true,
'message' => 'Unhold functionality - to be implemented with queue retrieval'
), 501);
}
/**
* Transfer a call to another extension/queue
*/
public function transfer_call($request) {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
$target = $request->get_param('target'); // Extension number or queue ID
if (empty($target)) {
return new WP_Error('missing_target', 'Transfer target is required', array('status' => 400));
}
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
$twilio = new TWP_Twilio_API();
// Find customer call leg
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
if (!$customer_call_sid) {
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
}
// Look up target (extension or queue)
global $wpdb;
$ext_table = $wpdb->prefix . 'twp_user_extensions';
$queues_table = $wpdb->prefix . 'twp_call_queues';
// Try as extension first
$target_queue = $wpdb->get_row($wpdb->prepare(
"SELECT q.* FROM $queues_table q
JOIN $ext_table e ON q.id = e.personal_queue_id
WHERE e.extension = %s",
$target
));
// If not extension, try as queue ID
if (!$target_queue && is_numeric($target)) {
$target_queue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $queues_table WHERE id = %d",
$target
));
}
if (!$target_queue) {
return new WP_Error('invalid_target', 'Transfer target not found', array('status' => 404));
}
// Transfer to queue
$twiml = new \Twilio\TwiML\VoiceResponse();
$twiml->say('Transferring your call.');
$twiml->enqueue($target_queue->queue_name, array(
'waitUrl' => $target_queue->wait_music_url ?: site_url('/wp-json/twilio-webhook/v1/queue-wait')
));
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Call transferred successfully'
), 200);
} catch (Exception $e) {
return new WP_Error('transfer_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Register FCM token for push notifications
*/
public function register_fcm_token($request) {
$user_id = $this->auth->get_current_user_id();
$fcm_token = $request->get_param('fcm_token');
$refresh_token = $request->get_param('refresh_token');
if (empty($fcm_token)) {
return new WP_Error('missing_token', 'FCM token is required', array('status' => 400));
}
$this->auth->update_fcm_token($user_id, $refresh_token, $fcm_token);
return new WP_REST_Response(array(
'success' => true,
'message' => 'FCM token registered successfully'
), 200);
}
/**
* Get agent phone number
*/
public function get_agent_phone($request) {
$user_id = $this->auth->get_current_user_id();
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
return new WP_REST_Response(array(
'success' => true,
'phone_number' => $agent_number ?: null
), 200);
}
/**
* Update agent phone number
*/
public function update_agent_phone($request) {
$user_id = $this->auth->get_current_user_id();
$phone_number = $request->get_param('phone_number');
if (empty($phone_number)) {
return new WP_Error('missing_phone', 'Phone number is required', array('status' => 400));
}
// Validate E.164 format
if (!preg_match('/^\+[1-9]\d{1,14}$/', $phone_number)) {
return new WP_Error('invalid_phone', 'Phone number must be in E.164 format (+1XXXXXXXXXX)', array('status' => 400));
}
update_user_meta($user_id, 'twp_agent_phone', $phone_number);
return new WP_REST_Response(array(
'success' => true,
'message' => 'Phone number updated successfully'
), 200);
}
/**
* Check if user has access to a queue
*/
private function user_has_queue_access($user_id, $queue_id) {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
// Check if it's user's personal queue
$is_personal = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $queues_table WHERE id = %d AND user_id = %d",
$queue_id, $user_id
));
if ($is_personal) {
return true;
}
// Check if user is assigned to this queue
$is_assigned = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $assignments_table WHERE queue_id = %d AND user_id = %d",
$queue_id, $user_id
));
return (bool)$is_assigned;
}
/**
* Calculate wait time in seconds
*/
private function calculate_wait_time($start_time) {
if (!$start_time) {
return 0;
}
$start = strtotime($start_time);
$now = current_time('timestamp');
return max(0, $now - $start);
}
}

View File

@@ -0,0 +1,457 @@
<?php
/**
* Mobile App JWT Authentication Handler
*
* Handles JWT token generation, validation, and refresh for Android/iOS apps
*/
class TWP_Mobile_Auth {
private $secret_key;
private $token_expiry = 86400; // 24 hours in seconds
private $refresh_expiry = 2592000; // 30 days in seconds
/**
* Constructor
*/
public function __construct() {
$this->secret_key = $this->get_secret_key();
}
/**
* Get or generate JWT secret key
*/
private function get_secret_key() {
$key = get_option('twp_mobile_jwt_secret');
if (empty($key)) {
// Generate a secure random key
$key = bin2hex(random_bytes(32));
update_option('twp_mobile_jwt_secret', $key);
}
return $key;
}
/**
* Register REST API endpoints
*/
public function register_endpoints() {
add_action('rest_api_init', function() {
// Login endpoint
register_rest_route('twilio-mobile/v1', '/auth/login', array(
'methods' => 'POST',
'callback' => array($this, 'handle_login'),
'permission_callback' => '__return_true'
));
// Refresh token endpoint
register_rest_route('twilio-mobile/v1', '/auth/refresh', array(
'methods' => 'POST',
'callback' => array($this, 'handle_refresh'),
'permission_callback' => '__return_true'
));
// Logout endpoint
register_rest_route('twilio-mobile/v1', '/auth/logout', array(
'methods' => 'POST',
'callback' => array($this, 'handle_logout'),
'permission_callback' => array($this, 'verify_token')
));
// Validate token endpoint (for debugging)
register_rest_route('twilio-mobile/v1', '/auth/validate', array(
'methods' => 'GET',
'callback' => array($this, 'handle_validate'),
'permission_callback' => array($this, 'verify_token')
));
});
}
/**
* Handle login request
*/
public function handle_login($request) {
$username = $request->get_param('username');
$password = $request->get_param('password');
$fcm_token = $request->get_param('fcm_token'); // Optional
$device_info = $request->get_param('device_info'); // Optional
if (empty($username) || empty($password)) {
return new WP_Error('missing_credentials', 'Username and password are required', array('status' => 400));
}
// Authenticate user
$user = wp_authenticate($username, $password);
if (is_wp_error($user)) {
return new WP_Error('invalid_credentials', 'Invalid username or password', array('status' => 401));
}
// Check if user has phone agent capabilities
if (!user_can($user->ID, 'twp_access_browser_phone') && !user_can($user->ID, 'manage_options')) {
return new WP_Error('insufficient_permissions', 'User does not have phone agent access', array('status' => 403));
}
// Generate tokens
$access_token = $this->generate_token($user->ID, 'access');
$refresh_token = $this->generate_token($user->ID, 'refresh');
// Store session in database
$this->store_session($user->ID, $refresh_token, $fcm_token, $device_info);
// Get user data
$user_data = $this->get_user_data($user->ID);
return new WP_REST_Response(array(
'success' => true,
'access_token' => $access_token,
'refresh_token' => $refresh_token,
'expires_in' => $this->token_expiry,
'user' => $user_data
), 200);
}
/**
* Handle token refresh request
*/
public function handle_refresh($request) {
$refresh_token = $request->get_param('refresh_token');
if (empty($refresh_token)) {
return new WP_Error('missing_token', 'Refresh token is required', array('status' => 400));
}
// Verify refresh token
$payload = $this->decode_token($refresh_token);
if (!$payload || $payload->type !== 'refresh') {
return new WP_Error('invalid_token', 'Invalid refresh token', array('status' => 401));
}
// Check if session exists and is valid
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
$session = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE user_id = %d AND refresh_token = %s AND is_active = 1 AND expires_at > NOW()",
$payload->user_id,
$refresh_token
));
if (!$session) {
return new WP_Error('invalid_session', 'Session expired or invalid', array('status' => 401));
}
// Generate new access token
$access_token = $this->generate_token($payload->user_id, 'access');
// Update last_used timestamp
$wpdb->update(
$table,
array('last_used' => current_time('mysql')),
array('id' => $session->id),
array('%s'),
array('%d')
);
return new WP_REST_Response(array(
'success' => true,
'access_token' => $access_token,
'expires_in' => $this->token_expiry
), 200);
}
/**
* Handle logout request
*/
public function handle_logout($request) {
$user_id = $this->get_current_user_id();
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
}
// Get refresh token from request
$refresh_token = $request->get_param('refresh_token');
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
if ($refresh_token) {
// Invalidate specific session
$wpdb->update(
$table,
array('is_active' => 0),
array('user_id' => $user_id, 'refresh_token' => $refresh_token),
array('%d'),
array('%d', '%s')
);
} else {
// Invalidate all sessions for this user
$wpdb->update(
$table,
array('is_active' => 0),
array('user_id' => $user_id),
array('%d'),
array('%d')
);
}
return new WP_REST_Response(array(
'success' => true,
'message' => 'Logged out successfully'
), 200);
}
/**
* Handle token validation request
*/
public function handle_validate($request) {
$user_id = $this->get_current_user_id();
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
}
$user_data = $this->get_user_data($user_id);
return new WP_REST_Response(array(
'success' => true,
'valid' => true,
'user' => $user_data
), 200);
}
/**
* Generate JWT token
*/
private function generate_token($user_id, $type = 'access') {
$issued_at = time();
$expiry = $type === 'refresh' ? $this->refresh_expiry : $this->token_expiry;
$payload = array(
'iat' => $issued_at,
'exp' => $issued_at + $expiry,
'user_id' => $user_id,
'type' => $type
);
return $this->encode_token($payload);
}
/**
* Simple JWT encoding (header.payload.signature)
*/
private function encode_token($payload) {
$header = array('typ' => 'JWT', 'alg' => 'HS256');
$segments = array();
$segments[] = $this->base64url_encode(json_encode($header));
$segments[] = $this->base64url_encode(json_encode($payload));
$signing_input = implode('.', $segments);
$signature = hash_hmac('sha256', $signing_input, $this->secret_key, true);
$segments[] = $this->base64url_encode($signature);
return implode('.', $segments);
}
/**
* Simple JWT decoding
*/
private function decode_token($token) {
$segments = explode('.', $token);
if (count($segments) !== 3) {
return false;
}
list($header_b64, $payload_b64, $signature_b64) = $segments;
// Verify signature
$signing_input = $header_b64 . '.' . $payload_b64;
$signature = $this->base64url_decode($signature_b64);
$expected_signature = hash_hmac('sha256', $signing_input, $this->secret_key, true);
if (!hash_equals($signature, $expected_signature)) {
return false;
}
// Decode payload
$payload = json_decode($this->base64url_decode($payload_b64));
if (!$payload) {
return false;
}
// Check expiration
if (isset($payload->exp) && $payload->exp < time()) {
return false;
}
return $payload;
}
/**
* Base64 URL encode
*/
private function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Base64 URL decode
*/
private function base64url_decode($data) {
return base64_decode(strtr($data, '-_', '+/'));
}
/**
* Verify token (permission callback)
*/
public function verify_token($request) {
$auth_header = $request->get_header('Authorization');
if (empty($auth_header)) {
return false;
}
// Extract token from "Bearer <token>"
if (preg_match('/Bearer\s+(.*)$/i', $auth_header, $matches)) {
$token = $matches[1];
} else {
return false;
}
$payload = $this->decode_token($token);
if (!$payload || $payload->type !== 'access') {
return false;
}
// Store user ID for later use
$request->set_param('_twp_user_id', $payload->user_id);
return true;
}
/**
* Get current user ID from token
*/
public function get_current_user_id() {
$request = rest_get_server()->get_request();
return $request->get_param('_twp_user_id');
}
/**
* Store session in database
*/
private function store_session($user_id, $refresh_token, $fcm_token = null, $device_info = null) {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
$wpdb->insert(
$table,
array(
'user_id' => $user_id,
'refresh_token' => $refresh_token,
'fcm_token' => $fcm_token,
'device_info' => $device_info,
'created_at' => current_time('mysql'),
'expires_at' => date('Y-m-d H:i:s', time() + $this->refresh_expiry),
'last_used' => current_time('mysql'),
'is_active' => 1
),
array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d')
);
}
/**
* Get user data for response
*/
private function get_user_data($user_id) {
$user = get_userdata($user_id);
if (!$user) {
return null;
}
// Get agent phone number
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
// Get agent status
global $wpdb;
$status_table = $wpdb->prefix . 'twp_agent_status';
$status = $wpdb->get_row($wpdb->prepare(
"SELECT status, is_logged_in, current_call_sid FROM $status_table WHERE user_id = %d",
$user_id
));
// Get user extension
$ext_table = $wpdb->prefix . 'twp_user_extensions';
$extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension, direct_dial_number FROM $ext_table WHERE user_id = %d",
$user_id
));
return array(
'id' => $user->ID,
'username' => $user->user_login,
'display_name' => $user->display_name,
'email' => $user->user_email,
'phone_number' => $agent_number,
'extension' => $extension ? $extension->extension : null,
'direct_dial' => $extension ? $extension->direct_dial_number : null,
'status' => $status ? $status->status : 'offline',
'is_logged_in' => $status ? (bool)$status->is_logged_in : false,
'current_call_sid' => $status ? $status->current_call_sid : null,
'capabilities' => array(
'can_access_browser_phone' => user_can($user_id, 'twp_access_browser_phone'),
'can_access_voicemails' => user_can($user_id, 'twp_access_voicemails'),
'can_access_call_log' => user_can($user_id, 'twp_access_call_log'),
'can_access_agent_queue' => user_can($user_id, 'twp_access_agent_queue'),
'can_access_sms_inbox' => user_can($user_id, 'twp_access_sms_inbox'),
'is_admin' => user_can($user_id, 'manage_options')
)
);
}
/**
* Update FCM token for existing session
*/
public function update_fcm_token($user_id, $refresh_token, $fcm_token) {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
$wpdb->update(
$table,
array('fcm_token' => $fcm_token),
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
array('%s'),
array('%d', '%s', '%d')
);
}
/**
* Get all active FCM tokens for a user
*/
public function get_user_fcm_tokens($user_id) {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
return $wpdb->get_col($wpdb->prepare(
"SELECT fcm_token FROM $table WHERE user_id = %d AND is_active = 1 AND fcm_token IS NOT NULL AND expires_at > NOW()",
$user_id
));
}
/**
* Clean up expired sessions
*/
public static function cleanup_expired_sessions() {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
$wpdb->query("UPDATE $table SET is_active = 0 WHERE expires_at < NOW() AND is_active = 1");
}
}

View File

@@ -0,0 +1,308 @@
<?php
/**
* Server-Sent Events (SSE) Handler for Mobile App
*
* Provides real-time updates for queue state, incoming calls, and agent status
*/
class TWP_Mobile_SSE {
private $auth;
/**
* Constructor
*/
public function __construct() {
require_once plugin_dir_path(__FILE__) . 'class-twp-mobile-auth.php';
$this->auth = new TWP_Mobile_Auth();
}
/**
* Register SSE endpoint
*/
public function register_endpoints() {
add_action('rest_api_init', function() {
register_rest_route('twilio-mobile/v1', '/stream/events', array(
'methods' => 'GET',
'callback' => array($this, 'stream_events'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
/**
* Stream events to mobile app
*/
public function stream_events($request) {
$user_id = $this->auth->get_current_user_id();
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
}
// Set headers for SSE
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // Disable nginx buffering
// Disable PHP output buffering
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', 0);
@ini_set('implicit_flush', 1);
ob_implicit_flush(1);
while (ob_get_level() > 0) {
ob_end_flush();
}
// Send initial connection event
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
// Get initial state
$last_check = time();
$previous_state = $this->get_current_state($user_id);
// Stream loop - check for changes every 2 seconds
$max_duration = 300; // 5 minutes max connection time
$start_time = time();
while (time() - $start_time < $max_duration) {
// Check if connection is still alive
if (connection_aborted()) {
break;
}
// Get current state
$current_state = $this->get_current_state($user_id);
// Compare and send updates
$this->check_and_send_updates($previous_state, $current_state);
// Update previous state
$previous_state = $current_state;
// Send heartbeat every 15 seconds
if (time() - $last_check >= 15) {
$this->send_event('heartbeat', array('timestamp' => time()));
$last_check = time();
}
// Sleep for 2 seconds
sleep(2);
}
// Connection closing
$this->send_event('disconnect', array('reason' => 'timeout', 'timestamp' => time()));
exit;
}
/**
* Get current state for agent
*/
private function get_current_state($user_id) {
global $wpdb;
$state = array(
'agent_status' => $this->get_agent_status($user_id),
'queues' => $this->get_queues_state($user_id),
'current_call' => $this->get_current_call($user_id)
);
return $state;
}
/**
* Get agent status
*/
private function get_agent_status($user_id) {
global $wpdb;
$table = $wpdb->prefix . 'twp_agent_status';
$status = $wpdb->get_row($wpdb->prepare(
"SELECT status, is_logged_in, current_call_sid FROM $table WHERE user_id = %d",
$user_id
));
if (!$status) {
return array('status' => 'offline', 'is_logged_in' => false, 'current_call_sid' => null);
}
return array(
'status' => $status->status,
'is_logged_in' => (bool)$status->is_logged_in,
'current_call_sid' => $status->current_call_sid
);
}
/**
* Get queues state
*/
private function get_queues_state($user_id) {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
// Get queue IDs
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
$user_id
));
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return array();
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
$queues = $wpdb->get_results("
SELECT
q.id,
q.queue_name,
COUNT(c.id) as waiting_count,
MIN(c.enqueued_at) as oldest_call_time
FROM $queues_table q
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str)
GROUP BY q.id
");
$result = array();
foreach ($queues as $queue) {
$result[$queue->id] = array(
'id' => (int)$queue->id,
'name' => $queue->queue_name,
'waiting_count' => (int)$queue->waiting_count,
'oldest_call_time' => $queue->oldest_call_time
);
}
return $result;
}
/**
* Get current call for agent
*/
private function get_current_call($user_id) {
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
if (!$agent_number) {
return null;
}
$call = $wpdb->get_row($wpdb->prepare(
"SELECT call_sid, from_number, queue_id, status, joined_at
FROM $calls_table
WHERE agent_phone = %s AND status IN ('connecting', 'in_progress')
ORDER BY joined_at DESC
LIMIT 1",
$agent_number
));
if (!$call) {
return null;
}
return array(
'call_sid' => $call->call_sid,
'from_number' => $call->from_number,
'queue_id' => (int)$call->queue_id,
'status' => $call->status,
'duration' => time() - strtotime($call->joined_at)
);
}
/**
* Check state changes and send updates
*/
private function check_and_send_updates($previous, $current) {
// Check agent status changes
if ($previous['agent_status'] !== $current['agent_status']) {
$this->send_event('agent_status_changed', $current['agent_status']);
}
// Check queue changes
$this->check_queue_changes($previous['queues'], $current['queues']);
// Check current call changes
if ($previous['current_call'] !== $current['current_call']) {
if ($current['current_call'] && !$previous['current_call']) {
// New call started
$this->send_event('call_started', $current['current_call']);
} elseif (!$current['current_call'] && $previous['current_call']) {
// Call ended
$this->send_event('call_ended', $previous['current_call']);
} elseif ($current['current_call'] && $previous['current_call']) {
// Call status changed
if ($current['current_call']['status'] !== $previous['current_call']['status']) {
$this->send_event('call_status_changed', $current['current_call']);
}
}
}
}
/**
* Check for queue changes
*/
private function check_queue_changes($previous_queues, $current_queues) {
foreach ($current_queues as $queue_id => $current_queue) {
$previous_queue = $previous_queues[$queue_id] ?? null;
if (!$previous_queue) {
// New queue added
$this->send_event('queue_added', $current_queue);
continue;
}
// Check for waiting count changes
if ($current_queue['waiting_count'] !== $previous_queue['waiting_count']) {
if ($current_queue['waiting_count'] > $previous_queue['waiting_count']) {
// New call in queue
$this->send_event('call_enqueued', array(
'queue_id' => $queue_id,
'queue_name' => $current_queue['name'],
'waiting_count' => $current_queue['waiting_count']
));
} else {
// Call removed from queue
$this->send_event('call_dequeued', array(
'queue_id' => $queue_id,
'queue_name' => $current_queue['name'],
'waiting_count' => $current_queue['waiting_count']
));
}
}
}
// Check for removed queues
foreach ($previous_queues as $queue_id => $previous_queue) {
if (!isset($current_queues[$queue_id])) {
$this->send_event('queue_removed', array('queue_id' => $queue_id));
}
}
}
/**
* Send SSE event
*/
private function send_event($event_type, $data) {
echo "event: $event_type\n";
echo "data: " . json_encode($data) . "\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}

View File

@@ -3,7 +3,7 @@
* Plugin Name: Twilio WP Plugin * Plugin Name: Twilio WP Plugin
* Plugin URI: https://repo.anhonesthost.net/wp-plugins/twilio-wp-plugin * Plugin URI: https://repo.anhonesthost.net/wp-plugins/twilio-wp-plugin
* Description: WordPress plugin for Twilio integration with phone scheduling, call forwarding, queue management, and Eleven Labs TTS * Description: WordPress plugin for Twilio integration with phone scheduling, call forwarding, queue management, and Eleven Labs TTS
* Version: 2.8.9 * Version: {auto_update_value_on_deploy}
* Author: Josh Knapp * Author: Josh Knapp
* License: GPL v2 or later * License: GPL v2 or later
* Text Domain: twilio-wp-plugin * Text Domain: twilio-wp-plugin
@@ -15,7 +15,7 @@ if (!defined('WPINC')) {
} }
// Plugin constants // Plugin constants
define('TWP_VERSION', '2.8.9'); define('TWP_VERSION', '{auto_update_value_on_deploy}');
define('TWP_DB_VERSION', '1.6.2'); // Track database version separately define('TWP_DB_VERSION', '1.6.2'); // Track database version separately
define('TWP_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('TWP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('TWP_PLUGIN_URL', plugin_dir_url(__FILE__)); define('TWP_PLUGIN_URL', plugin_dir_url(__FILE__));