Compare commits
5 Commits
2026.03.06
...
2026.03.07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eedb7bdb8f | ||
|
|
f8c9c23077 | ||
|
|
5d3035a62c | ||
|
|
7df6090554 | ||
|
|
8cc6fa8c3c |
@@ -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
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ 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')
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,39 +169,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,
|
||||||
@@ -211,44 +195,42 @@ class TWP_Mobile_API {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||||
|
|
||||||
// Get queues assigned to this user
|
// Auto-create personal queues if they don't exist
|
||||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
$user_id
|
$user_id
|
||||||
));
|
));
|
||||||
|
|
||||||
// Also include personal queues
|
if (!$existing_extension) {
|
||||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
|
|
||||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
|
||||||
|
|
||||||
if (empty($all_queue_ids)) {
|
|
||||||
return new WP_REST_Response(array(
|
|
||||||
'success' => true,
|
|
||||||
'queues' => array()
|
|
||||||
), 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||||
|
$queues = $wpdb->get_results($wpdb->prepare("
|
||||||
// Get queue information with call counts
|
SELECT DISTINCT
|
||||||
$queues = $wpdb->get_results("
|
|
||||||
SELECT
|
|
||||||
q.id,
|
q.id,
|
||||||
q.queue_name,
|
q.queue_name,
|
||||||
q.queue_type,
|
q.queue_type,
|
||||||
q.extension,
|
q.extension,
|
||||||
COUNT(c.id) as waiting_count
|
COUNT(c.id) as waiting_count
|
||||||
FROM $queues_table q
|
FROM $queues_table q
|
||||||
|
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||||
WHERE q.id IN ($queue_ids_str)
|
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||||
|
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||||
GROUP BY q.id
|
GROUP BY q.id
|
||||||
");
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN q.queue_type = 'personal' THEN 1
|
||||||
|
WHEN q.queue_type = 'hold' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
q.queue_name ASC
|
||||||
|
", $user_id, $user_id));
|
||||||
|
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ($queues as $queue) {
|
foreach ($queues as $queue) {
|
||||||
@@ -696,18 +678,29 @@ 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 for mobile Voice SDK (not ClientToken which is browser-only)
|
||||||
|
$token = new \Twilio\Jwt\AccessToken($account_sid, $account_sid, $auth_token, 3600, $identity);
|
||||||
|
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
|
||||||
|
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
|
||||||
|
$voiceGrant->setIncomingAllow(true);
|
||||||
|
$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) {
|
||||||
@@ -715,6 +708,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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -142,38 +142,40 @@ class TWP_Mobile_SSE {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||||
|
|
||||||
// Get queue IDs
|
// Auto-create personal queues if they don't exist
|
||||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
$user_id
|
$user_id
|
||||||
));
|
));
|
||||||
|
|
||||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
if (!$existing_extension) {
|
||||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
|
|
||||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
|
||||||
|
|
||||||
if (empty($all_queue_ids)) {
|
|
||||||
return array();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||||
|
$queues = $wpdb->get_results($wpdb->prepare("
|
||||||
$queues = $wpdb->get_results("
|
SELECT DISTINCT
|
||||||
SELECT
|
|
||||||
q.id,
|
q.id,
|
||||||
q.queue_name,
|
q.queue_name,
|
||||||
COUNT(c.id) as waiting_count,
|
COUNT(c.id) as waiting_count,
|
||||||
MIN(c.enqueued_at) as oldest_call_time
|
MIN(c.enqueued_at) as oldest_call_time
|
||||||
FROM $queues_table q
|
FROM $queues_table q
|
||||||
|
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||||
WHERE q.id IN ($queue_ids_str)
|
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||||
|
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||||
GROUP BY q.id
|
GROUP BY q.id
|
||||||
");
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN q.queue_type = 'personal' THEN 1
|
||||||
|
WHEN q.queue_type = 'hold' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
q.queue_name ASC
|
||||||
|
", $user_id, $user_id));
|
||||||
|
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ($queues as $queue) {
|
foreach ($queues as $queue) {
|
||||||
|
|||||||
@@ -371,7 +371,13 @@ 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 CallerId via extraOptions; browser sends From as phone number
|
||||||
|
$from_number = '';
|
||||||
|
if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) {
|
||||||
|
$from_number = $params['CallerId'];
|
||||||
|
} elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) {
|
||||||
|
$from_number = $params['From'];
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ class AgentStatus {
|
|||||||
|
|
||||||
factory AgentStatus.fromJson(Map<String, dynamic> json) {
|
factory AgentStatus.fromJson(Map<String, dynamic> json) {
|
||||||
return AgentStatus(
|
return AgentStatus(
|
||||||
status: _parseStatus(json['status'] as String),
|
status: _parseStatus((json['status'] ?? 'offline') as String),
|
||||||
isLoggedIn: json['is_logged_in'] as bool,
|
isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
|
||||||
currentCallSid: json['current_call_sid'] as String?,
|
currentCallSid: json['current_call_sid'] as String?,
|
||||||
lastActivity: json['last_activity'] as String?,
|
lastActivity: json['last_activity'] as String?,
|
||||||
availableForQueues: json['available_for_queues'] as bool? ?? true,
|
availableForQueues: json['available_for_queues'] != false && json['available_for_queues'] != 0 && json['available_for_queues'] != '0',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ class QueueInfo {
|
|||||||
|
|
||||||
factory QueueInfo.fromJson(Map<String, dynamic> json) {
|
factory QueueInfo.fromJson(Map<String, dynamic> json) {
|
||||||
return QueueInfo(
|
return QueueInfo(
|
||||||
id: json['id'] as int,
|
id: _toInt(json['id']),
|
||||||
name: json['name'] as String,
|
name: (json['name'] ?? '') as String,
|
||||||
type: json['type'] as String,
|
type: (json['type'] ?? '') as String,
|
||||||
extension: json['extension'] as String?,
|
extension: json['extension'] as String?,
|
||||||
waitingCount: json['waiting_count'] as int,
|
waitingCount: _toInt(json['waiting_count']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class QueueCall {
|
class QueueCall {
|
||||||
@@ -43,12 +49,18 @@ class QueueCall {
|
|||||||
|
|
||||||
factory QueueCall.fromJson(Map<String, dynamic> json) {
|
factory QueueCall.fromJson(Map<String, dynamic> json) {
|
||||||
return QueueCall(
|
return QueueCall(
|
||||||
callSid: json['call_sid'] as String,
|
callSid: (json['call_sid'] ?? '') as String,
|
||||||
fromNumber: json['from_number'] as String,
|
fromNumber: (json['from_number'] ?? '') as String,
|
||||||
toNumber: json['to_number'] as String,
|
toNumber: (json['to_number'] ?? '') as String,
|
||||||
position: json['position'] as int,
|
position: _toInt(json['position']),
|
||||||
status: json['status'] as String,
|
status: (json['status'] ?? '') as String,
|
||||||
waitTime: json['wait_time'] as int,
|
waitTime: _toInt(json['wait_time']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ class User {
|
|||||||
|
|
||||||
factory User.fromJson(Map<String, dynamic> json) {
|
factory User.fromJson(Map<String, dynamic> json) {
|
||||||
return User(
|
return User(
|
||||||
id: json['user_id'] as int,
|
id: _toInt(json['user_id']),
|
||||||
login: json['user_login'] as String,
|
login: (json['user_login'] ?? '') as String,
|
||||||
displayName: json['display_name'] as String,
|
displayName: (json['display_name'] ?? '') as String,
|
||||||
email: json['email'] as String?,
|
email: json['email'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ 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 +22,14 @@ 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;
|
||||||
|
|
||||||
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) {
|
||||||
@@ -33,7 +45,7 @@ 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'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateStatus(AgentStatusValue newStatus) async {
|
Future<void> updateStatus(AgentStatusValue newStatus) async {
|
||||||
@@ -49,7 +61,7 @@ class AgentProvider extends ChangeNotifier {
|
|||||||
currentCallSid: _status?.currentCallSid,
|
currentCallSid: _status?.currentCallSid,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (_) {}
|
} catch (e) { debugPrint('AgentProvider.updateStatus error: $e'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchQueues() async {
|
Future<void> fetchQueues() async {
|
||||||
@@ -60,11 +72,24 @@ 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'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -63,13 +63,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();
|
||||||
} 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 {
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ 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, {String? callerId}) async {
|
||||||
|
_callInfo = _callInfo.copyWith(
|
||||||
|
state: CallState.connecting,
|
||||||
|
callerNumber: number,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
await _voiceService.makeCall(number, callerId: callerId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> holdCall() async {
|
Future<void> holdCall() async {
|
||||||
final sid = _callInfo.callSid;
|
final sid = _callInfo.callSid;
|
||||||
if (sid == null) return;
|
if (sid == null) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/agent_provider.dart';
|
import '../providers/agent_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/queue_card.dart';
|
import '../widgets/queue_card.dart';
|
||||||
import 'active_call_screen.dart';
|
import 'active_call_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
@@ -23,6 +24,123 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showDialer(BuildContext context) {
|
||||||
|
final numberController = TextEditingController();
|
||||||
|
String? selectedCallerId;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Number display
|
||||||
|
TextField(
|
||||||
|
controller: numberController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
autofillHints: const [AutofillHints.telephoneNumber],
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(ctx).textTheme.headlineSmall,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
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
|
||||||
|
if (phoneNumbers.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: selectedCallerId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Caller ID',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Default'),
|
||||||
|
),
|
||||||
|
...phoneNumbers.map((p) => DropdownMenuItem<String>(
|
||||||
|
value: p.phoneNumber,
|
||||||
|
child: Text('${p.friendlyName} (${p.phoneNumber})'),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedCallerId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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, callerId: selectedCallerId);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final agent = context.watch<AgentProvider>();
|
final agent = context.watch<AgentProvider>();
|
||||||
@@ -58,6 +176,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showDialer(context),
|
||||||
|
child: const Icon(Icons.phone),
|
||||||
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () => agent.refresh(),
|
onRefresh: () => agent.refresh(),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
@@ -39,6 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
serverUrl = 'https://$serverUrl';
|
serverUrl = 'https://$serverUrl';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
context.read<AuthProvider>().login(
|
context.read<AuthProvider>().login(
|
||||||
serverUrl,
|
serverUrl,
|
||||||
_usernameController.text.trim(),
|
_usernameController.text.trim(),
|
||||||
@@ -55,7 +57,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Form(
|
child: AutofillGroup(
|
||||||
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -80,6 +83,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
|
autofillHints: const [AutofillHints.url],
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||||
),
|
),
|
||||||
@@ -91,6 +95,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
prefixIcon: Icon(Icons.person),
|
prefixIcon: Icon(Icons.person),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||||
),
|
),
|
||||||
@@ -110,6 +115,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
obscureText: _obscurePassword,
|
obscureText: _obscurePassword,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null || v.isEmpty ? 'Required' : null,
|
v == null || v.isEmpty ? 'Required' : null,
|
||||||
),
|
),
|
||||||
@@ -142,6 +148,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ class VoiceService {
|
|||||||
_identity = data['identity'] as String;
|
_identity = data['identity'] as String;
|
||||||
await TwilioVoice.instance.setTokens(accessToken: token);
|
await TwilioVoice.instance.setTokens(accessToken: token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Token fetch failed - will retry on next interval
|
debugPrint('VoiceService._fetchAndRegisterToken error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +63,23 @@ class VoiceService {
|
|||||||
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> makeCall(String to, {String? callerId}) async {
|
||||||
|
try {
|
||||||
|
final extraOptions = <String, dynamic>{};
|
||||||
|
if (callerId != null && callerId.isNotEmpty) {
|
||||||
|
extraOptions['CallerId'] = callerId;
|
||||||
|
}
|
||||||
|
return await TwilioVoice.instance.call.place(
|
||||||
|
to: to,
|
||||||
|
from: _identity ?? '',
|
||||||
|
extraOptions: extraOptions,
|
||||||
|
) ?? false;
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user