Compare commits
2 Commits
2026.03.06
...
2026.03.06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc6fa8c3c | ||
|
|
d41b6aa535 |
@@ -52,8 +52,6 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
|
||||
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']));
|
||||
|
||||
$settings_saved = true;
|
||||
}
|
||||
@@ -65,8 +63,6 @@ $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', '');
|
||||
$twilio_api_key_sid = get_option('twp_twilio_api_key_sid', '');
|
||||
$twilio_api_key_secret = get_option('twp_twilio_api_key_secret', '');
|
||||
|
||||
// Get update status
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||
@@ -175,37 +171,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_twilio_api_key_sid">Twilio API Key SID</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...">
|
||||
<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>
|
||||
</table>
|
||||
|
||||
<?php if ($fcm_sa_configured): ?>
|
||||
|
||||
@@ -211,44 +211,41 @@ 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) {
|
||||
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,37 +686,25 @@ 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');
|
||||
|
||||
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
|
||||
);
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||
$twilio = new TWP_Twilio_API();
|
||||
$result = $twilio->generate_capability_token($identity);
|
||||
|
||||
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
|
||||
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
|
||||
$voiceGrant->setIncomingAllow(true);
|
||||
|
||||
$token->addGrant($voiceGrant);
|
||||
if (!$result['success']) {
|
||||
return new WP_Error('token_error', $result['error'], array('status' => 500));
|
||||
}
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'token' => $token->toJWT(),
|
||||
'identity' => $identity,
|
||||
'expires_in' => 3600
|
||||
'token' => $result['data']['token'],
|
||||
'identity' => $result['data']['client_name'],
|
||||
'expires_in' => $result['data']['expires_in']
|
||||
), 200);
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,15 @@ class CallProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
|
||||
|
||||
Future<void> makeCall(String number) async {
|
||||
_callInfo = _callInfo.copyWith(
|
||||
state: CallState.connecting,
|
||||
callerNumber: number,
|
||||
);
|
||||
notifyListeners();
|
||||
await _voiceService.makeCall(number);
|
||||
}
|
||||
|
||||
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,90 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showDialer(BuildContext context) {
|
||||
final numberController = TextEditingController();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Dialpad
|
||||
Dialpad(
|
||||
onDigit: (digit) {
|
||||
numberController.text += digit;
|
||||
numberController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: numberController.text.length),
|
||||
);
|
||||
},
|
||||
onClose: () => Navigator.pop(ctx),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Call button
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.call),
|
||||
label: const Text('Call'),
|
||||
onPressed: () {
|
||||
final number = numberController.text.trim();
|
||||
if (number.isNotEmpty) {
|
||||
context.read<CallProvider>().makeCall(number);
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final agent = context.watch<AgentProvider>();
|
||||
@@ -58,6 +143,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> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -62,6 +62,10 @@ class VoiceService {
|
||||
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
||||
}
|
||||
|
||||
Future<bool> makeCall(String to) async {
|
||||
return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false;
|
||||
}
|
||||
|
||||
Future<void> sendDigits(String digits) async {
|
||||
await TwilioVoice.instance.call.sendDigits(digits);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user