Compare commits
6 Commits
2026.03.06
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4af4be94a4 | ||
|
|
78e6c5a4ee | ||
|
|
eedb7bdb8f | ||
|
|
f8c9c23077 | ||
|
|
5d3035a62c | ||
|
|
7df6090554 |
@@ -1,53 +0,0 @@
|
|||||||
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
|
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Dependencies
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
mobile/android/.gradle/
|
||||||
|
mobile/android/build/
|
||||||
|
mobile/android/app/build/
|
||||||
|
mobile/build/
|
||||||
|
mobile/.dart_tool/
|
||||||
|
|
||||||
|
# Local config (machine-specific paths)
|
||||||
|
mobile/android/local.properties
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
27
CLAUDE.md
27
CLAUDE.md
@@ -96,6 +96,33 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
|
|||||||
- Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
- Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
||||||
- Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
|
- Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
|
||||||
|
|
||||||
|
## Mobile App SSE (Server-Sent Events)
|
||||||
|
The mobile app uses SSE for real-time updates (queue changes, agent status). If SSE doesn't work (green dot stays red), the app automatically falls back to 5-second polling.
|
||||||
|
|
||||||
|
### Apache + PHP-FPM Buffering Fix
|
||||||
|
`mod_proxy_fcgi` buffers PHP output by default, which breaks SSE streaming. Fix by adding a config file on the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/home/shadowdao/public_html/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
|
||||||
|
httpd -t && systemctl restart httpd
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`flushpackets=on`** is the key — tells Apache to flush PHP-FPM output immediately
|
||||||
|
- This is a `ProxyPassMatch` directive — **cannot** go in `.htaccess`, must be server config
|
||||||
|
- The PHP-FPM socket path (`/run/php-fpm/www.sock`) must match `/etc/httpd/conf.d/php.conf`
|
||||||
|
- If the server uses nginx instead of Apache, add `X-Accel-Buffering: no` header (already in PHP code)
|
||||||
|
- If behind HAProxy with HTTP/2, the issue is Apache→client buffering, not HTTP/2 framing
|
||||||
|
|
||||||
|
### Diagnosis
|
||||||
|
```bash
|
||||||
|
# Check PHP-FPM proxy config
|
||||||
|
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
|
||||||
|
# Check if flushpackets is configured
|
||||||
|
grep -r "flushpackets" /etc/httpd/conf.d/
|
||||||
|
# Test SSE endpoint (should stream data, not hang)
|
||||||
|
curl -N -H "Authorization: Bearer TOKEN" https://phone.cloud-hosting.io/wp-json/twilio-mobile/v1/stream/events
|
||||||
|
```
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
See `README.md` for detailed version history. Current version: v2.8.9.
|
See `README.md` for detailed version history. Current version: v2.8.9.
|
||||||
|
|
||||||
|
|||||||
@@ -1580,10 +1580,127 @@ class TWP_Admin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Automatic Updates</h2>
|
||||||
|
<?php
|
||||||
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||||
|
$updater = new TWP_Auto_Updater();
|
||||||
|
|
||||||
|
// Handle manual update check
|
||||||
|
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_update_settings')) {
|
||||||
|
$update_result = $updater->manual_check_for_updates();
|
||||||
|
if (isset($update_result)) {
|
||||||
|
echo '<div class="notice notice-' . ($update_result['update_available'] ? 'warning' : 'success') . ' is-dismissible">';
|
||||||
|
echo '<p><strong>' . esc_html($update_result['message']) . '</strong></p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle save update settings
|
||||||
|
if (isset($_POST['twp_save_update_settings']) && check_admin_referer('twp_update_settings')) {
|
||||||
|
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']));
|
||||||
|
echo '<div class="notice notice-success is-dismissible"><p><strong>Update settings saved.</strong></p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$update_status = $updater->get_update_status();
|
||||||
|
$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', '');
|
||||||
|
?>
|
||||||
|
<form method="post" action="">
|
||||||
|
<?php wp_nonce_field('twp_update_settings'); ?>
|
||||||
|
<div class="card">
|
||||||
|
<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">
|
||||||
|
<p class="description">
|
||||||
|
Optional. Required only for private repositories.
|
||||||
|
</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>
|
||||||
|
<p>
|
||||||
|
<button type="submit" name="twp_save_update_settings" class="button button-primary">
|
||||||
|
Save Update Settings
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display schedules page
|
* Display schedules page
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ if (!current_user_can('manage_options')) {
|
|||||||
wp_die(__('You do not have sufficient permissions to access this page.'));
|
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
|
// Handle test notification
|
||||||
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
|
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
|
||||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
||||||
@@ -49,10 +42,6 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
|
|||||||
} else {
|
} else {
|
||||||
update_option('twp_fcm_service_account_json', '');
|
update_option('twp_fcm_service_account_json', '');
|
||||||
}
|
}
|
||||||
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;
|
$settings_saved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +49,6 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
|
|||||||
$fcm_project_id = get_option('twp_fcm_project_id', '');
|
$fcm_project_id = get_option('twp_fcm_project_id', '');
|
||||||
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
|
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
|
||||||
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
|
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
|
||||||
$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
|
// Get mobile app statistics
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
|
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||||
@@ -86,12 +66,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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)): ?>
|
<?php if (isset($notification_result)): ?>
|
||||||
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
|
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
|
||||||
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
|
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
|
||||||
@@ -183,91 +157,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</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 -->
|
<!-- API Documentation -->
|
||||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||||
<h2>API Endpoints</h2>
|
<h2>API Endpoints</h2>
|
||||||
|
|||||||
@@ -479,7 +479,13 @@ class TWP_Activator {
|
|||||||
if (empty($auto_busy_at_exists)) {
|
if (empty($auto_busy_at_exists)) {
|
||||||
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at");
|
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add pre_call_status column to store status before a call set agent to busy
|
||||||
|
$pre_call_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_agent_status LIKE 'pre_call_status'");
|
||||||
|
if (empty($pre_call_exists)) {
|
||||||
|
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN pre_call_status varchar(20) DEFAULT NULL AFTER auto_busy_at");
|
||||||
|
}
|
||||||
|
|
||||||
$table_schedules = $wpdb->prefix . 'twp_phone_schedules';
|
$table_schedules = $wpdb->prefix . 'twp_phone_schedules';
|
||||||
|
|
||||||
// Check if holiday_dates column exists
|
// Check if holiday_dates column exists
|
||||||
|
|||||||
@@ -619,35 +619,35 @@ class TWP_Agent_Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check and revert agents from auto-busy to available after 1 minute
|
* Check and revert agents from auto-busy to their previous status after 30 seconds
|
||||||
*/
|
*/
|
||||||
public static function revert_auto_busy_agents() {
|
public static function revert_auto_busy_agents() {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table_name = $wpdb->prefix . 'twp_agent_status';
|
$table_name = $wpdb->prefix . 'twp_agent_status';
|
||||||
|
|
||||||
// Find agents who have been auto-busy for more than 1 minute and are still logged in
|
// Find agents who have been auto-busy for more than 30 seconds and are still logged in
|
||||||
$cutoff_time = date('Y-m-d H:i:s', strtotime('-1 minute'));
|
$cutoff_time = date('Y-m-d H:i:s', strtotime('-30 seconds'));
|
||||||
|
|
||||||
$auto_busy_agents = $wpdb->get_results($wpdb->prepare(
|
$auto_busy_agents = $wpdb->get_results($wpdb->prepare(
|
||||||
"SELECT user_id, current_call_sid FROM $table_name
|
"SELECT user_id, current_call_sid, pre_call_status FROM $table_name
|
||||||
WHERE status = 'busy'
|
WHERE status = 'busy'
|
||||||
AND auto_busy_at IS NOT NULL
|
AND auto_busy_at IS NOT NULL
|
||||||
AND auto_busy_at < %s
|
AND auto_busy_at < %s
|
||||||
AND is_logged_in = 1",
|
AND is_logged_in = 1",
|
||||||
$cutoff_time
|
$cutoff_time
|
||||||
));
|
));
|
||||||
|
|
||||||
foreach ($auto_busy_agents as $agent) {
|
foreach ($auto_busy_agents as $agent) {
|
||||||
// Verify the call is actually finished before reverting
|
// Verify the call is actually finished before reverting
|
||||||
$call_sid = $agent->current_call_sid;
|
$call_sid = $agent->current_call_sid;
|
||||||
$call_active = false;
|
$call_active = false;
|
||||||
|
|
||||||
if ($call_sid) {
|
if ($call_sid) {
|
||||||
// Check if call is still active using Twilio API
|
// Check if call is still active using Twilio API
|
||||||
try {
|
try {
|
||||||
$api = new TWP_Twilio_API();
|
$api = new TWP_Twilio_API();
|
||||||
$call_status = $api->get_call_status($call_sid);
|
$call_status = $api->get_call_status($call_sid);
|
||||||
|
|
||||||
// If call is still in progress, don't revert yet
|
// If call is still in progress, don't revert yet
|
||||||
if (in_array($call_status, ['queued', 'ringing', 'in-progress'])) {
|
if (in_array($call_status, ['queued', 'ringing', 'in-progress'])) {
|
||||||
$call_active = true;
|
$call_active = true;
|
||||||
@@ -655,17 +655,25 @@ class TWP_Agent_Manager {
|
|||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage());
|
error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage());
|
||||||
// If we can't check call status, assume it's finished and proceed with revert
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only revert if call is not active
|
// Only revert if call is not active
|
||||||
if (!$call_active) {
|
if (!$call_active) {
|
||||||
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from auto-busy to available");
|
$revert_to = !empty($agent->pre_call_status) ? $agent->pre_call_status : 'available';
|
||||||
self::set_agent_status($agent->user_id, 'available', null, false);
|
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from busy to {$revert_to}");
|
||||||
|
self::set_agent_status($agent->user_id, $revert_to, null, false);
|
||||||
|
// Clear pre_call_status
|
||||||
|
$wpdb->update(
|
||||||
|
$table_name,
|
||||||
|
array('pre_call_status' => null),
|
||||||
|
array('user_id' => $agent->user_id),
|
||||||
|
array('%s'),
|
||||||
|
array('%d')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return count($auto_busy_agents);
|
return count($auto_busy_agents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TWP_Auto_Updater {
|
|||||||
public function __construct() {
|
public function __construct() {
|
||||||
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
|
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
|
||||||
$this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0';
|
$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';
|
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases?limit=1&draft=false';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +74,7 @@ class TWP_Auto_Updater {
|
|||||||
$custom_repo = get_option('twp_gitea_repo', '');
|
$custom_repo = get_option('twp_gitea_repo', '');
|
||||||
if (!empty($custom_repo)) {
|
if (!empty($custom_repo)) {
|
||||||
$this->gitea_repo = $custom_repo;
|
$this->gitea_repo = $custom_repo;
|
||||||
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
|
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases?limit=1&draft=false';
|
||||||
}
|
}
|
||||||
|
|
||||||
$update_info = $this->get_latest_release();
|
$update_info = $this->get_latest_release();
|
||||||
@@ -184,9 +184,16 @@ class TWP_Auto_Updater {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$release = json_decode($response);
|
$releases = json_decode($response);
|
||||||
|
|
||||||
if (!$release || !isset($release->tag_name)) {
|
if (!$releases || !is_array($releases) || empty($releases)) {
|
||||||
|
error_log('TWP Auto-Updater: No releases found from Gitea');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = $releases[0];
|
||||||
|
|
||||||
|
if (!isset($release->tag_name)) {
|
||||||
error_log('TWP Auto-Updater: Invalid release data from Gitea');
|
error_log('TWP Auto-Updater: Invalid release data from Gitea');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -210,6 +217,12 @@ class TWP_Auto_Updater {
|
|||||||
$download_url = $release->zipball_url;
|
$download_url = $release->zipball_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append auth token to download URL for private repos
|
||||||
|
if (!empty($gitea_token) && $download_url) {
|
||||||
|
$separator = (strpos($download_url, '?') !== false) ? '&' : '?';
|
||||||
|
$download_url .= $separator . 'token=' . urlencode($gitea_token);
|
||||||
|
}
|
||||||
|
|
||||||
// Format changelog
|
// Format changelog
|
||||||
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';
|
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class TWP_Call_Queue {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
// Notify agents via SMS when a new call enters the queue
|
// Notify agents via SMS and FCM when a new call enters the queue
|
||||||
self::notify_agents_for_queue($queue_id, $call_data['from_number']);
|
self::notify_agents_for_queue($queue_id, $call_data['from_number'], $call_data['call_sid']);
|
||||||
return $position;
|
return $position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,49 +580,60 @@ class TWP_Call_Queue {
|
|||||||
/**
|
/**
|
||||||
* Notify agents via SMS when a call enters the queue
|
* Notify agents via SMS when a call enters the queue
|
||||||
*/
|
*/
|
||||||
private static function notify_agents_for_queue($queue_id, $caller_number) {
|
private static function notify_agents_for_queue($queue_id, $caller_number, $call_sid = '') {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
|
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
|
||||||
|
|
||||||
// Get queue information including assigned agent group and phone number
|
// Get queue information including assigned agent group and phone number
|
||||||
$queue_table = $wpdb->prefix . 'twp_call_queues';
|
$queue_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
$queue = $wpdb->get_row($wpdb->prepare(
|
$queue = $wpdb->get_row($wpdb->prepare(
|
||||||
"SELECT * FROM $queue_table WHERE id = %d",
|
"SELECT * FROM $queue_table WHERE id = %d",
|
||||||
$queue_id
|
$queue_id
|
||||||
));
|
));
|
||||||
|
|
||||||
if (!$queue) {
|
if (!$queue) {
|
||||||
error_log("TWP: Queue {$queue_id} not found in database");
|
error_log("TWP: Queue {$queue_id} not found in database");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$queue->agent_group_id) {
|
|
||||||
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
|
|
||||||
|
|
||||||
// Send Discord/Slack notification for incoming call
|
// Send Discord/Slack notification for incoming call
|
||||||
require_once dirname(__FILE__) . '/class-twp-notifications.php';
|
require_once dirname(__FILE__) . '/class-twp-notifications.php';
|
||||||
error_log("TWP: Triggering Discord/Slack notification for incoming call");
|
|
||||||
TWP_Notifications::send_call_notification('incoming_call', array(
|
TWP_Notifications::send_call_notification('incoming_call', array(
|
||||||
'type' => 'incoming_call',
|
'type' => 'incoming_call',
|
||||||
'caller' => $caller_number,
|
'caller' => $caller_number,
|
||||||
'queue' => $queue->queue_name,
|
'queue' => $queue->queue_name,
|
||||||
'queue_id' => $queue_id
|
'queue_id' => $queue_id
|
||||||
));
|
));
|
||||||
|
|
||||||
// Get members of the assigned agent group
|
|
||||||
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
|
||||||
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
|
|
||||||
|
|
||||||
// Send FCM push notifications to agents' mobile devices
|
// Send FCM push notifications to agents' mobile devices
|
||||||
require_once dirname(__FILE__) . '/class-twp-fcm.php';
|
require_once dirname(__FILE__) . '/class-twp-fcm.php';
|
||||||
$fcm = new TWP_FCM();
|
$fcm = new TWP_FCM();
|
||||||
|
$notified_users = array();
|
||||||
|
|
||||||
|
// Always notify personal queue owner
|
||||||
|
if (!empty($queue->user_id)) {
|
||||||
|
$fcm->notify_queue_alert($queue->user_id, $caller_number, $queue->queue_name, $call_sid);
|
||||||
|
$notified_users[] = $queue->user_id;
|
||||||
|
error_log("TWP: FCM queue alert sent to queue owner user {$queue->user_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$queue->agent_group_id) {
|
||||||
|
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
|
||||||
|
|
||||||
|
// Get members of the assigned agent group
|
||||||
|
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
||||||
|
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
|
||||||
|
|
||||||
foreach ($members as $member) {
|
foreach ($members as $member) {
|
||||||
$fcm->notify_incoming_call($member->user_id, $caller_number, $queue->queue_name, '');
|
if (!in_array($member->user_id, $notified_users)) {
|
||||||
|
$fcm->notify_queue_alert($member->user_id, $caller_number, $queue->queue_name, $call_sid);
|
||||||
|
$notified_users[] = $member->user_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($members)) {
|
if (empty($members)) {
|
||||||
|
|||||||
@@ -279,22 +279,68 @@ class TWP_FCM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send incoming call notification
|
* Send queue alert notification (call entered queue).
|
||||||
|
* Uses data-only message so it works in background/killed state.
|
||||||
*/
|
*/
|
||||||
public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) {
|
public function notify_queue_alert($user_id, $from_number, $queue_name, $call_sid) {
|
||||||
$title = 'Incoming Call';
|
$title = 'Call Waiting';
|
||||||
$body = "Call from $from_number in $queue_name queue";
|
$body = "Call from $from_number in $queue_name";
|
||||||
|
|
||||||
$data = array(
|
$data = array(
|
||||||
'type' => 'incoming_call',
|
'type' => 'queue_alert',
|
||||||
'call_sid' => $call_sid,
|
'call_sid' => $call_sid,
|
||||||
'from_number' => $from_number,
|
'from_number' => $from_number,
|
||||||
'queue_name' => $queue_name
|
'queue_name' => $queue_name,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->send_notification($user_id, $title, $body, $data, true);
|
return $this->send_notification($user_id, $title, $body, $data, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel queue alert notification (call answered or caller disconnected).
|
||||||
|
*/
|
||||||
|
public function notify_queue_alert_cancel($user_id, $call_sid) {
|
||||||
|
$data = array(
|
||||||
|
'type' => 'queue_alert_cancel',
|
||||||
|
'call_sid' => $call_sid,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->send_notification($user_id, '', '', $data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send queue alert cancel to all agents assigned to a queue.
|
||||||
|
*/
|
||||||
|
public function cancel_queue_alert_for_queue($queue_id, $call_sid) {
|
||||||
|
global $wpdb;
|
||||||
|
$queue_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
|
|
||||||
|
$queue = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $queue_table WHERE id = %d", $queue_id
|
||||||
|
));
|
||||||
|
if (!$queue) return;
|
||||||
|
|
||||||
|
$notified_users = array();
|
||||||
|
|
||||||
|
// Notify personal queue owner
|
||||||
|
if (!empty($queue->user_id)) {
|
||||||
|
$this->notify_queue_alert_cancel($queue->user_id, $call_sid);
|
||||||
|
$notified_users[] = $queue->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify agent group members
|
||||||
|
if (!empty($queue->agent_group_id)) {
|
||||||
|
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
||||||
|
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
|
||||||
|
foreach ($members as $member) {
|
||||||
|
if (!in_array($member->user_id, $notified_users)) {
|
||||||
|
$this->notify_queue_alert_cancel($member->user_id, $call_sid);
|
||||||
|
$notified_users[] = $member->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send queue timeout notification
|
* Send queue timeout notification
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -106,6 +106,27 @@ class TWP_Mobile_API {
|
|||||||
'callback' => array($this, 'get_voice_token'),
|
'callback' => array($this, 'get_voice_token'),
|
||||||
'permission_callback' => array($this->auth, 'verify_token')
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Phone numbers for caller ID
|
||||||
|
register_rest_route('twilio-mobile/v1', '/phone-numbers', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => array($this, 'get_phone_numbers'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
|
|
||||||
|
// Outbound call (click-to-call via server)
|
||||||
|
register_rest_route('twilio-mobile/v1', '/calls/outbound', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => array($this, 'initiate_outbound_call'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
|
|
||||||
|
// FCM push credential setup (admin only)
|
||||||
|
register_rest_route('twilio-mobile/v1', '/admin/push-credential', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => array($this, 'setup_push_credential'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,39 +183,16 @@ class TWP_Mobile_API {
|
|||||||
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
|
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php';
|
||||||
$table = $wpdb->prefix . 'twp_agent_status';
|
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||||
|
|
||||||
// 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')
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Handle login status change first (matches browser phone behavior)
|
||||||
if ($is_logged_in !== null) {
|
if ($is_logged_in !== null) {
|
||||||
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
|
TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in);
|
||||||
if ($is_logged_in) {
|
|
||||||
$data['logged_in_at'] = current_time('mysql');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($exists) {
|
// Set agent status (handles auto_busy_at and all status fields)
|
||||||
$wpdb->update(
|
TWP_Agent_Manager::set_agent_status($user_id, $new_status);
|
||||||
$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(
|
return new WP_REST_Response(array(
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -221,6 +219,7 @@ class TWP_Mobile_API {
|
|||||||
));
|
));
|
||||||
|
|
||||||
if (!$existing_extension) {
|
if (!$existing_extension) {
|
||||||
|
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||||
TWP_User_Queue_Manager::create_user_queues($user_id);
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,12 +311,9 @@ class TWP_Mobile_API {
|
|||||||
$user_id = $this->auth->get_current_user_id();
|
$user_id = $this->auth->get_current_user_id();
|
||||||
$call_sid = $request['call_sid'];
|
$call_sid = $request['call_sid'];
|
||||||
|
|
||||||
// Get agent phone number
|
// Check for WebRTC client_identity parameter
|
||||||
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
$body = $request->get_json_params();
|
||||||
|
$client_identity = isset($body['client_identity']) ? sanitize_text_field($body['client_identity']) : null;
|
||||||
if (empty($agent_number)) {
|
|
||||||
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Twilio API
|
// Initialize Twilio API
|
||||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
@@ -342,46 +338,120 @@ class TWP_Mobile_API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect agent to call
|
if (!empty($client_identity)) {
|
||||||
$agent_call = $twilio->create_call(
|
// WebRTC path: redirect the queued call to the Twilio Client device
|
||||||
$agent_number,
|
// Use the original caller's number as caller ID so it shows on the agent's device
|
||||||
$call->to_number,
|
$caller_id = $call->from_number;
|
||||||
array(
|
if (empty($caller_id)) {
|
||||||
'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'),
|
$caller_id = $call->to_number;
|
||||||
'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'),
|
}
|
||||||
'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'),
|
if (empty($caller_id)) {
|
||||||
'timeout' => 30
|
$caller_id = get_option('twp_caller_id_number', '');
|
||||||
)
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// Update call record
|
$twiml = '<Response><Dial callerId="' . htmlspecialchars($caller_id) . '"><Client>' . htmlspecialchars($client_identity) . '</Client></Dial></Response>';
|
||||||
$wpdb->update(
|
|
||||||
$calls_table,
|
error_log('TWP accept_call: call_sid=' . $call_sid . ' client=' . $client_identity . ' twiml=' . $twiml);
|
||||||
array(
|
|
||||||
'status' => 'connecting',
|
$result = $twilio->update_call($call_sid, array('twiml' => $twiml));
|
||||||
'agent_phone' => $agent_number,
|
|
||||||
|
error_log('TWP accept_call result: ' . json_encode($result));
|
||||||
|
if (!$result['success']) {
|
||||||
|
return new WP_Error('twilio_error', $result['error'] ?? 'Failed to update call', array('status' => 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update call record
|
||||||
|
$wpdb->update(
|
||||||
|
$calls_table,
|
||||||
|
array(
|
||||||
|
'status' => 'connecting',
|
||||||
|
'agent_phone' => 'client:' . $client_identity,
|
||||||
|
),
|
||||||
|
array('call_sid' => $call_sid),
|
||||||
|
array('%s', '%s'),
|
||||||
|
array('%s')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save current status before setting busy, so we can revert after call ends
|
||||||
|
$status_table = $wpdb->prefix . 'twp_agent_status';
|
||||||
|
$current = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT status FROM $status_table WHERE user_id = %d", $user_id
|
||||||
|
));
|
||||||
|
$pre_call_status = ($current && $current->status !== 'busy') ? $current->status : null;
|
||||||
|
|
||||||
|
$wpdb->update(
|
||||||
|
$status_table,
|
||||||
|
array(
|
||||||
|
'status' => 'busy',
|
||||||
|
'current_call_sid' => $call_sid,
|
||||||
|
'pre_call_status' => $pre_call_status,
|
||||||
|
'auto_busy_at' => null,
|
||||||
|
),
|
||||||
|
array('user_id' => $user_id),
|
||||||
|
array('%s', '%s', '%s', '%s'),
|
||||||
|
array('%d')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel queue alert notifications on all agents' devices
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-fcm.php';
|
||||||
|
$fcm = new TWP_FCM();
|
||||||
|
$fcm->cancel_queue_alert_for_queue($call->queue_id, $call_sid);
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Call accepted via WebRTC client',
|
||||||
|
'call_sid' => $call_sid
|
||||||
|
), 200);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Phone-based path (original flow): dial the agent's 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 and no client_identity provided', array('status' => 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
'agent_call_sid' => $agent_call->sid
|
||||||
),
|
), 200);
|
||||||
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) {
|
} catch (Exception $e) {
|
||||||
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
||||||
@@ -693,18 +763,53 @@ class TWP_Mobile_API {
|
|||||||
$identity = 'agent' . $user_id . $clean_name;
|
$identity = 'agent' . $user_id . $clean_name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure Twilio SDK autoloader is loaded
|
||||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
$twilio = new TWP_Twilio_API();
|
new TWP_Twilio_API();
|
||||||
$result = $twilio->generate_capability_token($identity);
|
|
||||||
|
|
||||||
if (!$result['success']) {
|
$account_sid = get_option('twp_twilio_account_sid');
|
||||||
return new WP_Error('token_error', $result['error'], array('status' => 500));
|
$auth_token = get_option('twp_twilio_auth_token');
|
||||||
|
$twiml_app_sid = get_option('twp_twiml_app_sid');
|
||||||
|
|
||||||
|
if (empty($account_sid) || empty($auth_token) || empty($twiml_app_sid)) {
|
||||||
|
return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessToken requires an API Key (not account credentials).
|
||||||
|
// Auto-create and cache one if it doesn't exist yet.
|
||||||
|
$api_key_sid = get_option('twp_twilio_api_key_sid');
|
||||||
|
$api_key_secret = get_option('twp_twilio_api_key_secret');
|
||||||
|
|
||||||
|
if (empty($api_key_sid) || empty($api_key_secret)) {
|
||||||
|
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
|
||||||
|
$newKey = $client->newKeys->create(['friendlyName' => 'TWP Mobile Voice']);
|
||||||
|
$api_key_sid = $newKey->sid;
|
||||||
|
$api_key_secret = $newKey->secret;
|
||||||
|
update_option('twp_twilio_api_key_sid', $api_key_sid);
|
||||||
|
update_option('twp_twilio_api_key_secret', $api_key_secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = new \Twilio\Jwt\AccessToken($account_sid, $api_key_sid, $api_key_secret, 3600, $identity);
|
||||||
|
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
|
||||||
|
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
|
||||||
|
$voiceGrant->setIncomingAllow(true);
|
||||||
|
|
||||||
|
// Include FCM push credential for incoming call notifications.
|
||||||
|
// Auto-create from the stored Firebase service account JSON if not yet created.
|
||||||
|
$push_credential_sid = get_option('twp_twilio_push_credential_sid');
|
||||||
|
if (empty($push_credential_sid)) {
|
||||||
|
$push_credential_sid = $this->ensure_push_credential($account_sid, $auth_token);
|
||||||
|
}
|
||||||
|
if (!empty($push_credential_sid)) {
|
||||||
|
$voiceGrant->setPushCredentialSid($push_credential_sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->addGrant($voiceGrant);
|
||||||
|
|
||||||
return new WP_REST_Response(array(
|
return new WP_REST_Response(array(
|
||||||
'token' => $result['data']['token'],
|
'token' => $token->toJWT(),
|
||||||
'identity' => $result['data']['client_name'],
|
'identity' => $identity,
|
||||||
'expires_in' => $result['data']['expires_in']
|
'expires_in' => 3600
|
||||||
), 200);
|
), 200);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -712,6 +817,37 @@ class TWP_Mobile_API {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available Twilio phone numbers for caller ID
|
||||||
|
*/
|
||||||
|
public function get_phone_numbers($request) {
|
||||||
|
try {
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
|
$twilio = new TWP_Twilio_API();
|
||||||
|
$result = $twilio->get_phone_numbers();
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
return new WP_Error('twilio_error', $result['error'], array('status' => 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
$phone_numbers = array();
|
||||||
|
foreach ($result['data']['incoming_phone_numbers'] as $number) {
|
||||||
|
$phone_numbers[] = array(
|
||||||
|
'phone_number' => $number['phone_number'],
|
||||||
|
'friendly_name' => $number['friendly_name'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'success' => true,
|
||||||
|
'phone_numbers' => $phone_numbers
|
||||||
|
), 200);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has access to a queue
|
* Check if user has access to a queue
|
||||||
*/
|
*/
|
||||||
@@ -739,6 +875,79 @@ class TWP_Mobile_API {
|
|||||||
return (bool)$is_assigned;
|
return (bool)$is_assigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin endpoint to force re-creation of the Twilio Push Credential.
|
||||||
|
*/
|
||||||
|
public function setup_push_credential($request) {
|
||||||
|
$user_id = $this->auth->get_current_user_id();
|
||||||
|
$user = get_userdata($user_id);
|
||||||
|
if (!user_can($user, 'manage_options')) {
|
||||||
|
return new WP_Error('forbidden', 'Admin access required', array('status' => 403));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
|
new TWP_Twilio_API();
|
||||||
|
|
||||||
|
$account_sid = get_option('twp_twilio_account_sid');
|
||||||
|
$auth_token = get_option('twp_twilio_auth_token');
|
||||||
|
|
||||||
|
// Force re-creation by clearing existing SID
|
||||||
|
delete_option('twp_twilio_push_credential_sid');
|
||||||
|
$sid = $this->ensure_push_credential($account_sid, $auth_token);
|
||||||
|
|
||||||
|
if (empty($sid)) {
|
||||||
|
return new WP_Error('credential_error', 'Failed to create push credential. Check that Firebase service account JSON is configured in Mobile App Settings.', array('status' => 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(array(
|
||||||
|
'success' => true,
|
||||||
|
'credential_sid' => $sid,
|
||||||
|
), 200);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('TWP setup_push_credential error: ' . $e->getMessage());
|
||||||
|
return new WP_Error('credential_error', $e->getMessage(), array('status' => 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-create Twilio Push Credential from the stored Firebase service account JSON.
|
||||||
|
* Returns the credential SID or empty string on failure.
|
||||||
|
*/
|
||||||
|
private function ensure_push_credential($account_sid, $auth_token) {
|
||||||
|
$sa_json = get_option('twp_fcm_service_account_json', '');
|
||||||
|
if (empty($sa_json)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sa = json_decode($sa_json, true);
|
||||||
|
if (!$sa || empty($sa['project_id']) || empty($sa['private_key'])) {
|
||||||
|
error_log('TWP: Firebase service account JSON is invalid');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
|
||||||
|
|
||||||
|
$credential = $client->notify->v1->credentials->create(
|
||||||
|
'fcm',
|
||||||
|
[
|
||||||
|
'friendlyName' => 'TWP Mobile FCM',
|
||||||
|
'secret' => $sa_json,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
update_option('twp_twilio_push_credential_sid', $credential->sid);
|
||||||
|
error_log('TWP: Created Twilio push credential: ' . $credential->sid);
|
||||||
|
return $credential->sid;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('TWP ensure_push_credential error: ' . $e->getMessage());
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate wait time in seconds
|
* Calculate wait time in seconds
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class TWP_Mobile_Auth {
|
|||||||
private $secret_key;
|
private $secret_key;
|
||||||
private $token_expiry = 86400; // 24 hours in seconds
|
private $token_expiry = 86400; // 24 hours in seconds
|
||||||
private $refresh_expiry = 2592000; // 30 days in seconds
|
private $refresh_expiry = 2592000; // 30 days in seconds
|
||||||
|
private $current_user_id = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
@@ -330,7 +331,7 @@ class TWP_Mobile_Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store user ID for later use
|
// Store user ID for later use
|
||||||
$request->set_param('_twp_user_id', $payload->user_id);
|
$this->current_user_id = $payload->user_id;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -339,8 +340,7 @@ class TWP_Mobile_Auth {
|
|||||||
* Get current user ID from token
|
* Get current user ID from token
|
||||||
*/
|
*/
|
||||||
public function get_current_user_id() {
|
public function get_current_user_id() {
|
||||||
$request = rest_get_server()->get_request();
|
return $this->current_user_id;
|
||||||
return $request->get_param('_twp_user_id');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -423,13 +423,21 @@ class TWP_Mobile_Auth {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||||
|
|
||||||
$wpdb->update(
|
if (!empty($refresh_token)) {
|
||||||
$table,
|
$wpdb->update(
|
||||||
array('fcm_token' => $fcm_token),
|
$table,
|
||||||
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
|
array('fcm_token' => $fcm_token),
|
||||||
array('%s'),
|
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
|
||||||
array('%d', '%s', '%d')
|
array('%s'),
|
||||||
);
|
array('%d', '%s', '%d')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No refresh token — update the most recent active session for this user
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE $table SET fcm_token = %s WHERE user_id = %d AND is_active = 1 AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
|
||||||
|
$fcm_token, $user_id
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,14 +26,32 @@ class TWP_Mobile_SSE {
|
|||||||
'callback' => array($this, 'stream_events'),
|
'callback' => array($this, 'stream_events'),
|
||||||
'permission_callback' => array($this->auth, 'verify_token')
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
));
|
));
|
||||||
|
register_rest_route('twilio-mobile/v1', '/stream/poll', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => array($this, 'poll_state'),
|
||||||
|
'permission_callback' => array($this->auth, 'verify_token')
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return current state as JSON (polling alternative to SSE)
|
||||||
|
*/
|
||||||
|
public function poll_state($request) {
|
||||||
|
$user_id = $this->auth->get_current_user_id();
|
||||||
|
if (!$user_id) {
|
||||||
|
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
||||||
|
}
|
||||||
|
return rest_ensure_response($this->get_current_state($user_id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream events to mobile app
|
* Stream events to mobile app
|
||||||
*/
|
*/
|
||||||
public function stream_events($request) {
|
public function stream_events($request) {
|
||||||
|
error_log('TWP SSE: stream_events called');
|
||||||
$user_id = $this->auth->get_current_user_id();
|
$user_id = $this->auth->get_current_user_id();
|
||||||
|
error_log('TWP SSE: user_id=' . ($user_id ?: 'false'));
|
||||||
|
|
||||||
if (!$user_id) {
|
if (!$user_id) {
|
||||||
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
||||||
@@ -56,6 +74,15 @@ class TWP_Mobile_SSE {
|
|||||||
ob_end_flush();
|
ob_end_flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush padding to overcome Apache/HTTP2 frame buffering.
|
||||||
|
// SSE comments (lines starting with ':') are ignored by clients.
|
||||||
|
// We send >4KB to ensure the first HTTP/2 DATA frame is flushed.
|
||||||
|
echo ':' . str_repeat(' ', 4096) . "\n\n";
|
||||||
|
if (ob_get_level() > 0) ob_flush();
|
||||||
|
flush();
|
||||||
|
|
||||||
|
error_log('TWP SSE: padding flushed, sending connected event');
|
||||||
|
|
||||||
// Send initial connection event
|
// Send initial connection event
|
||||||
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
|
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,13 @@ class TWP_Webhooks {
|
|||||||
*/
|
*/
|
||||||
public function handle_browser_voice($request) {
|
public function handle_browser_voice($request) {
|
||||||
$params = $request->get_params();
|
$params = $request->get_params();
|
||||||
|
error_log('TWP browser-voice webhook params: ' . json_encode(array(
|
||||||
|
'From' => $params['From'] ?? '',
|
||||||
|
'To' => $params['To'] ?? '',
|
||||||
|
'CallerId' => $params['CallerId'] ?? '',
|
||||||
|
'CallSid' => $params['CallSid'] ?? '',
|
||||||
|
)));
|
||||||
|
|
||||||
$call_data = array(
|
$call_data = array(
|
||||||
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
|
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
|
||||||
'From' => isset($params['From']) ? $params['From'] : '',
|
'From' => isset($params['From']) ? $params['From'] : '',
|
||||||
@@ -371,17 +377,45 @@ class TWP_Webhooks {
|
|||||||
|
|
||||||
if (isset($params['To']) && !empty($params['To'])) {
|
if (isset($params['To']) && !empty($params['To'])) {
|
||||||
$to_number = $params['To'];
|
$to_number = $params['To'];
|
||||||
$from_number = isset($params['From']) ? $params['From'] : '';
|
// Mobile SDK sends From as identity (e.g. "agent2jknapp"), browser sends From as phone number
|
||||||
|
// Only use CallerId/From if it looks like a phone number (starts with + or is all digits)
|
||||||
|
$from_number = '';
|
||||||
|
if (!empty($params['CallerId']) && preg_match('/^\+?\d+$/', $params['CallerId'])) {
|
||||||
|
$from_number = $params['CallerId'];
|
||||||
|
} elseif (!empty($params['From']) && preg_match('/^\+?\d+$/', $params['From'])) {
|
||||||
|
$from_number = $params['From'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default caller ID if no valid one provided
|
||||||
|
if (empty($from_number)) {
|
||||||
|
$from_number = get_option('twp_caller_id_number', '');
|
||||||
|
}
|
||||||
|
if (empty($from_number)) {
|
||||||
|
$from_number = get_option('twp_default_sms_number', '');
|
||||||
|
}
|
||||||
|
// Last resort: fetch first Twilio number from API
|
||||||
|
if (empty($from_number)) {
|
||||||
|
try {
|
||||||
|
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||||
|
$twilio = new TWP_Twilio_API();
|
||||||
|
$numbers = $twilio->get_phone_numbers();
|
||||||
|
if (!empty($numbers['data']['incoming_phone_numbers'][0]['phone_number'])) {
|
||||||
|
$from_number = $numbers['data']['incoming_phone_numbers'][0]['phone_number'];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('TWP browser-voice: failed to fetch default number: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If it's an outgoing call to a phone number
|
// If it's an outgoing call to a phone number
|
||||||
if (strpos($to_number, 'client:') !== 0) {
|
if (strpos($to_number, 'client:') !== 0) {
|
||||||
$twiml .= '<Dial timeout="30"';
|
$twiml .= '<Dial timeout="30"';
|
||||||
|
|
||||||
// Add caller ID if provided
|
// Add caller ID (required for outbound calls to phone numbers)
|
||||||
if (!empty($from_number) && strpos($from_number, 'client:') !== 0) {
|
if (!empty($from_number)) {
|
||||||
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
|
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
$twiml .= '>';
|
$twiml .= '>';
|
||||||
$twiml .= '<Number>' . htmlspecialchars($to_number) . '</Number>';
|
$twiml .= '<Number>' . htmlspecialchars($to_number) . '</Number>';
|
||||||
$twiml .= '</Dial>';
|
$twiml .= '</Dial>';
|
||||||
@@ -394,9 +428,11 @@ class TWP_Webhooks {
|
|||||||
} else {
|
} else {
|
||||||
$twiml .= '<Say voice="alice">No destination number provided.</Say>';
|
$twiml .= '<Say voice="alice">No destination number provided.</Say>';
|
||||||
}
|
}
|
||||||
|
|
||||||
$twiml .= '</Response>';
|
$twiml .= '</Response>';
|
||||||
|
|
||||||
|
error_log('TWP browser-voice TwiML: ' . $twiml);
|
||||||
|
|
||||||
return $this->send_twiml_response($twiml);
|
return $this->send_twiml_response($twiml);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,11 +944,36 @@ class TWP_Webhooks {
|
|||||||
// Update call status in queue if applicable
|
// Update call status in queue if applicable
|
||||||
// Remove from queue for any terminal call state
|
// Remove from queue for any terminal call state
|
||||||
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) {
|
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) {
|
||||||
|
// Get queue_id before removing so we can send cancel notifications
|
||||||
|
global $wpdb;
|
||||||
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
|
$queued_call = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT queue_id FROM $calls_table WHERE call_sid = %s",
|
||||||
|
$status_data['CallSid']
|
||||||
|
));
|
||||||
|
|
||||||
$queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
|
$queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
|
||||||
if ($queue_removed) {
|
if ($queue_removed) {
|
||||||
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']);
|
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']);
|
||||||
error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')');
|
error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')');
|
||||||
|
|
||||||
|
// Cancel queue alert notifications on agents' devices
|
||||||
|
if ($queued_call) {
|
||||||
|
require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php';
|
||||||
|
$fcm = new TWP_FCM();
|
||||||
|
$fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $status_data['CallSid']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set auto_busy_at for agents whose call just ended, so they revert after 30s
|
||||||
|
$agent_status_table = $wpdb->prefix . 'twp_agent_status';
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE $agent_status_table
|
||||||
|
SET auto_busy_at = %s, current_call_sid = NULL
|
||||||
|
WHERE current_call_sid = %s AND status = 'busy'",
|
||||||
|
current_time('mysql'),
|
||||||
|
$status_data['CallSid']
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty response
|
// Empty response
|
||||||
|
|||||||
176
mobile/README.md
Normal file
176
mobile/README.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# TWP Softphone — Mobile App
|
||||||
|
|
||||||
|
Flutter-based VoIP softphone client for the Twilio WordPress Plugin. Uses the Twilio Voice SDK (WebRTC) to make and receive calls via the Android Telecom framework.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Flutter 3.29+ (tested with 3.41.4)
|
||||||
|
- Android device/tablet (API 26+)
|
||||||
|
- TWP WordPress plugin installed and configured on server
|
||||||
|
- Twilio account with Voice capability
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
flutter pub get
|
||||||
|
flutter build apk --debug
|
||||||
|
adb install build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Setup
|
||||||
|
|
||||||
|
The app connects to your WordPress site running the TWP plugin. The server must have:
|
||||||
|
|
||||||
|
1. **TWP Plugin** installed and activated
|
||||||
|
2. **Twilio credentials** configured (Account SID, Auth Token)
|
||||||
|
3. **At least one Twilio phone number** purchased
|
||||||
|
4. **A WordPress user** with agent permissions
|
||||||
|
|
||||||
|
### SSE (Server-Sent Events) — Apache + PHP-FPM
|
||||||
|
|
||||||
|
The app uses SSE for real-time updates (queue changes, agent status). On Apache with PHP-FPM, `mod_proxy_fcgi` buffers output by default, which breaks SSE streaming.
|
||||||
|
|
||||||
|
**Fix** — Create a config file on the web server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/path/to/wordpress/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
|
||||||
|
httpd -t && systemctl restart httpd
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Adjust the paths:**
|
||||||
|
> - Socket path must match your PHP-FPM config (check `grep fcgi /etc/httpd/conf.d/php.conf`)
|
||||||
|
> - Document root must match your WordPress installation path
|
||||||
|
|
||||||
|
**Diagnosis** — If the green connection dot stays red:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current PHP-FPM proxy config
|
||||||
|
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
|
||||||
|
|
||||||
|
# Check if flushpackets is configured
|
||||||
|
grep -r "flushpackets" /etc/httpd/conf.d/
|
||||||
|
|
||||||
|
# Test SSE endpoint (should stream data continuously, not hang)
|
||||||
|
curl -N -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
https://your-site.com/wp-json/twilio-mobile/v1/stream/events
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `flushpackets=on` is a `ProxyPassMatch` directive — it **cannot** go in `.htaccess`
|
||||||
|
- If using **nginx** instead of Apache, the `X-Accel-Buffering: no` header (already in the PHP code) handles this automatically
|
||||||
|
- The app automatically falls back to 5-second polling if SSE fails, so the app still works without this config — just with higher latency
|
||||||
|
|
||||||
|
## App Setup (Android)
|
||||||
|
|
||||||
|
### First Launch
|
||||||
|
|
||||||
|
1. Open the app and enter your server URL (e.g., `https://phone.cloud-hosting.io`)
|
||||||
|
2. Log in with your WordPress credentials
|
||||||
|
3. Grant permissions when prompted:
|
||||||
|
- Microphone (required for calls)
|
||||||
|
- Phone/Call (required for Android Telecom integration)
|
||||||
|
|
||||||
|
### Phone Account
|
||||||
|
|
||||||
|
Android requires a registered and **enabled** phone account for VoIP apps. The app registers automatically, but enabling must be done manually:
|
||||||
|
|
||||||
|
1. If prompted, tap **"Open Settings"** to go to Android's Phone Account settings
|
||||||
|
2. Find **"TWP Softphone"** in the list and toggle it **ON**
|
||||||
|
3. Return to the app
|
||||||
|
|
||||||
|
If you skipped this step, tap the orange warning card on the dashboard.
|
||||||
|
|
||||||
|
> **Path:** Settings → Apps → Default apps → Phone → Calling accounts → TWP Softphone
|
||||||
|
|
||||||
|
### Making Calls
|
||||||
|
|
||||||
|
1. Tap the phone FAB (bottom right) to open the dialer
|
||||||
|
2. Enter the phone number
|
||||||
|
3. Caller ID is auto-selected from your Twilio numbers
|
||||||
|
4. Tap **Call** — the Android system call screen (InCallUI) handles the active call
|
||||||
|
|
||||||
|
### Receiving Calls
|
||||||
|
|
||||||
|
Incoming calls appear via Android's native call UI. Answer/reject using the standard Android interface.
|
||||||
|
|
||||||
|
> **Note:** FCM push notifications are required for receiving calls when the app is in the background. This requires `google-services.json` in `android/app/`.
|
||||||
|
|
||||||
|
### Queue Management
|
||||||
|
|
||||||
|
- View assigned queues on the dashboard
|
||||||
|
- Tap a queue with waiting calls to see callers
|
||||||
|
- Tap **Accept** to take a call from the queue
|
||||||
|
|
||||||
|
### Agent Status
|
||||||
|
|
||||||
|
Toggle between **Available**, **Busy**, and **Offline** using the status bar at the top of the dashboard.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── config/ # App configuration
|
||||||
|
├── models/ # Data models (CallInfo, QueueState, AgentStatus, User)
|
||||||
|
├── providers/ # State management (AuthProvider, CallProvider, AgentProvider)
|
||||||
|
├── screens/ # UI screens (Login, Dashboard, Settings, ActiveCall)
|
||||||
|
├── services/ # API/SDK services (VoiceService, SseService, ApiClient, AuthService)
|
||||||
|
├── widgets/ # Reusable widgets (Dialpad, QueueCard, AgentStatusToggle)
|
||||||
|
└── main.dart # App entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
34 tests covering CallInfo, QueueState, and CallProvider.
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug APK
|
||||||
|
flutter build apk --debug
|
||||||
|
|
||||||
|
# Release APK (requires signing config)
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### ADB Deployment (WiFi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to device
|
||||||
|
adb connect DEVICE_IP:PORT
|
||||||
|
|
||||||
|
# Install
|
||||||
|
adb install -r build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
|
||||||
|
# Launch
|
||||||
|
adb shell am start -n io.cloudhosting.twp.twp_softphone/.MainActivity
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
adb logcat -s flutter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Dependencies
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `twilio_voice` | Twilio Voice SDK (WebRTC calling) |
|
||||||
|
| `provider` | State management |
|
||||||
|
| `dio` | HTTP client (REST API, SSE) |
|
||||||
|
| `firebase_messaging` | FCM push for incoming calls |
|
||||||
|
| `flutter_secure_storage` | Secure token storage |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Green dot stays red | SSE buffering — see [Server Setup](#sse-server-sent-events--apache--php-fpm) |
|
||||||
|
| "No registered phone account" | Enable phone account in Android Settings (see [Phone Account](#phone-account)) |
|
||||||
|
| Calls fail with "Invalid callerId" | Server webhook needs phone number validation — check `handle_browser_voice` in `class-twp-webhooks.php` |
|
||||||
|
| App hangs on login | Check server is reachable: `curl https://your-site.com/wp-json/twilio-mobile/v1/auth/login` |
|
||||||
|
| No incoming calls | Ensure FCM is configured (`google-services.json`) and phone account is enabled |
|
||||||
@@ -51,6 +51,15 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT"/>
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- Twilio Voice FCM handler — must have higher priority than Flutter's default -->
|
||||||
|
<service
|
||||||
|
android:name="com.twilio.twilio_voice.fcm.VoiceFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter android:priority="10">
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|||||||
BIN
mobile/android/app/src/main/res/raw/queue_alert.ogg
Normal file
BIN
mobile/android/app/src/main/res/raw/queue_alert.ogg
Normal file
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
flutter.sdk=/opt/flutter
|
|
||||||
sdk.dir=/opt/android-sdk
|
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/agent_status.dart';
|
import '../models/agent_status.dart';
|
||||||
import '../models/queue_state.dart';
|
import '../models/queue_state.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../services/sse_service.dart';
|
import '../services/sse_service.dart';
|
||||||
|
|
||||||
|
class PhoneNumber {
|
||||||
|
final String phoneNumber;
|
||||||
|
final String friendlyName;
|
||||||
|
PhoneNumber({required this.phoneNumber, required this.friendlyName});
|
||||||
|
factory PhoneNumber.fromJson(Map<String, dynamic> json) => PhoneNumber(
|
||||||
|
phoneNumber: json['phone_number'] as String,
|
||||||
|
friendlyName: json['friendly_name'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class AgentProvider extends ChangeNotifier {
|
class AgentProvider extends ChangeNotifier {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
final SseService _sse;
|
final SseService _sse;
|
||||||
@@ -12,12 +23,15 @@ class AgentProvider extends ChangeNotifier {
|
|||||||
AgentStatus? _status;
|
AgentStatus? _status;
|
||||||
List<QueueInfo> _queues = [];
|
List<QueueInfo> _queues = [];
|
||||||
bool _sseConnected = false;
|
bool _sseConnected = false;
|
||||||
|
List<PhoneNumber> _phoneNumbers = [];
|
||||||
StreamSubscription? _sseSub;
|
StreamSubscription? _sseSub;
|
||||||
StreamSubscription? _connSub;
|
StreamSubscription? _connSub;
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
AgentStatus? get status => _status;
|
AgentStatus? get status => _status;
|
||||||
List<QueueInfo> get queues => _queues;
|
List<QueueInfo> get queues => _queues;
|
||||||
bool get sseConnected => _sseConnected;
|
bool get sseConnected => _sseConnected;
|
||||||
|
List<PhoneNumber> get phoneNumbers => _phoneNumbers;
|
||||||
|
|
||||||
AgentProvider(this._api, this._sse) {
|
AgentProvider(this._api, this._sse) {
|
||||||
_connSub = _sse.connectionState.listen((connected) {
|
_connSub = _sse.connectionState.listen((connected) {
|
||||||
@@ -26,6 +40,11 @@ class AgentProvider extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
|
|
||||||
_sseSub = _sse.events.listen(_handleSseEvent);
|
_sseSub = _sse.events.listen(_handleSseEvent);
|
||||||
|
|
||||||
|
_refreshTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 15),
|
||||||
|
(_) => fetchQueues(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchStatus() async {
|
Future<void> fetchStatus() async {
|
||||||
@@ -33,7 +52,10 @@ class AgentProvider extends ChangeNotifier {
|
|||||||
final response = await _api.dio.get('/agent/status');
|
final response = await _api.dio.get('/agent/status');
|
||||||
_status = AgentStatus.fromJson(response.data);
|
_status = AgentStatus.fromJson(response.data);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('AgentProvider.fetchStatus error: $e');
|
||||||
|
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateStatus(AgentStatusValue newStatus) async {
|
Future<void> updateStatus(AgentStatusValue newStatus) async {
|
||||||
@@ -49,7 +71,12 @@ class AgentProvider extends ChangeNotifier {
|
|||||||
currentCallSid: _status?.currentCallSid,
|
currentCallSid: _status?.currentCallSid,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('AgentProvider.updateStatus error: $e');
|
||||||
|
if (e is DioException) {
|
||||||
|
debugPrint('AgentProvider.updateStatus response: ${e.response?.data}');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchQueues() async {
|
Future<void> fetchQueues() async {
|
||||||
@@ -60,11 +87,27 @@ class AgentProvider extends ChangeNotifier {
|
|||||||
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
|
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('AgentProvider.fetchQueues error: $e');
|
||||||
|
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPhoneNumbers() async {
|
||||||
|
try {
|
||||||
|
final response = await _api.dio.get('/phone-numbers');
|
||||||
|
final data = response.data;
|
||||||
|
_phoneNumbers = (data['phone_numbers'] as List)
|
||||||
|
.map((p) => PhoneNumber.fromJson(p as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AgentProvider.fetchPhoneNumbers error: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
await Future.wait([fetchStatus(), fetchQueues()]);
|
await Future.wait([fetchStatus(), fetchQueues(), fetchPhoneNumbers()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSseEvent(SseEvent event) {
|
void _handleSseEvent(SseEvent event) {
|
||||||
@@ -81,6 +124,7 @@ class AgentProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
_sseSub?.cancel();
|
_sseSub?.cancel();
|
||||||
_connSub?.cancel();
|
_connSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ enum AuthState { unauthenticated, authenticating, authenticated }
|
|||||||
|
|
||||||
class AuthProvider extends ChangeNotifier {
|
class AuthProvider extends ChangeNotifier {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
late final AuthService _authService;
|
late AuthService _authService;
|
||||||
late final VoiceService _voiceService;
|
late VoiceService _voiceService;
|
||||||
late final PushNotificationService _pushService;
|
late PushNotificationService _pushService;
|
||||||
late final SseService _sseService;
|
late SseService _sseService;
|
||||||
|
|
||||||
AuthState _state = AuthState.unauthenticated;
|
AuthState _state = AuthState.unauthenticated;
|
||||||
User? _user;
|
User? _user;
|
||||||
@@ -36,8 +36,9 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> tryRestoreSession() async {
|
Future<void> tryRestoreSession() async {
|
||||||
final restored = await _authService.tryRestoreSession();
|
final user = await _authService.tryRestoreSession();
|
||||||
if (restored) {
|
if (user != null) {
|
||||||
|
_user = user;
|
||||||
_state = AuthState.authenticated;
|
_state = AuthState.authenticated;
|
||||||
await _initializeServices();
|
await _initializeServices();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -63,13 +64,19 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
Future<void> _initializeServices() async {
|
Future<void> _initializeServices() async {
|
||||||
try {
|
try {
|
||||||
await _pushService.initialize();
|
await _pushService.initialize();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('AuthProvider: push service init error: $e');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await _voiceService.initialize();
|
await _voiceService.initialize(deviceToken: _pushService.fcmToken);
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('AuthProvider: voice service init error: $e');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await _sseService.connect();
|
await _sseService.connect();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('AuthProvider: SSE connect error: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
@@ -90,10 +97,18 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForceLogout() {
|
void _handleForceLogout() {
|
||||||
|
_voiceService.dispose();
|
||||||
|
_sseService.disconnect();
|
||||||
|
|
||||||
_state = AuthState.unauthenticated;
|
_state = AuthState.unauthenticated;
|
||||||
_user = null;
|
_user = null;
|
||||||
_error = 'Session expired. Please log in again.';
|
_error = 'Session expired. Please log in again.';
|
||||||
_sseService.disconnect();
|
|
||||||
|
// Re-create services for potential re-login
|
||||||
|
_voiceService = VoiceService(_apiClient);
|
||||||
|
_pushService = PushNotificationService(_apiClient);
|
||||||
|
_sseService = SseService(_apiClient);
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class CallProvider extends ChangeNotifier {
|
|||||||
Timer? _durationTimer;
|
Timer? _durationTimer;
|
||||||
StreamSubscription? _eventSub;
|
StreamSubscription? _eventSub;
|
||||||
DateTime? _connectedAt;
|
DateTime? _connectedAt;
|
||||||
|
bool _pendingAutoAnswer = false;
|
||||||
|
|
||||||
CallInfo get callInfo => _callInfo;
|
CallInfo get callInfo => _callInfo;
|
||||||
|
|
||||||
@@ -20,9 +21,13 @@ class CallProvider extends ChangeNotifier {
|
|||||||
void _handleCallEvent(CallEvent event) {
|
void _handleCallEvent(CallEvent event) {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case CallEvent.incoming:
|
case CallEvent.incoming:
|
||||||
_callInfo = _callInfo.copyWith(
|
if (_pendingAutoAnswer) {
|
||||||
state: CallState.ringing,
|
_pendingAutoAnswer = false;
|
||||||
);
|
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
||||||
|
_voiceService.answer();
|
||||||
|
} else {
|
||||||
|
_callInfo = _callInfo.copyWith(state: CallState.ringing);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case CallEvent.ringing:
|
case CallEvent.ringing:
|
||||||
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
||||||
@@ -47,20 +52,24 @@ class CallProvider extends ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update caller info from active call
|
// Update caller info from active call (skip if call just ended)
|
||||||
final call = TwilioVoice.instance.call;
|
if (_callInfo.state != CallState.idle) {
|
||||||
final active = call.activeCall;
|
final call = TwilioVoice.instance.call;
|
||||||
if (active != null) {
|
final active = call.activeCall;
|
||||||
_callInfo = _callInfo.copyWith(
|
if (active != null) {
|
||||||
callerNumber: active.from,
|
if (_callInfo.callerNumber == null) {
|
||||||
);
|
_callInfo = _callInfo.copyWith(
|
||||||
// Fetch SID asynchronously
|
callerNumber: active.from,
|
||||||
call.getSid().then((sid) {
|
);
|
||||||
if (sid != null && sid != _callInfo.callSid) {
|
|
||||||
_callInfo = _callInfo.copyWith(callSid: sid);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
});
|
// Fetch SID asynchronously
|
||||||
|
call.getSid().then((sid) {
|
||||||
|
if (sid != null && sid != _callInfo.callSid && _callInfo.isActive) {
|
||||||
|
_callInfo = _callInfo.copyWith(callSid: sid);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -85,7 +94,16 @@ class CallProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> answer() => _voiceService.answer();
|
Future<void> answer() => _voiceService.answer();
|
||||||
Future<void> reject() => _voiceService.reject();
|
Future<void> reject() => _voiceService.reject();
|
||||||
Future<void> hangUp() => _voiceService.hangUp();
|
Future<void> hangUp() async {
|
||||||
|
await _voiceService.hangUp();
|
||||||
|
// If SDK didn't fire callEnded (e.g. no active SDK call), reset manually
|
||||||
|
if (_callInfo.state != CallState.idle) {
|
||||||
|
_stopDurationTimer();
|
||||||
|
_callInfo = const CallInfo();
|
||||||
|
_pendingAutoAnswer = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggleMute() async {
|
Future<void> toggleMute() async {
|
||||||
final newMuted = !_callInfo.isMuted;
|
final newMuted = !_callInfo.isMuted;
|
||||||
@@ -103,13 +121,18 @@ class CallProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
|
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
|
||||||
|
|
||||||
Future<void> makeCall(String number) async {
|
Future<void> makeCall(String number, {String? callerId}) async {
|
||||||
_callInfo = _callInfo.copyWith(
|
_callInfo = _callInfo.copyWith(
|
||||||
state: CallState.connecting,
|
state: CallState.connecting,
|
||||||
callerNumber: number,
|
callerNumber: number,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _voiceService.makeCall(number);
|
final success = await _voiceService.makeCall(number, callerId: callerId);
|
||||||
|
if (!success) {
|
||||||
|
debugPrint('CallProvider.makeCall: call.place() returned false');
|
||||||
|
_callInfo = const CallInfo(); // reset to idle
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> holdCall() async {
|
Future<void> holdCall() async {
|
||||||
@@ -134,6 +157,20 @@ class CallProvider extends ChangeNotifier {
|
|||||||
await _voiceService.transferCall(sid, target);
|
await _voiceService.transferCall(sid, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> acceptQueueCall(String callSid) async {
|
||||||
|
_pendingAutoAnswer = true;
|
||||||
|
_callInfo = _callInfo.copyWith(state: CallState.connecting);
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await _voiceService.acceptQueueCall(callSid);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('CallProvider.acceptQueueCall error: $e');
|
||||||
|
_pendingAutoAnswer = false;
|
||||||
|
_callInfo = const CallInfo();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stopDurationTimer();
|
_stopDurationTimer();
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:twilio_voice/twilio_voice.dart';
|
||||||
|
import '../models/queue_state.dart';
|
||||||
import '../providers/agent_provider.dart';
|
import '../providers/agent_provider.dart';
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/call_provider.dart';
|
import '../providers/call_provider.dart';
|
||||||
import '../widgets/agent_status_toggle.dart';
|
import '../widgets/agent_status_toggle.dart';
|
||||||
import '../widgets/dialpad.dart';
|
import '../widgets/dialpad.dart';
|
||||||
import '../widgets/queue_card.dart';
|
import '../widgets/queue_card.dart';
|
||||||
import 'active_call_screen.dart';
|
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
class DashboardScreen extends StatefulWidget {
|
class DashboardScreen extends StatefulWidget {
|
||||||
@@ -16,16 +21,74 @@ class DashboardScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DashboardScreenState extends State<DashboardScreen> {
|
class _DashboardScreenState extends State<DashboardScreen> {
|
||||||
|
bool _phoneAccountEnabled = true; // assume true until checked
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<AgentProvider>().refresh();
|
context.read<AgentProvider>().refresh();
|
||||||
|
_checkPhoneAccount();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPhoneAccount() async {
|
||||||
|
if (!kIsWeb && Platform.isAndroid) {
|
||||||
|
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
|
||||||
|
if (mounted && !enabled) {
|
||||||
|
setState(() => _phoneAccountEnabled = false);
|
||||||
|
_showPhoneAccountDialog();
|
||||||
|
} else if (mounted) {
|
||||||
|
setState(() => _phoneAccountEnabled = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPhoneAccountDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Enable Phone Account'),
|
||||||
|
content: const Text(
|
||||||
|
'TWP Softphone needs to be enabled as a calling account to make and receive calls.\n\n'
|
||||||
|
'Tap "Open Settings" below, then find "TWP Softphone" in the list and toggle it ON.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Later'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
await TwilioVoice.instance.openPhoneAccountSettings();
|
||||||
|
// Poll until enabled or user comes back
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
if (!mounted) return;
|
||||||
|
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
|
||||||
|
if (enabled) {
|
||||||
|
setState(() => _phoneAccountEnabled = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-check one more time when coming back
|
||||||
|
_checkPhoneAccount();
|
||||||
|
},
|
||||||
|
child: const Text('Open Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showDialer(BuildContext context) {
|
void _showDialer(BuildContext context) {
|
||||||
final numberController = TextEditingController();
|
final numberController = TextEditingController();
|
||||||
|
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
|
||||||
|
// Auto-select first phone number as caller ID
|
||||||
|
String? selectedCallerId =
|
||||||
|
phoneNumbers.isNotEmpty ? phoneNumbers.first.phoneNumber : null;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -34,94 +97,211 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
return Padding(
|
return StatefulBuilder(
|
||||||
padding: EdgeInsets.only(
|
builder: (ctx, setSheetState) {
|
||||||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
return Padding(
|
||||||
top: 16,
|
padding: EdgeInsets.only(
|
||||||
left: 16,
|
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||||
right: 16,
|
top: 16,
|
||||||
),
|
left: 16,
|
||||||
child: Column(
|
right: 16,
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
child: Column(
|
||||||
// Number display
|
mainAxisSize: MainAxisSize.min,
|
||||||
TextField(
|
children: [
|
||||||
controller: numberController,
|
// Number display
|
||||||
keyboardType: TextInputType.phone,
|
TextField(
|
||||||
autofillHints: const [AutofillHints.telephoneNumber],
|
controller: numberController,
|
||||||
textAlign: TextAlign.center,
|
keyboardType: TextInputType.phone,
|
||||||
style: Theme.of(ctx).textTheme.headlineSmall,
|
autofillHints: const [AutofillHints.telephoneNumber],
|
||||||
decoration: InputDecoration(
|
textAlign: TextAlign.center,
|
||||||
hintText: 'Enter phone number',
|
style: Theme.of(ctx).textTheme.headlineSmall,
|
||||||
suffixIcon: IconButton(
|
decoration: InputDecoration(
|
||||||
icon: const Icon(Icons.backspace_outlined),
|
hintText: 'Enter phone number',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.backspace_outlined),
|
||||||
|
onPressed: () {
|
||||||
|
final text = numberController.text;
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
numberController.text =
|
||||||
|
text.substring(0, text.length - 1);
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Caller ID selector (only if multiple numbers)
|
||||||
|
if (phoneNumbers.length > 1) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: selectedCallerId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Caller ID',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
items: phoneNumbers.map((p) => DropdownMenuItem<String>(
|
||||||
|
value: p.phoneNumber,
|
||||||
|
child: Text('${p.friendlyName} (${p.phoneNumber})'),
|
||||||
|
)).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedCallerId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] else if (phoneNumbers.length == 1) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Caller ID: ${phoneNumbers.first.phoneNumber}',
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Dialpad
|
||||||
|
Dialpad(
|
||||||
|
onDigit: (digit) {
|
||||||
|
numberController.text += digit;
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onClose: () => Navigator.pop(ctx),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Call button
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 48),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
label: const Text('Call'),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final text = numberController.text;
|
final number = numberController.text.trim();
|
||||||
if (text.isNotEmpty) {
|
if (number.isEmpty) return;
|
||||||
numberController.text =
|
if (selectedCallerId == null) {
|
||||||
text.substring(0, text.length - 1);
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
numberController.selection = TextSelection.fromPosition(
|
const SnackBar(content: Text('No caller ID available. Add a phone number first.')),
|
||||||
TextPosition(offset: numberController.text.length),
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
|
||||||
|
Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
);
|
||||||
// Dialpad
|
},
|
||||||
Dialpad(
|
|
||||||
onDigit: (digit) {
|
|
||||||
numberController.text += digit;
|
|
||||||
numberController.selection = TextSelection.fromPosition(
|
|
||||||
TextPosition(offset: numberController.text.length),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onClose: () => Navigator.pop(ctx),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Call button
|
|
||||||
ElevatedButton.icon(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
minimumSize: const Size(double.infinity, 48),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.call),
|
|
||||||
label: const Text('Call'),
|
|
||||||
onPressed: () {
|
|
||||||
final number = numberController.text.trim();
|
|
||||||
if (number.isNotEmpty) {
|
|
||||||
context.read<CallProvider>().makeCall(number);
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showQueueCalls(BuildContext context, QueueInfo queue) {
|
||||||
|
final voiceService = context.read<AuthProvider>().voiceService;
|
||||||
|
final callProvider = context.read<CallProvider>();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
|
future: voiceService.getQueueCalls(queue.id),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: Text('Error loading calls: ${snapshot.error}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final calls = (snapshot.data ?? [])
|
||||||
|
.map((c) => QueueCall.fromJson(c))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (calls.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: Text('No calls waiting')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'${queue.name} - Waiting Calls',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...calls.map((call) => ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
child: Icon(Icons.phone_in_talk),
|
||||||
|
),
|
||||||
|
title: Text(call.fromNumber),
|
||||||
|
subtitle: Text('Waiting ${_formatWaitTime(call.waitTime)}'),
|
||||||
|
trailing: FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.call, size: 18),
|
||||||
|
label: const Text('Accept'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
callProvider.acceptQueueCall(call.callSid);
|
||||||
|
// Cancel queue alert notification
|
||||||
|
FlutterLocalNotificationsPlugin().cancel(9001);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatWaitTime(int seconds) {
|
||||||
|
if (seconds < 60) return '${seconds}s';
|
||||||
|
final minutes = seconds ~/ 60;
|
||||||
|
final secs = seconds % 60;
|
||||||
|
return '${minutes}m ${secs}s';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final agent = context.watch<AgentProvider>();
|
final agent = context.watch<AgentProvider>();
|
||||||
final call = context.watch<CallProvider>();
|
|
||||||
|
|
||||||
// Navigate to active call screen when a call comes in
|
// Android Telecom framework handles the call UI via the native InCallUI,
|
||||||
if (call.callInfo.isActive) {
|
// so we don't navigate to our own ActiveCallScreen.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
|
||||||
MaterialPageRoute(builder: (_) => const ActiveCallScreen()),
|
|
||||||
(route) => route.isFirst,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -152,6 +332,18 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
|
if (!_phoneAccountEnabled)
|
||||||
|
Card(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.warning, color: Colors.orange.shade700),
|
||||||
|
title: const Text('Phone Account Not Enabled'),
|
||||||
|
subtitle: const Text('Tap to enable calling in settings'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showPhoneAccountDialog(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_phoneAccountEnabled) const SizedBox(height: 8),
|
||||||
const AgentStatusToggle(),
|
const AgentStatusToggle(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('Queues',
|
Text('Queues',
|
||||||
@@ -167,7 +359,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
else
|
else
|
||||||
...agent.queues.map((q) => Padding(
|
...agent.queues.map((q) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: QueueCard(queue: q),
|
child: QueueCard(
|
||||||
|
queue: q,
|
||||||
|
onTap: q.waitingCount > 0
|
||||||
|
? () => _showQueueCalls(context, q)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
@@ -14,11 +16,15 @@ class AuthService {
|
|||||||
{String? fcmToken}) async {
|
{String? fcmToken}) async {
|
||||||
await _api.setBaseUrl(serverUrl);
|
await _api.setBaseUrl(serverUrl);
|
||||||
|
|
||||||
final response = await _api.dio.post('/auth/login', data: {
|
final response = await _api.dio.post(
|
||||||
'username': username,
|
'/auth/login',
|
||||||
'password': password,
|
data: {
|
||||||
if (fcmToken != null) 'fcm_token': fcmToken,
|
'username': username,
|
||||||
});
|
'password': password,
|
||||||
|
if (fcmToken != null) 'fcm_token': fcmToken,
|
||||||
|
},
|
||||||
|
options: Options(receiveTimeout: const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
|
||||||
final data = response.data;
|
final data = response.data;
|
||||||
if (data['success'] != true) {
|
if (data['success'] != true) {
|
||||||
@@ -27,24 +33,31 @@ class AuthService {
|
|||||||
|
|
||||||
await _storage.write(key: 'access_token', value: data['access_token']);
|
await _storage.write(key: 'access_token', value: data['access_token']);
|
||||||
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
|
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
|
||||||
|
await _storage.write(key: 'user_data', value: jsonEncode(data['user']));
|
||||||
|
|
||||||
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
||||||
|
|
||||||
return User.fromJson(data['user']);
|
return User.fromJson(data['user']);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> tryRestoreSession() async {
|
Future<User?> tryRestoreSession() async {
|
||||||
final token = await _storage.read(key: 'access_token');
|
final token = await _storage.read(key: 'access_token');
|
||||||
if (token == null) return false;
|
if (token == null) return null;
|
||||||
|
|
||||||
await _api.restoreBaseUrl();
|
await _api.restoreBaseUrl();
|
||||||
if (_api.dio.options.baseUrl.isEmpty) return false;
|
if (_api.dio.options.baseUrl.isEmpty) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _api.dio.get('/agent/status');
|
final response = await _api.dio.get('/agent/status');
|
||||||
return response.statusCode == 200;
|
if (response.statusCode != 200) return null;
|
||||||
|
|
||||||
|
final userData = await _storage.read(key: 'user_data');
|
||||||
|
if (userData != null) {
|
||||||
|
return User.fromJson(jsonDecode(userData) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,60 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// Notification ID for queue alerts (fixed so we can cancel it).
|
||||||
|
const int _queueAlertNotificationId = 9001;
|
||||||
|
|
||||||
|
/// Background handler — must be top-level function.
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
await Firebase.initializeApp();
|
await Firebase.initializeApp();
|
||||||
// VoIP pushes are handled natively by twilio_voice plugin.
|
final data = message.data;
|
||||||
// Other data messages can show a local notification if needed.
|
final type = data['type'];
|
||||||
|
|
||||||
|
if (type == 'queue_alert') {
|
||||||
|
await _showQueueAlertNotification(data);
|
||||||
|
} else if (type == 'queue_alert_cancel') {
|
||||||
|
final plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
await plugin.cancel(_queueAlertNotificationId);
|
||||||
|
}
|
||||||
|
// VoIP pushes handled natively by twilio_voice plugin.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show an insistent queue alert notification (works from background handler too).
|
||||||
|
Future<void> _showQueueAlertNotification(Map<String, dynamic> data) async {
|
||||||
|
final plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
final title = data['title'] ?? 'Call Waiting';
|
||||||
|
final body = data['body'] ?? 'New call in queue';
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
'twp_queue_alerts',
|
||||||
|
'Queue Alerts',
|
||||||
|
channelDescription: 'Alerts when calls are waiting in queue',
|
||||||
|
importance: Importance.max,
|
||||||
|
priority: Priority.max,
|
||||||
|
playSound: true,
|
||||||
|
sound: const RawResourceAndroidNotificationSound('queue_alert'),
|
||||||
|
enableVibration: true,
|
||||||
|
vibrationPattern: Int64List.fromList([0, 500, 200, 500, 200, 500]),
|
||||||
|
ongoing: true,
|
||||||
|
autoCancel: false,
|
||||||
|
category: AndroidNotificationCategory.alarm,
|
||||||
|
additionalFlags: Int32List.fromList([4]), // FLAG_INSISTENT = 4
|
||||||
|
fullScreenIntent: true,
|
||||||
|
visibility: NotificationVisibility.public,
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugin.show(
|
||||||
|
_queueAlertNotificationId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
NotificationDetails(android: androidDetails),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PushNotificationService {
|
class PushNotificationService {
|
||||||
@@ -15,6 +62,9 @@ class PushNotificationService {
|
|||||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
String? _fcmToken;
|
||||||
|
|
||||||
|
String? get fcmToken => _fcmToken;
|
||||||
|
|
||||||
PushNotificationService(this._api);
|
PushNotificationService(this._api);
|
||||||
|
|
||||||
@@ -36,8 +86,12 @@ class PushNotificationService {
|
|||||||
|
|
||||||
// Get and register FCM token
|
// Get and register FCM token
|
||||||
final token = await _messaging.getToken();
|
final token = await _messaging.getToken();
|
||||||
|
debugPrint('FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
|
_fcmToken = token;
|
||||||
await _registerToken(token);
|
await _registerToken(token);
|
||||||
|
} else {
|
||||||
|
debugPrint('FCM: Failed to get token - Firebase may not be configured correctly');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for token refresh
|
// Listen for token refresh
|
||||||
@@ -60,7 +114,19 @@ class PushNotificationService {
|
|||||||
// VoIP incoming_call is handled by twilio_voice natively
|
// VoIP incoming_call is handled by twilio_voice natively
|
||||||
if (type == 'incoming_call') return;
|
if (type == 'incoming_call') return;
|
||||||
|
|
||||||
// Show local notification for other types (missed call, queue alert, etc.)
|
// Queue alert — show insistent notification
|
||||||
|
if (type == 'queue_alert') {
|
||||||
|
_showQueueAlertNotification(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue alert cancel — dismiss notification
|
||||||
|
if (type == 'queue_alert_cancel') {
|
||||||
|
_localNotifications.cancel(_queueAlertNotificationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show local notification for other types (missed call, etc.)
|
||||||
_localNotifications.show(
|
_localNotifications.show(
|
||||||
message.hashCode,
|
message.hashCode,
|
||||||
data['title'] ?? 'TWP Softphone',
|
data['title'] ?? 'TWP Softphone',
|
||||||
@@ -75,4 +141,9 @@ class PushNotificationService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel any active queue alert (called when agent accepts a call in-app).
|
||||||
|
void cancelQueueAlert() {
|
||||||
|
_localNotifications.cancel(_queueAlertNotificationId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
@@ -25,6 +26,9 @@ class SseService {
|
|||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
int _reconnectAttempt = 0;
|
int _reconnectAttempt = 0;
|
||||||
bool _shouldReconnect = true;
|
bool _shouldReconnect = true;
|
||||||
|
int _sseFailures = 0;
|
||||||
|
Timer? _pollTimer;
|
||||||
|
Map<String, dynamic>? _previousPollState;
|
||||||
|
|
||||||
Stream<SseEvent> get events => _eventController.stream;
|
Stream<SseEvent> get events => _eventController.stream;
|
||||||
Stream<bool> get connectionState => _connectionController.stream;
|
Stream<bool> get connectionState => _connectionController.stream;
|
||||||
@@ -34,34 +38,63 @@ class SseService {
|
|||||||
Future<void> connect() async {
|
Future<void> connect() async {
|
||||||
_shouldReconnect = true;
|
_shouldReconnect = true;
|
||||||
_reconnectAttempt = 0;
|
_reconnectAttempt = 0;
|
||||||
|
_sseFailures = 0;
|
||||||
await _doConnect();
|
await _doConnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doConnect() async {
|
Future<void> _doConnect() async {
|
||||||
|
// After 2 SSE failures, fall back to polling
|
||||||
|
if (_sseFailures >= 2) {
|
||||||
|
debugPrint('SSE: falling back to polling after $_sseFailures failures');
|
||||||
|
_startPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_cancelToken?.cancel();
|
_cancelToken?.cancel();
|
||||||
_cancelToken = CancelToken();
|
_cancelToken = CancelToken();
|
||||||
|
|
||||||
|
// Timer to detect if SSE stream never delivers data (Apache buffering)
|
||||||
|
Timer? firstDataTimer;
|
||||||
|
bool gotData = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final token = await _storage.read(key: 'access_token');
|
final token = await _storage.read(key: 'access_token');
|
||||||
|
debugPrint('SSE: connecting via stream (attempt ${_sseFailures + 1})');
|
||||||
|
|
||||||
|
firstDataTimer = Timer(const Duration(seconds: 8), () {
|
||||||
|
if (!gotData) {
|
||||||
|
debugPrint('SSE: no data received in 8s, cancelling');
|
||||||
|
_cancelToken?.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
final response = await _api.dio.get(
|
final response = await _api.dio.get(
|
||||||
'/stream/events',
|
'/stream/events',
|
||||||
options: Options(
|
options: Options(
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
responseType: ResponseType.stream,
|
responseType: ResponseType.stream,
|
||||||
|
receiveTimeout: Duration.zero,
|
||||||
),
|
),
|
||||||
cancelToken: _cancelToken,
|
cancelToken: _cancelToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
debugPrint('SSE: connected, status=${response.statusCode}');
|
||||||
_connectionController.add(true);
|
_connectionController.add(true);
|
||||||
_reconnectAttempt = 0;
|
_reconnectAttempt = 0;
|
||||||
|
_sseFailures = 0;
|
||||||
|
|
||||||
final stream = response.data.stream as Stream<List<int>>;
|
final stream = response.data.stream as Stream<List<int>>;
|
||||||
String buffer = '';
|
String buffer = '';
|
||||||
|
|
||||||
await for (final chunk in stream) {
|
await for (final chunk in stream) {
|
||||||
|
if (!gotData) {
|
||||||
|
gotData = true;
|
||||||
|
firstDataTimer.cancel();
|
||||||
|
debugPrint('SSE: first data received');
|
||||||
|
}
|
||||||
buffer += utf8.decode(chunk);
|
buffer += utf8.decode(chunk);
|
||||||
final lines = buffer.split('\n');
|
final lines = buffer.split('\n');
|
||||||
buffer = lines.removeLast(); // keep incomplete line in buffer
|
buffer = lines.removeLast();
|
||||||
|
|
||||||
String? eventName;
|
String? eventName;
|
||||||
String? dataStr;
|
String? dataStr;
|
||||||
@@ -82,8 +115,22 @@ class SseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
firstDataTimer?.cancel();
|
||||||
_connectionController.add(false);
|
// Distinguish user-initiated cancel from timeout cancel
|
||||||
|
if (e is DioException && e.type == DioExceptionType.cancel) {
|
||||||
|
if (!gotData && _shouldReconnect) {
|
||||||
|
// Cancelled by our firstDataTimer — count as SSE failure
|
||||||
|
debugPrint('SSE: stream timed out (no data), failure ${_sseFailures + 1}');
|
||||||
|
_sseFailures++;
|
||||||
|
_connectionController.add(false);
|
||||||
|
} else {
|
||||||
|
return; // User-initiated disconnect
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint('SSE: stream error: $e');
|
||||||
|
_sseFailures++;
|
||||||
|
_connectionController.add(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_shouldReconnect) {
|
if (_shouldReconnect) {
|
||||||
@@ -104,9 +151,81 @@ class SseService {
|
|||||||
_reconnectTimer = Timer(delay, _doConnect);
|
_reconnectTimer = Timer(delay, _doConnect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Polling fallback when SSE streaming doesn't work
|
||||||
|
void _startPolling() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_previousPollState = null;
|
||||||
|
_poll();
|
||||||
|
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _poll());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _poll() async {
|
||||||
|
if (!_shouldReconnect) return;
|
||||||
|
try {
|
||||||
|
final response = await _api.dio.get('/stream/poll');
|
||||||
|
final data = Map<String, dynamic>.from(response.data);
|
||||||
|
_connectionController.add(true);
|
||||||
|
|
||||||
|
if (_previousPollState != null) {
|
||||||
|
_diffAndEmit(_previousPollState!, data);
|
||||||
|
}
|
||||||
|
_previousPollState = data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('SSE poll error: $e');
|
||||||
|
_connectionController.add(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _diffAndEmit(Map<String, dynamic> prev, Map<String, dynamic> curr) {
|
||||||
|
final prevStatus = prev['agent_status']?.toString();
|
||||||
|
final currStatus = curr['agent_status']?.toString();
|
||||||
|
if (prevStatus != currStatus) {
|
||||||
|
_eventController.add(SseEvent(
|
||||||
|
event: 'agent_status_changed',
|
||||||
|
data: (curr['agent_status'] as Map<String, dynamic>?) ?? {},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final prevQueues = prev['queues'] as Map<String, dynamic>? ?? {};
|
||||||
|
final currQueues = curr['queues'] as Map<String, dynamic>? ?? {};
|
||||||
|
for (final entry in currQueues.entries) {
|
||||||
|
final currQueue = Map<String, dynamic>.from(entry.value);
|
||||||
|
final prevQueue = prevQueues[entry.key] as Map<String, dynamic>?;
|
||||||
|
if (prevQueue == null) {
|
||||||
|
_eventController.add(SseEvent(event: 'queue_added', data: currQueue));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final currCount = currQueue['waiting_count'] as int? ?? 0;
|
||||||
|
final prevCount = prevQueue['waiting_count'] as int? ?? 0;
|
||||||
|
if (currCount > prevCount) {
|
||||||
|
_eventController.add(SseEvent(event: 'call_enqueued', data: currQueue));
|
||||||
|
} else if (currCount < prevCount) {
|
||||||
|
_eventController.add(SseEvent(event: 'call_dequeued', data: currQueue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final prevCall = prev['current_call']?.toString();
|
||||||
|
final currCall = curr['current_call']?.toString();
|
||||||
|
if (prevCall != currCall) {
|
||||||
|
if (curr['current_call'] != null && prev['current_call'] == null) {
|
||||||
|
_eventController.add(SseEvent(
|
||||||
|
event: 'call_started',
|
||||||
|
data: curr['current_call'] as Map<String, dynamic>,
|
||||||
|
));
|
||||||
|
} else if (curr['current_call'] == null && prev['current_call'] != null) {
|
||||||
|
_eventController.add(SseEvent(
|
||||||
|
event: 'call_ended',
|
||||||
|
data: prev['current_call'] as Map<String, dynamic>,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
_shouldReconnect = false;
|
_shouldReconnect = false;
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_pollTimer = null;
|
||||||
_cancelToken?.cancel();
|
_cancelToken?.cancel();
|
||||||
_connectionController.add(false);
|
_connectionController.add(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:twilio_voice/twilio_voice.dart';
|
import 'package:twilio_voice/twilio_voice.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
@@ -6,6 +9,8 @@ class VoiceService {
|
|||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
Timer? _tokenRefreshTimer;
|
Timer? _tokenRefreshTimer;
|
||||||
String? _identity;
|
String? _identity;
|
||||||
|
String? _deviceToken;
|
||||||
|
StreamSubscription? _eventSubscription;
|
||||||
|
|
||||||
final StreamController<CallEvent> _callEventController =
|
final StreamController<CallEvent> _callEventController =
|
||||||
StreamController<CallEvent>.broadcast();
|
StreamController<CallEvent>.broadcast();
|
||||||
@@ -13,11 +18,30 @@ class VoiceService {
|
|||||||
|
|
||||||
VoiceService(this._api);
|
VoiceService(this._api);
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize({String? deviceToken}) async {
|
||||||
|
_deviceToken = deviceToken;
|
||||||
|
debugPrint('VoiceService.initialize: deviceToken=${deviceToken != null ? "present (${deviceToken.length} chars)" : "NULL"}');
|
||||||
|
|
||||||
|
// Request permissions (Android telecom requires these)
|
||||||
|
await TwilioVoice.instance.requestMicAccess();
|
||||||
|
if (!kIsWeb && Platform.isAndroid) {
|
||||||
|
await TwilioVoice.instance.requestReadPhoneStatePermission();
|
||||||
|
await TwilioVoice.instance.requestReadPhoneNumbersPermission();
|
||||||
|
await TwilioVoice.instance.requestCallPhonePermission();
|
||||||
|
await TwilioVoice.instance.requestManageOwnCallsPermission();
|
||||||
|
// Register phone account with Android telecom
|
||||||
|
// (enabling is handled by dashboard UI with a user-friendly dialog)
|
||||||
|
await TwilioVoice.instance.registerPhoneAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch token and register
|
||||||
await _fetchAndRegisterToken();
|
await _fetchAndRegisterToken();
|
||||||
|
|
||||||
TwilioVoice.instance.callEventsListener.listen((event) {
|
// Listen for call events (only once)
|
||||||
_callEventController.add(event);
|
_eventSubscription ??= TwilioVoice.instance.callEventsListener.listen((event) {
|
||||||
|
if (!_callEventController.isClosed) {
|
||||||
|
_callEventController.add(event);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh token every 50 minutes
|
// Refresh token every 50 minutes
|
||||||
@@ -34,9 +58,13 @@ class VoiceService {
|
|||||||
final data = response.data;
|
final data = response.data;
|
||||||
final token = data['token'] as String;
|
final token = data['token'] as String;
|
||||||
_identity = data['identity'] as String;
|
_identity = data['identity'] as String;
|
||||||
await TwilioVoice.instance.setTokens(accessToken: token);
|
await TwilioVoice.instance.setTokens(
|
||||||
|
accessToken: token,
|
||||||
|
deviceToken: _deviceToken ?? 'no-fcm',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Token fetch failed - will retry on next interval
|
debugPrint('VoiceService._fetchAndRegisterToken error: $e');
|
||||||
|
if (e is DioException) debugPrint(' response: ${e.response?.data}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,14 +90,41 @@ class VoiceService {
|
|||||||
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> makeCall(String to) async {
|
Future<bool> makeCall(String to, {String? callerId}) async {
|
||||||
return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false;
|
try {
|
||||||
|
final extraOptions = <String, dynamic>{};
|
||||||
|
if (callerId != null && callerId.isNotEmpty) {
|
||||||
|
extraOptions['CallerId'] = callerId;
|
||||||
|
}
|
||||||
|
debugPrint('VoiceService.makeCall: to=$to, from=$_identity, extras=$extraOptions');
|
||||||
|
final result = await TwilioVoice.instance.call.place(
|
||||||
|
to: to,
|
||||||
|
from: _identity ?? '',
|
||||||
|
extraOptions: extraOptions,
|
||||||
|
) ?? false;
|
||||||
|
debugPrint('VoiceService.makeCall: result=$result');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('VoiceService.makeCall error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendDigits(String digits) async {
|
Future<void> sendDigits(String digits) async {
|
||||||
await TwilioVoice.instance.call.sendDigits(digits);
|
await TwilioVoice.instance.call.sendDigits(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async {
|
||||||
|
final response = await _api.dio.get('/queues/$queueId/calls');
|
||||||
|
return List<Map<String, dynamic>>.from(response.data['calls'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> acceptQueueCall(String callSid) async {
|
||||||
|
await _api.dio.post('/calls/$callSid/accept', data: {
|
||||||
|
'client_identity': _identity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> holdCall(String callSid) async {
|
Future<void> holdCall(String callSid) async {
|
||||||
await _api.dio.post('/calls/$callSid/hold');
|
await _api.dio.post('/calls/$callSid/hold');
|
||||||
}
|
}
|
||||||
@@ -84,6 +139,8 @@ class VoiceService {
|
|||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tokenRefreshTimer?.cancel();
|
_tokenRefreshTimer?.cancel();
|
||||||
|
_eventSubscription?.cancel();
|
||||||
|
_eventSubscription = null;
|
||||||
_callEventController.close();
|
_callEventController.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import '../models/queue_state.dart';
|
|||||||
|
|
||||||
class QueueCard extends StatelessWidget {
|
class QueueCard extends StatelessWidget {
|
||||||
final QueueInfo queue;
|
final QueueInfo queue;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const QueueCard({super.key, required this.queue});
|
const QueueCard({super.key, required this.queue, this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
onTap: onTap,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: queue.waitingCount > 0
|
backgroundColor: queue.waitingCount > 0
|
||||||
? Colors.orange.shade100
|
? Colors.orange.shade100
|
||||||
|
|||||||
129
mobile/test/call_info_test.dart
Normal file
129
mobile/test/call_info_test.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twp_softphone/models/call_info.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CallInfo', () {
|
||||||
|
test('default state is idle', () {
|
||||||
|
const info = CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
expect(info.callSid, isNull);
|
||||||
|
expect(info.callerNumber, isNull);
|
||||||
|
expect(info.duration, Duration.zero);
|
||||||
|
expect(info.isMuted, false);
|
||||||
|
expect(info.isSpeakerOn, false);
|
||||||
|
expect(info.isOnHold, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isActive returns true for ringing, connecting, connected', () {
|
||||||
|
expect(const CallInfo(state: CallState.ringing).isActive, true);
|
||||||
|
expect(const CallInfo(state: CallState.connecting).isActive, true);
|
||||||
|
expect(const CallInfo(state: CallState.connected).isActive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isActive returns false for idle and disconnected', () {
|
||||||
|
expect(const CallInfo(state: CallState.idle).isActive, false);
|
||||||
|
expect(const CallInfo(state: CallState.disconnected).isActive, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith preserves unmodified fields', () {
|
||||||
|
const original = CallInfo(
|
||||||
|
state: CallState.connected,
|
||||||
|
callSid: 'CA123',
|
||||||
|
callerNumber: '+1234567890',
|
||||||
|
isMuted: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final modified = original.copyWith(isSpeakerOn: true);
|
||||||
|
expect(modified.state, CallState.connected);
|
||||||
|
expect(modified.callSid, 'CA123');
|
||||||
|
expect(modified.callerNumber, '+1234567890');
|
||||||
|
expect(modified.isMuted, true);
|
||||||
|
expect(modified.isSpeakerOn, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith can change state', () {
|
||||||
|
const info = CallInfo(state: CallState.connecting);
|
||||||
|
final updated = info.copyWith(state: CallState.connected);
|
||||||
|
expect(updated.state, CallState.connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith with callerNumber preserves it', () {
|
||||||
|
const info = CallInfo(callerNumber: '+19095737372');
|
||||||
|
final updated = info.copyWith(state: CallState.connected);
|
||||||
|
expect(updated.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset to idle clears all fields', () {
|
||||||
|
// Verify a complex state exists
|
||||||
|
const connected = CallInfo(
|
||||||
|
state: CallState.connected,
|
||||||
|
callSid: 'CA123',
|
||||||
|
callerNumber: '+1234567890',
|
||||||
|
isMuted: true,
|
||||||
|
isSpeakerOn: true,
|
||||||
|
isOnHold: true,
|
||||||
|
duration: Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
expect(connected.isActive, true);
|
||||||
|
|
||||||
|
// Simulating what callEnded does
|
||||||
|
const reset = CallInfo();
|
||||||
|
expect(reset.state, CallState.idle);
|
||||||
|
expect(reset.callSid, isNull);
|
||||||
|
expect(reset.callerNumber, isNull);
|
||||||
|
expect(reset.isActive, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallState transitions', () {
|
||||||
|
test('outbound call flow: idle -> connecting -> connected -> idle', () {
|
||||||
|
var info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
|
||||||
|
// makeCall sets connecting + callerNumber
|
||||||
|
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||||
|
expect(info.state, CallState.connecting);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
expect(info.isActive, true);
|
||||||
|
|
||||||
|
// SDK fires connected
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.state, CallState.connected);
|
||||||
|
expect(info.callerNumber, '+19095737372'); // preserved
|
||||||
|
expect(info.isActive, true);
|
||||||
|
|
||||||
|
// callEnded resets
|
||||||
|
info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
expect(info.isActive, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inbound call flow: idle -> ringing -> connected -> idle', () {
|
||||||
|
var info = const CallInfo();
|
||||||
|
|
||||||
|
info = info.copyWith(state: CallState.ringing);
|
||||||
|
expect(info.isActive, true);
|
||||||
|
|
||||||
|
// callerNumber set from active.from
|
||||||
|
info = info.copyWith(callerNumber: '+18005551234');
|
||||||
|
expect(info.callerNumber, '+18005551234');
|
||||||
|
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.state, CallState.connected);
|
||||||
|
|
||||||
|
info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outbound callerNumber not overwritten by null copyWith', () {
|
||||||
|
var info = const CallInfo(
|
||||||
|
state: CallState.connecting,
|
||||||
|
callerNumber: '+19095737372',
|
||||||
|
);
|
||||||
|
|
||||||
|
// copyWith without callerNumber should preserve it
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
367
mobile/test/call_provider_test.dart
Normal file
367
mobile/test/call_provider_test.dart
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twilio_voice/twilio_voice.dart';
|
||||||
|
import 'package:twp_softphone/models/call_info.dart';
|
||||||
|
import 'package:twp_softphone/providers/call_provider.dart';
|
||||||
|
import 'package:twp_softphone/services/voice_service.dart';
|
||||||
|
|
||||||
|
/// Minimal mock of VoiceService for testing CallProvider logic.
|
||||||
|
/// Only stubs methods that CallProvider calls directly.
|
||||||
|
class MockVoiceService implements VoiceService {
|
||||||
|
final StreamController<CallEvent> _eventController =
|
||||||
|
StreamController<CallEvent>.broadcast();
|
||||||
|
bool makeCallResult = true;
|
||||||
|
bool acceptQueueCallShouldThrow = false;
|
||||||
|
String? lastCallTo;
|
||||||
|
String? lastCallerId;
|
||||||
|
bool answerCalled = false;
|
||||||
|
bool hangUpCalled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<CallEvent> get callEvents => _eventController.stream;
|
||||||
|
|
||||||
|
void emitEvent(CallEvent event) => _eventController.add(event);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> makeCall(String to, {String? callerId}) async {
|
||||||
|
lastCallTo = to;
|
||||||
|
lastCallerId = callerId;
|
||||||
|
return makeCallResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> answer() async {
|
||||||
|
answerCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> hangUp() async {
|
||||||
|
hangUpCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reject() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> toggleMute(bool mute) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> toggleSpeaker(bool speaker) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendDigits(String digits) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async => [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> acceptQueueCall(String callSid) async {
|
||||||
|
if (acceptQueueCallShouldThrow) {
|
||||||
|
throw Exception('Network error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> holdCall(String callSid) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unholdCall(String callSid) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> transferCall(String callSid, String target) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize({String? deviceToken}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get identity => 'agent2testuser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused stubs required by the interface
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CallProvider.makeCall', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets state to connecting and passes number', () async {
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
expect(mockVoice.lastCallTo, '+19095737372');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes callerId when provided', () async {
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372', callerId: '+19516215107');
|
||||||
|
|
||||||
|
expect(mockVoice.lastCallerId, '+19516215107');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets to idle when call.place() returns false', () async {
|
||||||
|
mockVoice.makeCallResult = false;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(provider.callInfo.callerNumber, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stays connecting when call.place() returns true', () async {
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.hangUp', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets to idle even if SDK does not fire callEnded', () async {
|
||||||
|
// Simulate a connecting state
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
|
||||||
|
// Hang up without SDK firing callEnded
|
||||||
|
await provider.hangUp();
|
||||||
|
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(provider.callInfo.callerNumber, isNull);
|
||||||
|
expect(mockVoice.hangUpCalled, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider state transitions', () {
|
||||||
|
test('outbound: connecting preserves callerNumber through state changes', () {
|
||||||
|
// Simulating what CallProvider does internally
|
||||||
|
var info = const CallInfo();
|
||||||
|
|
||||||
|
// makeCall sets connecting + callerNumber
|
||||||
|
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||||
|
expect(info.state, CallState.connecting);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
|
||||||
|
// SDK fires connected — callerNumber preserved
|
||||||
|
info = info.copyWith(state: CallState.connected);
|
||||||
|
expect(info.state, CallState.connected);
|
||||||
|
expect(info.callerNumber, '+19095737372');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('makeCall failure resets cleanly to idle', () {
|
||||||
|
var info = const CallInfo();
|
||||||
|
|
||||||
|
// makeCall sets connecting
|
||||||
|
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||||
|
expect(info.state, CallState.connecting);
|
||||||
|
|
||||||
|
// call.place() returns false -> reset
|
||||||
|
info = const CallInfo();
|
||||||
|
expect(info.state, CallState.idle);
|
||||||
|
expect(info.callerNumber, isNull);
|
||||||
|
expect(info.isActive, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.acceptQueueCall', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets state to connecting before server call', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-answers incoming call after acceptQueueCall', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
|
||||||
|
// Simulate the FCM incoming call event that arrives after server redirect
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
|
||||||
|
// Allow the stream listener to process
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
// Should have auto-answered
|
||||||
|
expect(mockVoice.answerCalled, true);
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal incoming call shows ringing without auto-answer', () async {
|
||||||
|
// Without calling acceptQueueCall first
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(mockVoice.answerCalled, false);
|
||||||
|
expect(provider.callInfo.state, CallState.ringing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connected event after auto-answer sets connected state', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(mockVoice.answerCalled, true);
|
||||||
|
|
||||||
|
mockVoice.emitEvent(CallEvent.connected);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(provider.callInfo.state, CallState.connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets to idle on API error and clears pendingAutoAnswer', () async {
|
||||||
|
mockVoice.acceptQueueCallShouldThrow = true;
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
|
||||||
|
// Should have reset to idle after error
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
|
||||||
|
// Future incoming call should NOT be auto-answered
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(mockVoice.answerCalled, false);
|
||||||
|
expect(provider.callInfo.state, CallState.ringing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.hangUp edge cases', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hangUp when already idle is a no-op', () async {
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
await provider.hangUp();
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(mockVoice.hangUpCalled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hangUp clears pendingAutoAnswer flag', () async {
|
||||||
|
await provider.acceptQueueCall('CA123abc');
|
||||||
|
expect(provider.callInfo.state, CallState.connecting);
|
||||||
|
|
||||||
|
await provider.hangUp();
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
|
||||||
|
// Incoming call should NOT auto-answer after hangUp cleared the flag
|
||||||
|
mockVoice.emitEvent(CallEvent.incoming);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(mockVoice.answerCalled, false);
|
||||||
|
expect(provider.callInfo.state, CallState.ringing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.toggleMute and toggleSpeaker', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleMute flips isMuted state', () async {
|
||||||
|
expect(provider.callInfo.isMuted, false);
|
||||||
|
await provider.toggleMute();
|
||||||
|
expect(provider.callInfo.isMuted, true);
|
||||||
|
await provider.toggleMute();
|
||||||
|
expect(provider.callInfo.isMuted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleSpeaker flips isSpeakerOn state', () async {
|
||||||
|
expect(provider.callInfo.isSpeakerOn, false);
|
||||||
|
await provider.toggleSpeaker();
|
||||||
|
expect(provider.callInfo.isSpeakerOn, true);
|
||||||
|
await provider.toggleSpeaker();
|
||||||
|
expect(provider.callInfo.isSpeakerOn, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CallProvider.callEnded', () {
|
||||||
|
late MockVoiceService mockVoice;
|
||||||
|
late CallProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockVoice = MockVoiceService();
|
||||||
|
provider = CallProvider(mockVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
mockVoice.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callEnded resets state completely', () async {
|
||||||
|
// Set up a connected call
|
||||||
|
mockVoice.makeCallResult = true;
|
||||||
|
await provider.makeCall('+19095737372');
|
||||||
|
|
||||||
|
mockVoice.emitEvent(CallEvent.connected);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(provider.callInfo.state, CallState.connected);
|
||||||
|
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||||
|
|
||||||
|
// End the call
|
||||||
|
mockVoice.emitEvent(CallEvent.callEnded);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
expect(provider.callInfo.state, CallState.idle);
|
||||||
|
expect(provider.callInfo.callerNumber, isNull);
|
||||||
|
expect(provider.callInfo.callSid, isNull);
|
||||||
|
expect(provider.callInfo.isActive, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
92
mobile/test/queue_state_test.dart
Normal file
92
mobile/test/queue_state_test.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twp_softphone/models/queue_state.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('QueueInfo', () {
|
||||||
|
test('parses from JSON with all fields', () {
|
||||||
|
final json = {
|
||||||
|
'id': 1,
|
||||||
|
'name': 'General',
|
||||||
|
'type': 'general',
|
||||||
|
'extension': '100',
|
||||||
|
'waiting_count': 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
final queue = QueueInfo.fromJson(json);
|
||||||
|
expect(queue.id, 1);
|
||||||
|
expect(queue.name, 'General');
|
||||||
|
expect(queue.type, 'general');
|
||||||
|
expect(queue.extension, '100');
|
||||||
|
expect(queue.waitingCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses from JSON with string numbers', () {
|
||||||
|
final json = {
|
||||||
|
'id': '5',
|
||||||
|
'name': 'Support',
|
||||||
|
'type': 'personal',
|
||||||
|
'waiting_count': '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
final queue = QueueInfo.fromJson(json);
|
||||||
|
expect(queue.id, 5);
|
||||||
|
expect(queue.waitingCount, 0);
|
||||||
|
expect(queue.extension, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing fields gracefully', () {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
final queue = QueueInfo.fromJson(json);
|
||||||
|
expect(queue.id, 0);
|
||||||
|
expect(queue.name, '');
|
||||||
|
expect(queue.type, '');
|
||||||
|
expect(queue.extension, isNull);
|
||||||
|
expect(queue.waitingCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('QueueCall', () {
|
||||||
|
test('parses from JSON', () {
|
||||||
|
final json = {
|
||||||
|
'call_sid': 'CA123abc',
|
||||||
|
'from_number': '+18005551234',
|
||||||
|
'to_number': '+19095737372',
|
||||||
|
'position': 1,
|
||||||
|
'status': 'waiting',
|
||||||
|
'wait_time': 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
final call = QueueCall.fromJson(json);
|
||||||
|
expect(call.callSid, 'CA123abc');
|
||||||
|
expect(call.fromNumber, '+18005551234');
|
||||||
|
expect(call.toNumber, '+19095737372');
|
||||||
|
expect(call.position, 1);
|
||||||
|
expect(call.status, 'waiting');
|
||||||
|
expect(call.waitTime, 45);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles string wait_time', () {
|
||||||
|
final json = {
|
||||||
|
'call_sid': 'CA456',
|
||||||
|
'from_number': '+1800',
|
||||||
|
'to_number': '+1900',
|
||||||
|
'position': '2',
|
||||||
|
'status': 'waiting',
|
||||||
|
'wait_time': '120',
|
||||||
|
};
|
||||||
|
|
||||||
|
final call = QueueCall.fromJson(json);
|
||||||
|
expect(call.position, 2);
|
||||||
|
expect(call.waitTime, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing fields gracefully', () {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
final call = QueueCall.fromJson(json);
|
||||||
|
expect(call.callSid, '');
|
||||||
|
expect(call.fromNumber, '');
|
||||||
|
expect(call.position, 0);
|
||||||
|
expect(call.waitTime, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user