Compare commits
7 Commits
2026.03.06
...
2026.03.07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c9c23077 | ||
|
|
5d3035a62c | ||
|
|
7df6090554 | ||
|
|
8cc6fa8c3c | ||
|
|
d41b6aa535 | ||
|
|
4da794ed0c | ||
|
|
5adfa694c1 |
@@ -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
|
||||
97
CLAUDE.md
97
CLAUDE.md
@@ -6,8 +6,26 @@
|
||||
- **URL**: `https://phone.cloud-hosting.io/`
|
||||
- **Deployment**: rsync to Docker (remote server only, not local)
|
||||
- **SDK**: Twilio PHP SDK v8.7.0
|
||||
- **PHP**: 8.0+ required
|
||||
- **Optional**: AWS SDK (`aws/aws-sdk-php`) for SNS SMS provider
|
||||
- **External SDK**: `wp-content/twilio-sdk/` (survives plugin updates)
|
||||
|
||||
## Commands
|
||||
- **Install SDK (recommended)**: `./install-twilio-sdk-external.sh` (installs to `wp-content/twilio-sdk/`)
|
||||
- **Install SDK (internal)**: `./install-twilio-sdk.sh` (installs to `vendor/`, lost on plugin update)
|
||||
- **Test SDK**: `php test-sdk.php`
|
||||
- **Composer install SDK**: `composer install-sdk`
|
||||
- **Deploy**: rsync to Docker (remote server, see production path above)
|
||||
- **CI/CD**: Gitea workflows in `.gitea/workflows/` — `release.yml`, `update-version.yml`
|
||||
|
||||
## Directory Structure
|
||||
- `twilio-wp-plugin.php` — Main plugin file, constants, SDK loading
|
||||
- `includes/` — All backend classes (28 class files)
|
||||
- `admin/` — Admin UI class (`TWP_Admin`), mobile app settings page
|
||||
- `assets/js/` — Browser phone JS, service worker
|
||||
- `assets/images/`, `assets/sounds/` — Static assets
|
||||
- `.gitea/workflows/` — CI/CD (release, version update)
|
||||
|
||||
## Phone Variable Names
|
||||
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
|
||||
**Don't use**: `from_number`, `to_number`, `phone_number`, `$agent_phone`
|
||||
@@ -18,11 +36,20 @@
|
||||
- **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control
|
||||
- **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
|
||||
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
|
||||
- **TWP_Webhooks**: 26 endpoints at `twilio-webhook/v1`
|
||||
- **TWP_Activator**: Creates 15 DB tables, run `ensure_tables_exist()` if missing
|
||||
- **TWP_Webhooks**: 35 endpoints at `twilio-webhook/v1`
|
||||
- **TWP_Activator**: Creates 16 DB tables, run `ensure_tables_exist()` if missing
|
||||
- **TWP_Core**: Main plugin orchestrator, hooks all classes together
|
||||
- **TWP_SMS_Manager**: SMS abstraction with provider interface
|
||||
- **TWP_SMS_Provider_Twilio** / **TWP_SMS_Provider_SNS**: SMS providers (Twilio default, AWS SNS optional)
|
||||
- **TWP_Mobile_API**: REST API for mobile app
|
||||
- **TWP_Mobile_Auth** / **TWP_Mobile_SSE** / **TWP_FCM**: Mobile auth, server-sent events, push notifications
|
||||
- **TWP_Call_Queue**: Queue operations and management
|
||||
- **TWP_Callback_Manager**: Callback request handling
|
||||
- **TWP_Workflow**: Workflow step execution engine
|
||||
- **TWP_Auto_Updater**: Plugin auto-update from Gitea releases
|
||||
|
||||
## Database
|
||||
15 tables with `twp_` prefix. Key notes:
|
||||
16 tables with `twp_` prefix. Key notes:
|
||||
- `twp_call_queues`: User queues (general/personal/hold)
|
||||
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
|
||||
- `twp_queued_calls`: Uses `enqueued_at` not `joined_at`
|
||||
@@ -41,32 +68,6 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
|
||||
- Queue: Pass `waitUrl` as option in `enqueue()`
|
||||
- TwiML: Use SDK classes, not raw XML
|
||||
|
||||
## Recent Changes (v2.3.0)
|
||||
- Browser phone moved to admin-only
|
||||
- Call control uses `find_customer_call_leg()` to prevent disconnections
|
||||
- Auto-creates user queues/extensions when needed
|
||||
- Firefox support added
|
||||
- 1-min agent status auto-revert
|
||||
|
||||
## SDK Installation
|
||||
- **External SDK (Recommended)**: Use `install-twilio-sdk-external.sh` to install SDK to `wp-content/twilio-sdk/`
|
||||
- Survives WordPress plugin updates
|
||||
- SDK location defined by `TWP_EXTERNAL_SDK_DIR` constant
|
||||
- Loading priority: External first, then internal `vendor/` fallback
|
||||
- **Internal SDK (Alternative)**: Use `install-twilio-sdk.sh` to install to `vendor/`
|
||||
- Will be deleted when WordPress updates the plugin
|
||||
- Requires reinstallation after each plugin update
|
||||
- **SDK Loading**: Plugin checks external location first via autoloader, falls back to internal
|
||||
- **Post-Update Detection**: Hook on `upgrader_process_complete` checks SDK status and shows warning
|
||||
|
||||
## Browser Phone Configuration
|
||||
- **Edge Location Setting**: Configurable via Settings → Twilio Edge Location
|
||||
- Default: `roaming` (auto-select closest edge)
|
||||
- Options: ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
||||
- Stored in: `twp_twilio_edge` option
|
||||
- Used by: Browser phone JavaScript for WebRTC connection
|
||||
- Critical: Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
|
||||
|
||||
## Development Notes
|
||||
- **API**: E.164 format (+1XXXXXXXXXX)
|
||||
- **Database**: Use `$wpdb`, prepared statements
|
||||
@@ -79,30 +80,24 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
|
||||
- User-specific queues with extensions
|
||||
- Browser phone at `admin.php?page=twilio-wp-browser-phone`
|
||||
- ElevenLabs TTS with Alice fallback
|
||||
- 68 AJAX actions, 26 REST endpoints
|
||||
- 77 AJAX actions, 35 REST endpoints
|
||||
- Browser phone moved to admin-only (v2.3.0)
|
||||
- Firefox, Chrome, Safari, Edge support
|
||||
- 1-min agent status auto-revert
|
||||
|
||||
## Recent Technical Changes (v2.8.9)
|
||||
## SDK Loading
|
||||
- **External SDK (Recommended)**: `wp-content/twilio-sdk/` — survives plugin updates
|
||||
- **Internal SDK**: `vendor/` — deleted on plugin update, needs reinstall
|
||||
- Loading priority: External first (`TWP_EXTERNAL_SDK_DIR`), then internal fallback
|
||||
- Post-update hook (`upgrader_process_complete`) warns if SDK missing
|
||||
|
||||
### SDK Persistence Between Plugin Updates
|
||||
- **Problem**: WordPress plugin updates delete entire plugin folder including `vendor/` SDK
|
||||
- **Solution**: External SDK installation at `wp-content/twilio-sdk/` survives updates
|
||||
- **Implementation**:
|
||||
- New constant: `TWP_EXTERNAL_SDK_DIR` points to `wp-content/twilio-sdk/`
|
||||
- Loading priority in `twp_check_sdk_installation()`: External first, internal fallback
|
||||
- Classes updated: `TWP_Twilio_API`, `TWP_Webhooks` constructors check external location first
|
||||
- New script: `install-twilio-sdk-external.sh` automates external installation
|
||||
- Post-update hook: `twp_check_sdk_after_update()` detects missing SDK after updates
|
||||
- Admin notices: `twp_sdk_missing_notice()` shows both installation options
|
||||
- Warning system: `twp_show_sdk_update_warning()` via transient after plugin updates
|
||||
## Browser Phone Configuration
|
||||
- **Edge Location**: `twp_twilio_edge` option, default `roaming`
|
||||
- Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
||||
- Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
|
||||
|
||||
### US Calls Failing Fix (Browser Phone)
|
||||
- **Problem**: Browser phone had hardcoded `edge: 'sydney'`, causing US calls to fail with immediate HANGUP
|
||||
- **Solution**: Configurable edge location via WordPress settings
|
||||
- **Implementation**:
|
||||
- New setting: `twp_twilio_edge` with default value `roaming`
|
||||
- Settings UI: Dropdown in admin settings with 8 edge options
|
||||
- Browser phone JS: Uses `get_option('twp_twilio_edge', 'roaming')` instead of hardcoded value
|
||||
- Edge options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
|
||||
## Changelog
|
||||
See `README.md` for detailed version history. Current version: v2.8.9.
|
||||
|
||||
---
|
||||
*Updated: Jan 2026*
|
||||
*Updated: Mar 2026*
|
||||
@@ -1580,6 +1580,123 @@ class TWP_Admin {
|
||||
}
|
||||
});
|
||||
</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>
|
||||
<?php
|
||||
}
|
||||
|
||||
@@ -13,13 +13,6 @@ if (!current_user_can('manage_options')) {
|
||||
wp_die(__('You do not have sufficient permissions to access this page.'));
|
||||
}
|
||||
|
||||
// Handle manual update check
|
||||
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_mobile_settings')) {
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||
$updater = new TWP_Auto_Updater();
|
||||
$update_result = $updater->manual_check_for_updates();
|
||||
}
|
||||
|
||||
// Handle test notification
|
||||
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
||||
@@ -36,31 +29,26 @@ if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_se
|
||||
|
||||
// Save settings
|
||||
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) {
|
||||
update_option('twp_fcm_server_key', sanitize_text_field($_POST['twp_fcm_server_key']));
|
||||
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
|
||||
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
|
||||
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
|
||||
update_option('twp_twilio_api_key_sid', sanitize_text_field($_POST['twp_twilio_api_key_sid']));
|
||||
update_option('twp_twilio_api_key_secret', sanitize_text_field($_POST['twp_twilio_api_key_secret']));
|
||||
update_option('twp_fcm_push_credential_sid', sanitize_text_field($_POST['twp_fcm_push_credential_sid']));
|
||||
|
||||
update_option('twp_fcm_project_id', sanitize_text_field($_POST['twp_fcm_project_id']));
|
||||
// Service account JSON — validate it parses as JSON before saving
|
||||
$sa_json_raw = isset($_POST['twp_fcm_service_account_json']) ? wp_unslash($_POST['twp_fcm_service_account_json']) : '';
|
||||
if (!empty($sa_json_raw)) {
|
||||
$sa_parsed = json_decode($sa_json_raw, true);
|
||||
if ($sa_parsed && isset($sa_parsed['client_email'], $sa_parsed['private_key'])) {
|
||||
update_option('twp_fcm_service_account_json', $sa_json_raw);
|
||||
} else {
|
||||
$sa_json_error = 'Invalid service account JSON — must contain client_email and private_key fields.';
|
||||
}
|
||||
} else {
|
||||
update_option('twp_fcm_service_account_json', '');
|
||||
}
|
||||
$settings_saved = true;
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
$fcm_server_key = get_option('twp_fcm_server_key', '');
|
||||
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
|
||||
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
|
||||
$gitea_token = get_option('twp_gitea_token', '');
|
||||
$twilio_api_key_sid = get_option('twp_twilio_api_key_sid', '');
|
||||
$twilio_api_key_secret = get_option('twp_twilio_api_key_secret', '');
|
||||
$fcm_push_credential_sid = get_option('twp_fcm_push_credential_sid', '');
|
||||
|
||||
// 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();
|
||||
|
||||
$fcm_project_id = get_option('twp_fcm_project_id', '');
|
||||
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
|
||||
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
|
||||
// Get mobile app statistics
|
||||
global $wpdb;
|
||||
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
@@ -78,18 +66,18 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($update_result)): ?>
|
||||
<div class="notice notice-<?php echo $update_result['update_available'] ? 'warning' : 'success'; ?> is-dismissible">
|
||||
<p><strong><?php echo esc_html($update_result['message']); ?></strong></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($notification_result)): ?>
|
||||
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
|
||||
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($sa_json_error)): ?>
|
||||
<div class="notice notice-error is-dismissible">
|
||||
<p><strong><?php echo esc_html($sa_json_error); ?></strong></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="twp-mobile-settings">
|
||||
<!-- Mobile App Overview -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
@@ -118,76 +106,48 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
|
||||
<!-- FCM Configuration -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>Firebase Cloud Messaging (FCM)</h2>
|
||||
<p>Configure FCM to enable push notifications for the mobile app.</p>
|
||||
<h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
|
||||
<p>Configure FCM using a service account for push notifications. The legacy server key API has been retired by Google.</p>
|
||||
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_fcm_server_key">FCM Server Key</label>
|
||||
<label for="twp_fcm_project_id">Firebase Project ID</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text"
|
||||
id="twp_fcm_server_key"
|
||||
name="twp_fcm_server_key"
|
||||
value="<?php echo esc_attr($fcm_server_key); ?>"
|
||||
id="twp_fcm_project_id"
|
||||
name="twp_fcm_project_id"
|
||||
value="<?php echo esc_attr($fcm_project_id); ?>"
|
||||
class="regular-text"
|
||||
placeholder="AAAA...">
|
||||
placeholder="my-project-12345">
|
||||
<p class="description">
|
||||
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key
|
||||
Found in Firebase Console > Project Settings > General > Project ID
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_twilio_api_key_sid">Twilio API Key SID</label>
|
||||
<label for="twp_fcm_service_account_json">Service Account JSON</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text"
|
||||
id="twp_twilio_api_key_sid"
|
||||
name="twp_twilio_api_key_sid"
|
||||
value="<?php echo esc_attr($twilio_api_key_sid); ?>"
|
||||
class="regular-text"
|
||||
placeholder="SK...">
|
||||
<textarea id="twp_fcm_service_account_json"
|
||||
name="twp_fcm_service_account_json"
|
||||
rows="6"
|
||||
class="large-text code"
|
||||
placeholder='Paste the entire contents of your service account JSON file...'><?php echo esc_textarea($fcm_service_account_json); ?></textarea>
|
||||
<p class="description">
|
||||
Create an API Key in Twilio Console > Account > API Keys. Required for mobile VoIP tokens.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_twilio_api_key_secret">Twilio API Key Secret</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="password"
|
||||
id="twp_twilio_api_key_secret"
|
||||
name="twp_twilio_api_key_secret"
|
||||
value="<?php echo esc_attr($twilio_api_key_secret); ?>"
|
||||
class="regular-text">
|
||||
<p class="description">
|
||||
The secret associated with the API Key SID above. Shown only once when key is created.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_fcm_push_credential_sid">Push Credential SID</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text"
|
||||
id="twp_fcm_push_credential_sid"
|
||||
name="twp_fcm_push_credential_sid"
|
||||
value="<?php echo esc_attr($fcm_push_credential_sid); ?>"
|
||||
class="regular-text"
|
||||
placeholder="CR...">
|
||||
<p class="description">
|
||||
Twilio Push Credential SID. Create in Twilio Console > Messaging > Push Credentials using your FCM server key. Required for incoming call push notifications.
|
||||
Generate in Firebase Console > Project Settings > Service Accounts > Generate New Private Key.
|
||||
Paste the entire JSON file contents here. Must contain <code>client_email</code> and <code>private_key</code> fields.
|
||||
</p>
|
||||
<?php if ($fcm_sa_configured): ?>
|
||||
<p style="color: #00a32a; margin-top: 5px;">✓ Service account configured</p>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if (!empty($fcm_server_key)): ?>
|
||||
<?php if ($fcm_sa_configured): ?>
|
||||
<p>
|
||||
<button type="submit" name="twp_test_notification" class="button">
|
||||
Send Test Notification
|
||||
@@ -197,91 +157,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Update Settings -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>Automatic Updates</h2>
|
||||
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row">Current Version</th>
|
||||
<td>
|
||||
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
|
||||
<?php if ($update_status['update_available']): ?>
|
||||
<span style="color: #d63638; margin-left: 10px;">
|
||||
⚠ Update available: <?php echo esc_html($update_status['latest_version']); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span style="color: #00a32a; margin-left: 10px;">✓ Up to date</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
|
||||
</th>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
id="twp_auto_update_enabled"
|
||||
name="twp_auto_update_enabled"
|
||||
value="1"
|
||||
<?php checked($auto_update_enabled); ?>>
|
||||
Automatically check for updates every 12 hours
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_gitea_repo">Gitea Repository</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text"
|
||||
id="twp_gitea_repo"
|
||||
name="twp_gitea_repo"
|
||||
value="<?php echo esc_attr($gitea_repo); ?>"
|
||||
class="regular-text"
|
||||
placeholder="org/repo-name">
|
||||
<p class="description">
|
||||
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_gitea_token">Gitea Access Token</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="password"
|
||||
id="twp_gitea_token"
|
||||
name="twp_gitea_token"
|
||||
value="<?php echo esc_attr($gitea_token); ?>"
|
||||
class="regular-text"
|
||||
placeholder="">
|
||||
<p class="description">
|
||||
Optional. Required only for private repositories. Create token at:
|
||||
<a href="https://repo.anhonesthost.net/user/settings/applications" target="_blank">Gitea Settings > Applications</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Last Update Check</th>
|
||||
<td>
|
||||
<?php
|
||||
$last_check = $update_status['last_check'];
|
||||
if ($last_check > 0) {
|
||||
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
|
||||
} else {
|
||||
echo 'Never';
|
||||
}
|
||||
?>
|
||||
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
|
||||
Check Now
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- API Documentation -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>API Endpoints</h2>
|
||||
|
||||
@@ -210,6 +210,12 @@ class TWP_Auto_Updater {
|
||||
$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
|
||||
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';
|
||||
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
<?php
|
||||
/**
|
||||
* Firebase Cloud Messaging (FCM) Integration
|
||||
* Firebase Cloud Messaging (FCM) Integration — HTTP v2 API
|
||||
*
|
||||
* Handles push notifications to mobile devices via FCM
|
||||
* Handles push notifications to mobile devices via FCM using
|
||||
* service account credentials and OAuth2 access tokens.
|
||||
*/
|
||||
class TWP_FCM {
|
||||
|
||||
private $server_key;
|
||||
private $fcm_url = 'https://fcm.googleapis.com/fcm/send';
|
||||
private $project_id;
|
||||
private $service_account;
|
||||
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->server_key = get_option('twp_fcm_server_key', '');
|
||||
$this->project_id = get_option('twp_fcm_project_id', '');
|
||||
$sa_json = get_option('twp_fcm_service_account_json', '');
|
||||
if (!empty($sa_json)) {
|
||||
$this->service_account = json_decode($sa_json, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to user's devices
|
||||
*/
|
||||
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
|
||||
if (empty($this->server_key)) {
|
||||
error_log('TWP FCM: Server key not configured');
|
||||
if (empty($this->project_id) || empty($this->service_account)) {
|
||||
error_log('TWP FCM: Project ID or service account not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,47 +63,54 @@ class TWP_FCM {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to specific token
|
||||
* Send notification to specific token via FCM HTTP v2 API
|
||||
*/
|
||||
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
|
||||
if ($data_only) {
|
||||
$payload = array(
|
||||
'to' => $token,
|
||||
'data' => array_merge($data, array(
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'timestamp' => time()
|
||||
)),
|
||||
'priority' => 'high'
|
||||
);
|
||||
} else {
|
||||
$notification = array(
|
||||
$access_token = $this->get_access_token();
|
||||
if (!$access_token) {
|
||||
return array('success' => false, 'error' => 'auth_failed');
|
||||
}
|
||||
|
||||
// FCM v2 requires all data values to be strings
|
||||
$string_data = array();
|
||||
foreach ($data as $key => $value) {
|
||||
$string_data[$key] = is_string($value) ? $value : (string)$value;
|
||||
}
|
||||
$string_data['title'] = $title;
|
||||
$string_data['body'] = $body;
|
||||
$string_data['timestamp'] = (string)time();
|
||||
|
||||
// Build the v2 message payload
|
||||
$message = array(
|
||||
'token' => $token,
|
||||
'data' => $string_data,
|
||||
'android' => array(
|
||||
'priority' => 'high',
|
||||
),
|
||||
);
|
||||
|
||||
if (!$data_only) {
|
||||
$message['notification'] = array(
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'sound' => 'default',
|
||||
'priority' => 'high',
|
||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
|
||||
);
|
||||
|
||||
$payload = array(
|
||||
'to' => $token,
|
||||
'notification' => $notification,
|
||||
'data' => array_merge($data, array(
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'timestamp' => time()
|
||||
)),
|
||||
'priority' => 'high'
|
||||
$message['android']['notification'] = array(
|
||||
'sound' => 'default',
|
||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
|
||||
);
|
||||
}
|
||||
|
||||
$payload = array('message' => $message);
|
||||
|
||||
$url = sprintf($this->fcm_url_template, $this->project_id);
|
||||
|
||||
$headers = array(
|
||||
'Authorization: key=' . $this->server_key,
|
||||
'Authorization: Bearer ' . $access_token,
|
||||
'Content-Type: application/json'
|
||||
);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->fcm_url);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
@@ -111,10 +124,14 @@ class TWP_FCM {
|
||||
if ($http_code !== 200) {
|
||||
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
|
||||
|
||||
// Check if token is invalid
|
||||
$response_data = json_decode($response, true);
|
||||
if (isset($response_data['results'][0]['error']) &&
|
||||
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) {
|
||||
$error_code = isset($response_data['error']['details'][0]['errorCode'])
|
||||
? $response_data['error']['details'][0]['errorCode'] : '';
|
||||
$error_status = isset($response_data['error']['status'])
|
||||
? $response_data['error']['status'] : '';
|
||||
|
||||
if (in_array($error_code, array('UNREGISTERED', 'INVALID_ARGUMENT')) ||
|
||||
$error_status === 'NOT_FOUND') {
|
||||
return array('success' => false, 'error' => 'invalid_token');
|
||||
}
|
||||
|
||||
@@ -124,6 +141,107 @@ class TWP_FCM {
|
||||
return array('success' => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth2 access token from service account credentials.
|
||||
* Caches the token in a transient until near expiry.
|
||||
*/
|
||||
private function get_access_token() {
|
||||
$cached = get_transient('twp_fcm_access_token');
|
||||
if ($cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (empty($this->service_account)) {
|
||||
error_log('TWP FCM: Service account not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
$jwt = $this->create_jwt();
|
||||
if (!$jwt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->service_account['token_uri']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array(
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
)));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($http_code !== 200) {
|
||||
error_log("TWP FCM: Failed to get access token. HTTP $http_code: $response");
|
||||
return false;
|
||||
}
|
||||
|
||||
$token_data = json_decode($response, true);
|
||||
$access_token = $token_data['access_token'];
|
||||
$expires_in = isset($token_data['expires_in']) ? (int)$token_data['expires_in'] : 3600;
|
||||
|
||||
// Cache token for 5 minutes less than actual expiry
|
||||
set_transient('twp_fcm_access_token', $access_token, max(60, $expires_in - 300));
|
||||
|
||||
return $access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed JWT for the service account OAuth2 flow
|
||||
*/
|
||||
private function create_jwt() {
|
||||
$sa = $this->service_account;
|
||||
|
||||
if (empty($sa['client_email']) || empty($sa['private_key']) || empty($sa['token_uri'])) {
|
||||
error_log('TWP FCM: Service account JSON missing required fields');
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$header = array('alg' => 'RS256', 'typ' => 'JWT');
|
||||
$claims = array(
|
||||
'iss' => $sa['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => $sa['token_uri'],
|
||||
'iat' => $now,
|
||||
'exp' => $now + 3600,
|
||||
);
|
||||
|
||||
$segments = array(
|
||||
$this->base64url_encode(json_encode($header)),
|
||||
$this->base64url_encode(json_encode($claims)),
|
||||
);
|
||||
|
||||
$signing_input = implode('.', $segments);
|
||||
|
||||
$private_key = openssl_pkey_get_private($sa['private_key']);
|
||||
if (!$private_key) {
|
||||
error_log('TWP FCM: Failed to parse service account private key');
|
||||
return false;
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
if (!openssl_sign($signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256)) {
|
||||
error_log('TWP FCM: Failed to sign JWT');
|
||||
return false;
|
||||
}
|
||||
|
||||
$segments[] = $this->base64url_encode($signature);
|
||||
|
||||
return implode('.', $segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64url encode (RFC 4648)
|
||||
*/
|
||||
private function base64url_encode($data) {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active FCM tokens for a user
|
||||
*/
|
||||
@@ -218,7 +336,7 @@ class TWP_FCM {
|
||||
|
||||
$data = array(
|
||||
'type' => 'test',
|
||||
'test' => true
|
||||
'test' => 'true'
|
||||
);
|
||||
|
||||
return $this->send_notification($user_id, $title, $body, $data);
|
||||
|
||||
@@ -106,6 +106,13 @@ class TWP_Mobile_API {
|
||||
'callback' => array($this, 'get_voice_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));
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_agent_status';
|
||||
|
||||
// Check if status exists
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
$data = array(
|
||||
'status' => $new_status,
|
||||
'last_activity' => current_time('mysql')
|
||||
);
|
||||
require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php';
|
||||
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||
|
||||
// Handle login status change first (matches browser phone behavior)
|
||||
if ($is_logged_in !== null) {
|
||||
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
|
||||
if ($is_logged_in) {
|
||||
$data['logged_in_at'] = current_time('mysql');
|
||||
}
|
||||
TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in);
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
$data,
|
||||
array('user_id' => $user_id),
|
||||
array('%s', '%s'),
|
||||
array('%d')
|
||||
);
|
||||
} else {
|
||||
$data['user_id'] = $user_id;
|
||||
$wpdb->insert($table, $data);
|
||||
}
|
||||
// Set agent status (handles auto_busy_at and all status fields)
|
||||
TWP_Agent_Manager::set_agent_status($user_id, $new_status);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
@@ -211,44 +195,42 @@ class TWP_Mobile_API {
|
||||
global $wpdb;
|
||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||
$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
|
||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
||||
// Auto-create personal queues if they don't exist
|
||||
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
// Also include personal queues
|
||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
||||
|
||||
if (empty($all_queue_ids)) {
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'queues' => array()
|
||||
), 200);
|
||||
if (!$existing_extension) {
|
||||
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
|
||||
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||
}
|
||||
|
||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
||||
|
||||
// Get queue information with call counts
|
||||
$queues = $wpdb->get_results("
|
||||
SELECT
|
||||
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||
$queues = $wpdb->get_results($wpdb->prepare("
|
||||
SELECT DISTINCT
|
||||
q.id,
|
||||
q.queue_name,
|
||||
q.queue_type,
|
||||
q.extension,
|
||||
COUNT(c.id) as waiting_count
|
||||
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'
|
||||
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
|
||||
");
|
||||
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();
|
||||
foreach ($queues as $queue) {
|
||||
@@ -689,36 +671,30 @@ class TWP_Mobile_API {
|
||||
public function get_voice_token($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$user = get_userdata($user_id);
|
||||
$identity = 'agent' . $user_id . preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
|
||||
|
||||
$account_sid = get_option('twp_twilio_account_sid');
|
||||
$auth_token = get_option('twp_twilio_auth_token');
|
||||
$api_key_sid = get_option('twp_twilio_api_key_sid');
|
||||
$api_key_secret = get_option('twp_twilio_api_key_secret');
|
||||
$twiml_app_sid = get_option('twp_twiml_app_sid');
|
||||
$push_credential_sid = get_option('twp_fcm_push_credential_sid');
|
||||
|
||||
if (empty($api_key_sid) || empty($api_key_secret)) {
|
||||
return new WP_Error('missing_api_key', 'Twilio API Key SID and Secret must be configured', array('status' => 500));
|
||||
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
|
||||
if (empty($clean_name)) {
|
||||
$clean_name = 'user';
|
||||
}
|
||||
$identity = 'agent' . $user_id . $clean_name;
|
||||
|
||||
try {
|
||||
$token = new \Twilio\Jwt\AccessToken(
|
||||
$account_sid,
|
||||
$api_key_sid,
|
||||
$api_key_secret,
|
||||
3600,
|
||||
$identity
|
||||
);
|
||||
// Ensure Twilio SDK autoloader is loaded
|
||||
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');
|
||||
$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);
|
||||
|
||||
if (!empty($push_credential_sid)) {
|
||||
$voiceGrant->setPushCredentialSid($push_credential_sid);
|
||||
}
|
||||
|
||||
$token->addGrant($voiceGrant);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
@@ -732,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
|
||||
*/
|
||||
|
||||
@@ -142,38 +142,40 @@ class TWP_Mobile_SSE {
|
||||
global $wpdb;
|
||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
||||
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||
|
||||
// Get queue IDs
|
||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
||||
// Auto-create personal queues if they don't exist
|
||||
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
||||
|
||||
if (empty($all_queue_ids)) {
|
||||
return array();
|
||||
if (!$existing_extension) {
|
||||
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||
}
|
||||
|
||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
||||
|
||||
$queues = $wpdb->get_results("
|
||||
SELECT
|
||||
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||
$queues = $wpdb->get_results($wpdb->prepare("
|
||||
SELECT DISTINCT
|
||||
q.id,
|
||||
q.queue_name,
|
||||
COUNT(c.id) as waiting_count,
|
||||
MIN(c.enqueued_at) as oldest_call_time
|
||||
FROM $queues_table q
|
||||
LEFT JOIN $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'
|
||||
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
|
||||
");
|
||||
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();
|
||||
foreach ($queues as $queue) {
|
||||
|
||||
@@ -371,7 +371,13 @@ class TWP_Webhooks {
|
||||
|
||||
if (isset($params['To']) && !empty($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 (strpos($to_number, 'client:') !== 0) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "twp-softphone-placeholder",
|
||||
"storage_bucket": "twp-softphone-placeholder.appspot.com"
|
||||
"project_number": "187457540438",
|
||||
"project_id": "twp-softphone",
|
||||
"storage_bucket": "twp-softphone.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"mobilesdk_app_id": "1:187457540438:android:d6d777270c23f6660946f7",
|
||||
"android_client_info": {
|
||||
"package_name": "io.cloudhosting.twp.twp_softphone"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "PLACEHOLDER_API_KEY"
|
||||
"current_key": "AIzaSyAdGJrWPy9b9arqHnlY5G_hsGiDcm6cchA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
|
||||
@@ -17,11 +17,11 @@ class AgentStatus {
|
||||
|
||||
factory AgentStatus.fromJson(Map<String, dynamic> json) {
|
||||
return AgentStatus(
|
||||
status: _parseStatus(json['status'] as String),
|
||||
isLoggedIn: json['is_logged_in'] as bool,
|
||||
status: _parseStatus((json['status'] ?? 'offline') as String),
|
||||
isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
|
||||
currentCallSid: json['current_call_sid'] 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) {
|
||||
return QueueInfo(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
type: json['type'] as String,
|
||||
id: _toInt(json['id']),
|
||||
name: (json['name'] ?? '') as String,
|
||||
type: (json['type'] ?? '') 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 {
|
||||
@@ -43,12 +49,18 @@ class QueueCall {
|
||||
|
||||
factory QueueCall.fromJson(Map<String, dynamic> json) {
|
||||
return QueueCall(
|
||||
callSid: json['call_sid'] as String,
|
||||
fromNumber: json['from_number'] as String,
|
||||
toNumber: json['to_number'] as String,
|
||||
position: json['position'] as int,
|
||||
status: json['status'] as String,
|
||||
waitTime: json['wait_time'] as int,
|
||||
callSid: (json['call_sid'] ?? '') as String,
|
||||
fromNumber: (json['from_number'] ?? '') as String,
|
||||
toNumber: (json['to_number'] ?? '') as String,
|
||||
position: _toInt(json['position']),
|
||||
status: (json['status'] ?? '') as String,
|
||||
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) {
|
||||
return User(
|
||||
id: json['user_id'] as int,
|
||||
login: json['user_login'] as String,
|
||||
displayName: json['display_name'] as String,
|
||||
id: _toInt(json['user_id']),
|
||||
login: (json['user_login'] ?? '') as String,
|
||||
displayName: (json['display_name'] ?? '') 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/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 {
|
||||
final ApiClient _api;
|
||||
final SseService _sse;
|
||||
@@ -12,12 +22,14 @@ class AgentProvider extends ChangeNotifier {
|
||||
AgentStatus? _status;
|
||||
List<QueueInfo> _queues = [];
|
||||
bool _sseConnected = false;
|
||||
List<PhoneNumber> _phoneNumbers = [];
|
||||
StreamSubscription? _sseSub;
|
||||
StreamSubscription? _connSub;
|
||||
|
||||
AgentStatus? get status => _status;
|
||||
List<QueueInfo> get queues => _queues;
|
||||
bool get sseConnected => _sseConnected;
|
||||
List<PhoneNumber> get phoneNumbers => _phoneNumbers;
|
||||
|
||||
AgentProvider(this._api, this._sse) {
|
||||
_connSub = _sse.connectionState.listen((connected) {
|
||||
@@ -33,7 +45,7 @@ class AgentProvider extends ChangeNotifier {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
_status = AgentStatus.fromJson(response.data);
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
} catch (e) { debugPrint('AgentProvider.fetchStatus error: $e'); }
|
||||
}
|
||||
|
||||
Future<void> updateStatus(AgentStatusValue newStatus) async {
|
||||
@@ -49,7 +61,7 @@ class AgentProvider extends ChangeNotifier {
|
||||
currentCallSid: _status?.currentCallSid,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
} catch (e) { debugPrint('AgentProvider.updateStatus error: $e'); }
|
||||
}
|
||||
|
||||
Future<void> fetchQueues() async {
|
||||
@@ -60,11 +72,24 @@ class AgentProvider extends ChangeNotifier {
|
||||
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
|
||||
.toList();
|
||||
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 {
|
||||
await Future.wait([fetchStatus(), fetchQueues()]);
|
||||
await Future.wait([fetchStatus(), fetchQueues(), fetchPhoneNumbers()]);
|
||||
}
|
||||
|
||||
void _handleSseEvent(SseEvent event) {
|
||||
|
||||
@@ -63,13 +63,19 @@ class AuthProvider extends ChangeNotifier {
|
||||
Future<void> _initializeServices() async {
|
||||
try {
|
||||
await _pushService.initialize();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('AuthProvider: push service init error: $e');
|
||||
}
|
||||
try {
|
||||
await _voiceService.initialize();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('AuthProvider: voice service init error: $e');
|
||||
}
|
||||
try {
|
||||
await _sseService.connect();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('AuthProvider: SSE connect error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
|
||||
@@ -103,6 +103,15 @@ class CallProvider extends ChangeNotifier {
|
||||
|
||||
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 {
|
||||
final sid = _callInfo.callSid;
|
||||
if (sid == null) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import '../providers/agent_provider.dart';
|
||||
import '../providers/call_provider.dart';
|
||||
import '../widgets/agent_status_toggle.dart';
|
||||
import '../widgets/dialpad.dart';
|
||||
import '../widgets/queue_card.dart';
|
||||
import 'active_call_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
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
onRefresh: () => agent.refresh(),
|
||||
child: ListView(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
@@ -39,6 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
serverUrl = 'https://$serverUrl';
|
||||
}
|
||||
|
||||
TextInput.finishAutofillContext();
|
||||
context.read<AuthProvider>().login(
|
||||
serverUrl,
|
||||
_usernameController.text.trim(),
|
||||
@@ -55,7 +57,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
child: AutofillGroup(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -80,6 +83,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||
),
|
||||
@@ -91,6 +95,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
prefixIcon: Icon(Icons.person),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofillHints: const [AutofillHints.username],
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||
),
|
||||
@@ -110,6 +115,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
obscureText: _obscurePassword,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Required' : null,
|
||||
),
|
||||
@@ -142,6 +148,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:twilio_voice/twilio_voice.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
@@ -36,7 +37,7 @@ class VoiceService {
|
||||
_identity = data['identity'] as String;
|
||||
await TwilioVoice.instance.setTokens(accessToken: token);
|
||||
} 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);
|
||||
}
|
||||
|
||||
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 {
|
||||
await TwilioVoice.instance.call.sendDigits(digits);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user