agenticerp_voice

SDK Flutter để gọi điện cho khách hàng qua tổng đài AgenticERP ngay trong ứng dụng của bạn: nhân viên bấm "Gọi" → khách thấy số hotline của doanh nghiệp → âm thanh đi ngay trong app → cuộc gọi được ghi âm và lưu vào lịch sử tổng đài như mọi cuộc gọi khác.

Thành phần Việc
AerpClient Kết nối tổng đài bằng token: connect(token) · updateToken · sự kiện kết nối
AerpCall Một cuộc gọi: makeCall(AerpCallParams) · hangup · mute · hold · getRecording
AerpAudioManager Loa ngoài / chọn thiết bị âm thanh
AerpResult Kết quả mọi thao tác: status, code, message

English version below.


1. Thông tin bạn nhận được

Đầu mối triển khai AgenticERP gửi cho bạn qua kênh an toàn một API key của doanh nghiệp — dùng chung cho SDK, gọi qua deep link (mục 7), webhook và API đối tác:

  • API Key SID (dạng SK…)
  • API Key Secret — chỉ lưu trong cấu hình máy chủ của bạn, không bao giờ đưa vào app
  • Địa chỉ máy chủ cho SDK (ví dụ https://erp.example.com)

Mỗi nhân viên gọi qua SDK là một người dùng tổng đài có quyền gọi ra và ít nhất một đầu số gọi ra.

(Dành cho đầu mối triển khai: mỗi doanh nghiệp tạo một key ở Tổng đài AI → Cấu hình → Đầu số → tab SDK gọi điện → Tạo API key; Secret chỉ hiện một lần. Nếu đối tác dùng cả SDK lẫn deep link trên key này, trong Tích hợp ứng dụng đối tác bật "Cho phép Voice SDK kết nối bằng key này" — mặc định tắt, khi đó key chỉ nhận deep link và connect trả sdk_key_invalid.)

2. Máy chủ của bạn ký token

Token là JWT HS256, ký bằng API Key Secret, do máy chủ của bạn phát cho app. Không bao giờ đưa Secret vào app di động.

Claim Bắt buộc Ý nghĩa
iss ✔ API Key SID
jti ✔ Mã duy nhất của token
exp ✔ Thời điểm hết hạn (giây Unix). Không được dài hơn "Hạn token tối đa" của key (mặc định 24 giờ)
userId ✔ Email đăng nhập AgenticERP của nhân viên gọi

Node.js (jsonwebtoken)

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

function voiceToken(userEmail) {
  return jwt.sign(
    { jti: crypto.randomUUID(), userId: userEmail },
    process.env.API_KEY_SECRET,
    { algorithm: 'HS256', issuer: process.env.API_KEY_SID, expiresIn: '1h' },
  );
}

PHP (firebase/php-jwt)

use Firebase\JWT\JWT;

$token = JWT::encode([
    'iss'    => getenv('API_KEY_SID'),
    'jti'    => bin2hex(random_bytes(16)),
    'exp'    => time() + 3600,
    'userId' => $userEmail,
], getenv('API_KEY_SECRET'), 'HS256');

C# (System.IdentityModel.Tokens.Jwt)

var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(apiKeySecret));
var token = new JwtSecurityToken(
    issuer: apiKeySid,
    claims: [new Claim("jti", Guid.NewGuid().ToString()), new Claim("userId", userEmail)],
    expires: DateTime.UtcNow.AddHours(1),
    signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
var jwt = new JwtSecurityTokenHandler().WriteToken(token);

Python (PyJWT)

import time, uuid, jwt

token = jwt.encode(
    {"iss": API_KEY_SID, "jti": str(uuid.uuid4()), "exp": int(time.time()) + 3600, "userId": user_email},
    API_KEY_SECRET, algorithm="HS256")

Gợi ý: cấp token hạn 1 giờ, chỉ cho nhân viên đã đăng nhập vào hệ thống của bạn.

Cuộc gọi được tính cho ai. userId là email đăng nhập AgenticERP của nhân viên — quy ước duy nhất, không cần cấu hình gì thêm. Mỗi cuộc gọi qua SDK được ghi vào lịch sử tổng đài dưới tên nhân viên đó (kèm máy lẻ của họ), nên lọc theo nhân viên, giám sát và thống kê đều tính như cuộc gọi bấm từ app AgenticERP. Mã nhân viên riêng của hệ thống bạn, nếu cần lưu kèm cuộc gọi, đưa vào customData (ví dụ {'employee_code': 'NV001'}) và đọc lại ở call.info.customData.

Sau connect, client.user cho biết tên và máy lẻ của nhân viên đã khớp — nên hiện lên app để nhân viên thấy mình đang gọi bằng máy lẻ nào.

3. Cài đặt

flutter pub add agenticerp_voice

Yêu cầu: Flutter ≥ 3.27 (Dart ≥ 3.6). Android minSdk 21+, iOS 13+.

Android — android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

iOS — ios/Runner/Info.plist:

<key>NSMicrophoneUsageDescription</key>
<string>Cần micro để nói chuyện với khách hàng qua tổng đài.</string>
<key>UIBackgroundModes</key>
<array>
  <string>audio</string>
</array>

UIBackgroundModes/audio giữ cuộc gọi khi nhân viên chuyển sang app khác. Trong ios/Podfile đặt platform :ios, '13.0'.

Quyền micro: SDK không tự hỏi quyền — app xin quyền trước khi gọi (ví dụ dùng permission_handler, xem example/).

4. Sử dụng

Kết nối

import 'package:agenticerp_voice/agenticerp_voice.dart';

final client = AerpClient(baseUrl: 'https://erp.example.com');

client.events.listen((event) async {
  switch (event) {
    case AerpClientConnected(:final user):
      print('Đã kết nối: ${user.displayName}, máy lẻ ${user.extension}');
    case AerpTokenWillExpire():          // ~60 giây trước khi hết hạn
    case AerpRequestAccessToken():       // phiên đã hết hạn / bị thu hồi
      await client.updateToken(await myServer.fetchVoiceToken());
    case AerpClientDisconnected(:final reason):
      print('Mất kết nối: $reason');
    case AerpClientError(:final code, :final message):
      print('Lỗi $code: $message');
  }
});

final result = await client.connect(await myServer.fetchVoiceToken());
if (!result.status) print('connect thất bại: ${result.code}');

// Các đầu số nhân viên được dùng để gọi ra (khách sẽ thấy số này):
for (final n in client.fromNumbers) print('${n.number} ${n.isDefault ? "(mặc định)" : ""}');

Phiên sống đúng bằng hạn token. Nếu phiên hết hạn giữa cuộc gọi, âm thanh vẫn tiếp tục; chỉ cần updateToken(...) là SDK nối lại việc theo dõi cuộc gọi (và gửi lại lệnh gác máy nếu nhân viên đã gác trong lúc chờ).

Gọi điện

final call = AerpCall(client);

call.events.listen((event) {
  switch (event) {
    case AerpSignalingStateChanged(:final state, :final reason):
      // calling → ringing → answered → ended (hoặc busy)
      print('Trạng thái: ${state.name} ${reason ?? ""}');
    case AerpMediaStateChanged(:final state):
      print('Âm thanh: ${state.name}');
    case AerpCallInfoUpdated(:final info):
      print('Thời lượng: ${call.talkDuration}');
    case AerpCallError(:final code, :final message):
      print('Lỗi cuộc gọi $code: $message');
  }
});

final result = await call.makeCall(const AerpCallParams(
  to: '0912345678',             // số khách hàng
  from: '02812345678',          // tuỳ chọn — bỏ trống = đầu số mặc định của nhân viên
  customData: {'booking_id': 'BK-1024'},   // mã của bạn, đọc lại ở call.info.customData
));
if (!result.status) print('Không gọi được: ${result.code} — ${result.message}');

await call.mute(true);                  // tắt tiếng
await call.setSpeakerphoneOn(true);     // loa ngoài
await call.hold(true);                  // giữ máy (khách nghe nhạc chờ), chỉ khi đang nói chuyện
await call.hangup();                    // gác máy
await call.destroy();                   // giải phóng khi đóng màn hình

Mỗi AerpCall dùng cho một cuộc gọi; mỗi AerpClient có tối đa một cuộc gọi đang diễn ra. Trong lúc đổ chuông SDK tự phát nhạc chờ (tắt bằng AerpCallOptions(playRingback: false)).

Ghi âm

final rec = await call.getRecording();   // sau khi cuộc gọi kết thúc
if (rec.available) openUrl(rec.playbackUrl!);   // link ký sẵn, hiệu lực ~10 phút

Tệp ghi âm cần vài giây để tải lên sau khi gác máy; nếu available == false hãy thử lại sau.

5. Trạng thái và mã lỗi

Trạng thái cuộc gọi (AerpSignalingState): calling (đang quay số) → ringing (máy khách đổ chuông) → answered (khách nghe máy) → ended / busy.

Lý do kết thúc (reason / call.info.endReason): local_hangup (nhân viên gác máy), remote_hangup / normal (khách gác máy), no_answer, busy, unreachable (thuê bao không liên lạc được — nhà mạng phát thông báo), cancelled, media_lost, hoặc mã lỗi của tổng đài.

Mã lỗi thường gặp (AerpResult.code):

Mã Ý nghĩa Cách xử lý
sdk_token_expired Token hết hạn Lấy token mới, gọi updateToken
sdk_token_signature_invalid Sai Secret Kiểm tra API_KEY_SECRET trên máy chủ của bạn
sdk_token_ttl_too_long Hạn token dài hơn mức cho phép của key Giảm exp
sdk_key_invalid / sdk_key_revoked SID sai hoặc key đã tắt Liên hệ quản trị tổng đài
sdk_user_not_found userId không phải email của nhân viên tổng đài nào Kiểm tra email đăng nhập AgenticERP của nhân viên
forbidden Nhân viên chưa có quyền gọi ra Quản trị cấp quyền Tổng đài
invalid_phone Số điện thoại không hợp lệ Kiểm tra số
dnc_blocked Số nằm trong danh sách không gọi —
call_permission_denied Máy lẻ không được gọi loại số này (quốc tế, 1900…) Quản trị nâng quyền gọi
from_number_not_allowed Đầu số from không thuộc quyền của nhân viên Dùng số trong client.fromNumbers
no_from_number Nhân viên chưa được cấp đầu số gọi ra Quản trị cấp đầu số
call_failed Tổng đài không đặt được cuộc gọi Xem message, thử lại
call_in_progress Đang có cuộc gọi khác Gác cuộc gọi trước
not_connected Chưa connect Gọi connect(token)
network_error / timeout Mất mạng Thử lại

6. App ví dụ

example/ là app hoàn chỉnh: nhập địa chỉ tổng đài + token, chọn đầu số, gọi, tắt tiếng, loa ngoài, giữ máy, gác máy. Example dùng gói agenticerp_voice trên pub.dev, nên có thể chép riêng thư mục này làm khung cho app của bạn. Chạy: cd example && flutter pub get && flutter run.

Nếu ứng dụng của bạn không cần tự phát âm thanh cuộc gọi, có thể chuyển việc gọi sang app AgenticERP trên điện thoại của nhân viên: nhân viên bấm Gọi trong ứng dụng của bạn → app AgenticERP mở màn xác nhận → cuộc gọi đi ra bằng đầu số doanh nghiệp, có ghi âm và lịch sử → kết quả trả về hệ thống của bạn qua webhook hoặc API đối tác. Cách này dùng cùng API Key (SID / Secret) với SDK.

SDK (mục 1–6) Deep link (mục này)
Âm thanh cuộc gọi Trong app của bạn Trong app AgenticERP
Nhân viên cần cài Chỉ app của bạn App của bạn và app AgenticERP (bản cập nhật mới nhất)
Kết quả cuộc gọi AerpCall.events, call.info Webhook ký HMAC hoặc API đối tác

7.1 Liên kết

com.agenticwork.agenticerp://voip/handover?v=1&ref=<mã tham chiếu>&user=<tên người dùng>&to=<số điện thoại>&ts=<giây unix>[&t=<token ký>][&from=<đầu số>][&label=<tên hiển thị>][&ret=<URL quay lại>]
Tham số Bắt buộc Ý nghĩa
v ✔ Luôn là 1
ref ✔ Mã tham chiếu của bạn (A-Z a-z 0-9 . _ : -, tối đa 128 ký tự) — mọi kết quả trả về theo mã này
user ✔ Tên người dùng bên hệ thống của bạn (chỉ để theo dõi)
to ✔ Số khách hàng, 8–15 chữ số, có thể có + ở đầu
ts ✔ Thời điểm tạo liên kết (giây Unix). Liên kết không ký hết hạn sau 10 phút
t Token ký (mục 7.3) — khuyến nghị
from Đầu số gọi ra; phải là đầu số nhân viên được dùng. Bỏ trống = đầu số mặc định
label Tên hiển thị trên màn xác nhận (tối đa 80 ký tự), không gửi lên máy chủ
ret URL quay lại ứng dụng của bạn sau cuộc gọi; tiền tố phải được đăng ký trước

Mã hoá từng giá trị bằng Uri.encodeComponent (dấu cách thành %20, + thành %2B). Đừng dùng Uri(queryParameters: …) của Dart cho liên kết này: nó đổi dấu cách thành +, và AgenticERP giữ + nguyên văn nên Nguyễn Lan sẽ thành Nguyễn+Lan.

7.2 Mở app AgenticERP

Cùng một chuỗi liên kết dùng cho cả hai nền tảng: Android gửi liên kết bằng intent tường minh tới app AgenticERP, iOS mở thẳng URL (scheme theo bundle id).

import 'dart:io';
import 'package:android_intent_plus/android_intent.dart';
import 'package:url_launcher/url_launcher.dart';

String handoverQuery(Map<String, String> p) =>
    p.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&');

Future<void> callViaAgenticErp({required String ref, required String user, required String to, String? token}) async {
  final query = handoverQuery({
    'v': '1',
    'ref': ref,
    'user': user,
    'to': to,
    'ts': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
    if (token != null) 't': token,
  });
  if (Platform.isAndroid) {
    await AndroidIntent(
      action: 'action_view',
      data: 'com.agenticwork.agenticerp://voip/handover?$query',
      package: 'com.agenticwork.agenticerp',
      componentName: 'com.agenticwork.agenticerp.MainActivity',
    ).launch();
  } else {
    await launchUrl(Uri.parse('com.agenticwork.agenticerp://voip/handover?$query'),
        mode: LaunchMode.externalApplication);
  }
}

Muốn kiểm tra app AgenticERP đã cài chưa trước khi hiện nút Gọi: Android thêm <queries><package android:name="com.agenticwork.agenticerp" /></queries> vào AndroidManifest.xml; iOS thêm com.agenticwork.agenticerp vào LSApplicationQueriesSchemes trong Info.plist rồi dùng canLaunchUrl.

7.3 Token ký cho liên kết (khuyến nghị)

Máy chủ của bạn ký token cho từng lần bấm, đặt vào tham số t. Màn xác nhận khi đó hiện Đã xác thực, và AgenticERP từ chối liên kết bị sửa ref, to hoặc user.

const token = jwt.sign(
  { jti: crypto.randomUUID(), ref, to, usr: user /* , ret */ },
  process.env.API_KEY_SECRET,
  { algorithm: 'HS256', issuer: process.env.API_KEY_SID, audience: 'agentic-erp-handover', expiresIn: 600 },
);

Claim exp không được dài hơn hạn tối đa đã cấu hình cho tích hợp (mặc định 900 giây).

7.4 Nhận kết quả

  • Webhook: AgenticERP gửi POST tới URL của bạn với các sự kiện handover.declined, call.started, call.ended, call.recording. Header X-AgenticERP-Signature: t=<giây unix>,v1=<hex> là HMAC-SHA256 của chuỗi <t>.<thân yêu cầu> bằng secret webhook (whsec_…); X-AgenticERP-Event-Id dùng để bỏ trùng.
  • API đối tác (máy chủ của bạn tự đọc): GET /api/v1/sdk/partner/handovers?external_ref=<ref>, GET /api/v1/sdk/partner/handovers/{id}, GET /api/v1/sdk/partner/handovers/{id}/recording, GET /api/v1/sdk/partner/events?cursor=…, với Authorization: Bearer <JWT> ký bằng API Key Secret: iss = SID, aud = agentic-erp-partner, có jti, hạn tối đa 300 giây.

Hướng dẫn tích hợp đầy đủ (bảng trạng thái, mã lỗi, nội dung webhook, quay lại ứng dụng của bạn, danh sách kiểm thử) có trong tài liệu "Gọi điện từ ứng dụng đối tác qua AgenticERP" (PDF) do đầu mối triển khai AgenticERP gửi kèm API Key.


English

agenticerp_voice lets your Flutter app place phone calls to customers through the AgenticERP call centre: the customer sees the company hotline, the call audio runs inside your app, and the call is recorded and logged in the call centre like any other call. Main types: AerpClient (connection + token), AerpCall (one call), AerpAudioManager (loudspeaker / devices) and AerpResult (result of every operation).

1. What you receive. Your AgenticERP deployment contact sends you, over a secure channel, a single API key for the business — shared by the SDK, deep links (section 7), webhooks and the partner API: API Key SID, API Key Secret (keep it on your server only) and the server address — and tells you that the token's userId is the staff member's AgenticERP login email. Each staff member calling through the SDK is a call-centre user with call-out permission and at least one outbound hotline. (For the deployment contact: create one key per business in Call Center → Configuration → Hotlines → Calling SDK → Create API key; the Secret is shown once. If the partner uses both the SDK and deep links on that key, turn on "Allow the Voice SDK to connect with this key" in the partner-app integration settings — it is off by default, and then connect returns sdk_key_invalid.)

2. Your server signs tokens. HS256 JWT signed with the Secret, claims iss (SID), jti, exp (not longer than the key's maximum lifetime, 24 h by default) and userId. userId decides which staff member the call belongs to: every SDK call is logged under that AgenticERP user (with their extension), so per-agent history, monitoring and statistics count it like a call made from the AgenticERP app. userId is always the staff member's AgenticERP login email; keep your own employee code in the call's customData if you need it. client.user shows the matched name and extension. Samples for Node.js, PHP, C# and Python are in section 2 above. Never ship the Secret in the app.

3. Install. flutter pub add agenticerp_voice, then add the Android permissions and the iOS Info.plist keys from section 3. Flutter ≥ 3.27, Android minSdk 21, iOS 13. Ask for the microphone permission in your app before calling.

4. Use. AerpClient(baseUrl:) → connect(token) → listen to events and call updateToken(newToken) on AerpTokenWillExpire / AerpRequestAccessToken. Then AerpCall(client).makeCall(AerpCallParams(to: ..., from: ..., customData: ...)), follow AerpSignalingStateChanged (calling → ringing → answered → ended/busy), and use mute, setSpeakerphoneOn, hold, hangup, getRecording, destroy. One call per AerpCall, one active call per client. A local ringback tone plays while ringing (disable with AerpCallOptions(playRingback: false)).

5. Errors. Every operation returns AerpResult(status, code, message); the codes are listed in the table in section 5 and in AerpErrorCode.

6. Example. example/ is a complete demo app that depends on the pub.dev release, so you can copy the folder as a starting point (cd example && flutter pub get && flutter run).

7. Second option: call via deep link (no SDK). If your app does not need to carry the call audio itself, hand the call to the AgenticERP app on the staff member's phone with one link, com.agenticwork.agenticerp://voip/handover?v=1&ref=…&user=…&to=…&ts=…[&t=…][&from=…][&label=…][&ret=…]: send it as an explicit intent on Android (package com.agenticwork.agenticerp, activity com.agenticwork.agenticerp.MainActivity) and open it directly on iOS. Encode each value with Uri.encodeComponent (not Uri(queryParameters:), which turns spaces into +). Optionally sign each link (t): HS256 with the API Key Secret, iss = SID, aud = agentic-erp-handover, claims jti, ref, to, usr. Results reach your server by signed webhook (X-AgenticERP-Signature: t=…,v1=HMAC-SHA256("<t>.<body>")) or the partner API (/api/v1/sdk/partner/handovers…, /events, bearer JWT with aud = agentic-erp-partner, max 300 s). The same API key serves both options. The full integration guide ("Call handover from a partner app", PDF) comes with your key.

Libraries

agenticerp_voice
AgenticERP Voice SDK — place phone calls through the AgenticERP call centre (tổng đài) from a Flutter app, with the call audio running inside the app.