GPSAI Chatbot
Intégration Mobile
Guide d'intégration complet pour embarquer l'assistant conversationnel GPSAI dans votre application Android ou iOS, en utilisant Stream Chat comme couche de transport.
01Vue d'ensemble
GPSAI est un assistant conversationnel arabe/français qui permet aux propriétaires de flotte d'interroger leurs véhicules en langage naturel (« وين voiture 4 توا ؟ », « قداش السرعة »…). Les applications mobiles communiquent avec le bot via Stream Chat ; tout le traitement IA se fait côté serveur et arrive sous forme de messages chat classiques.
02Architecture
Le flux complet d'un message implique quatre acteurs :
Pour le développeur mobile, seules les étapes 01 et
04 sont visibles. Le SDK Stream gère automatiquement
la livraison, l'ordre, le cache hors-ligne, les indicateurs de
saisie et les accusés de lecture.
03Prérequis
Ce dont vous avez besoin de l'équipe GPS Tunisie
- Une URL de base du backend (ex :
http://plat.gps-tunisie.com:8080/api) - Un ID utilisateur GPS authentifié (entier, issu de votre flow de login existant)
- L'ID utilisateur du bot — par défaut
user_1
Vous n'avez pas besoin de connaître la clé API Stream — le backend la retourne avec le token à chaque authentification.
04Installer le SDK Stream
dependencies { // Stream Chat UI Components implementation "io.getstream:stream-chat-android-ui-components:6.4.0" implementation "io.getstream:stream-chat-android-offline:6.4.0" implementation "io.getstream:stream-chat-android-state:6.4.0" // HTTP client for auth token call implementation "com.squareup.okhttp3:okhttp:4.12.0" }
target 'YourApp' do use_frameworks! # Stream Chat SDK pod 'StreamChat', '~> 4.0' pod 'StreamChatUI', '~> 4.0' end
.package( url: "https://github.com/GetStream/stream-chat-swift", from: "4.0.0" )
Permissions requises
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <!-- voice only -->
<key>NSMicrophoneUsageDescription</key> <string>Used for voice messages with GPSAI assistant</string>
05Obtenir le jeton d'authentification
Avant de se connecter à Stream, demandez un JWT à durée limitée et la clé API publique Stream au backend GPSAI. Passez l'ID utilisateur GPS que vous avez déjà depuis votre propre système de login.
Corps de la requête
{
"user_id": "user_42", // "user_" + gpsUserId
"gps_user_id": "42" // raw GPS user ID
}
Réponse
{
"token": "eyJhbGciOi...", // JWT (24h validity)
"api_key": "nwz6q327uhqy", // public Stream API key
"bot_id": "user_1", // bot user ID for channel
"user_id": "user_42",
"gps_user_id": "42"
}
| Champ | Rôle |
|---|---|
token |
RequisÀ passer à connectUser() dans le SDK Stream. |
api_key |
RequisClé Stream publique pour instancier ChatClient. Ne la hardcodez jamais — lisez-la toujours depuis cette réponse. |
bot_id |
RequisID utilisateur du bot à inclure comme membre lors de la création du canal. |
Implémentation
private void fetchToken() { new Thread(() -> { try { OkHttpClient client = new OkHttpClient(); JSONObject body = new JSONObject(); body.put("user_id", "user_" + gpsUserId); body.put("gps_user_id", gpsUserId); Request request = new Request.Builder() .url(BACKEND_URL + "/chatbot_stream_backend.php?action=token") .post(RequestBody.create( MediaType.parse("application/json"), body.toString())) .build(); Réponse response = client.newCall(request).execute(); JSONObject json = new JSONObject(response.body().string()); String token = json.getString("token"); String apiKey = json.getString("api_key"); String botId = json.getString("bot_id"); runOnUiThread(() -> initChat(apiKey, token, botId)); } catch (Exception e) { Log.e(TAG, "Token fetch failed", e); } }).start(); }
struct GPSAuthRéponse: Decodable { let token: String let apiKey: String let botId: String enum CodingKeys: String, CodingKey { case token case apiKey = "api_key" case botId = "bot_id" } } func fetchToken(gpsUserId: String) async throws -> GPSAuthRéponse { var request = URLRequest(url: URL(string: "\(backendURL)/chatbot_stream_backend.php?action=token")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = [ "user_id": "user_\(gpsUserId)", "gps_user_id": gpsUserId ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, _) = try await URLSession.shared.data(for: request) return try JSONDecoder().decode(GPSAuthRéponse.self, from: data) }
gps_user_id a son propre JWT. Si un utilisateur
se déconnecte et qu'un autre se connecte, redemandez un nouveau
token. Le token est valable 24 heures.
06Connecter l'utilisateur à Stream
Avec le token en main, construisez un ChatClient et
connectez l'utilisateur. Stream maintient un WebSocket persistant
pour les mises à jour temps réel.
private void initChat(String apiKey, String token, String botId) { // Required plugins for SDK 6.x StreamOfflinePluginFactory offlinePlugin = new StreamOfflinePluginFactory(getApplicationContext()); StreamStatePluginFactory statePlugin = new StreamStatePluginFactory( new StatePluginConfig(true, true), getApplicationContext()); ChatClient client = new ChatClient.Builder(apiKey, getApplicationContext()) .withPlugins(offlinePlugin, statePlugin) .build(); User user = new User.Builder() .withId("user_" + gpsUserId) .withName("Utilisateur " + gpsUserId) .build(); client.connectUser(user, token).enqueue(result -> { if (result.isSuccess()) { createOrOpenChannel(client, botId); } else { Log.e(TAG, "Connect failed: " + result.errorOrNull()); } }); }
import StreamChat func initChat(auth: GPSAuthRéponse, gpsUserId: String) { let config = ChatClientConfig(apiKey: .init(auth.apiKey)) let client = ChatClient(config: config) let userInfo = UserInfo( id: "user_\(gpsUserId)", name: "Utilisateur \(gpsUserId)" ) client.connectUser( userInfo: userInfo, token: try! Token(rawValue: auth.token) ) { error in if let error = error { print("Connect failed: \(error)") } else { self.openChannel(client: client, botId: auth.botId, gpsUserId: gpsUserId) } } }
07Ouvrir le canal de chat
Créez un canal de type messaging avec un ID déterministe
basé sur l'utilisateur. Le bot doit être ajouté comme membre, et le
gps_user_id doit être placé dans extraData —
le webhook backend le lit pour router correctement les requêtes vers
l'AI Engine.
| Convention | Valeur |
|---|---|
| Type de canal | messaging |
| ID du canal | gps_chat_user_{gpsUserId} |
| Membres | user_{gpsUserId} + user_1 (le bot) |
| Données extra | { "gps_user_id": "42", "name": "Assistant GPS" } |
private void createOrOpenChannel(ChatClient client, String botId) { String channelId = "gps_chat_user_" + gpsUserId; Map<String, Object> extraData = new HashMap<>(); extraData.put("gps_user_id", gpsUserId); extraData.put("name", "Assistant GPS"); client.channel("messaging", channelId) .create(Arrays.asList("user_" + gpsUserId, botId), extraData) .enqueue(result -> { if (result.isSuccess()) { String cid = "messaging:" + channelId; startActivity(CustomChatActivity.newIntent(this, cid)); finish(); } }); }
func openChannel(client: ChatClient, botId: String, gpsUserId: String) { let channelId = ChannelId(type: .messaging, id: "gps_chat_user_\(gpsUserId)") let controller = try! client.channelController( createChannelWithId: channelId, name: "Assistant GPS", members: ["user_\(gpsUserId)", botId], isCurrentUserMember: true, extraData: ["gps_user_id": .string(gpsUserId)] ) controller.synchronize { error in guard error == nil else { return } // Push your ChatViewController with `controller` let chatVC = ChatViewController(channelController: controller) navigationController?.pushViewController(chatVC, animated: true) } }
extraData["gps_user_id"] est absent, le webhook
backend ne peut pas identifier quelle flotte interroger. Le bot
répondra mais les données seront celles de l'utilisateur
1 (fallback par défaut).
08Envoyer des messages
Utilisez l'API standard d'envoi de message du SDK Stream. Le bot répondra automatiquement en 1 à 3 secondes selon la charge de l'AI Engine.
private void sendMessage(String text) { Message message = new Message.Builder() .withText(text) .build(); ChatClient.instance() .channel("messaging", channelId) .sendMessage(message, false) .enqueue(result -> { if (result.isSuccess()) { Log.d(TAG, "Sent: " + text); } }); }
func sendMessage(_ text: String) { channelController.createNewMessage(text: text) { result in switch result { case .success(let messageId): print("Sent: \(messageId)") case .failure(let error): print("Error: \(error)") } } }
Réception des réponses du bot
Pas besoin de polling ni d'appel à un endpoint — le SDK Stream
pousse les messages du bot via le même WebSocket. Bind un
MessageListView (Android) ou utilisez un
ChannelControllerDelegate (iOS) et les nouveaux
messages apparaîtront automatiquement.
09Messages vocaux facultatif
Les messages vocaux utilisent une architecture en deux
pistes parallèles : l'enregistrement est envoyé immédiatement
comme un message Stream portant le chemin du fichier local dans
extraData (pour que l'utilisateur puisse le réécouter
dans le chat), et en parallèle l'audio est encodé en base64 puis
POSTé au backend pour la transcription et le traitement IA. Le
backend injecte ensuite la transcription comme message utilisateur,
puis la réponse du bot.
Flow d'enregistrement
- Tap sur le micro → enregistrement vers un emplacement permanent (
filesDir/voice_messages/) - Échantillonnage des amplitudes audio toutes les 80 ms (pour la waveform)
- Stop → envoi du message Stream avec
extraData+ POST du base64 au backend - Le backend transfère à l'AI Engine, reçoit
transcript+ réponse du bot, poste les deux dans le canal
Enregistrement de l'audio
// MPEG_4 / AAC, 16 kHz mono, 64 kbps — small and Whisper-friendly recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); recorder.setAudioSamplingRate(16000); recorder.setAudioEncodingBitRate(64000); // Store in filesDir (NOT cacheDir — file must survive for replay) File dir = new File(context.getFilesDir(), "voice_messages"); String path = new File(dir, "voice_" + System.currentTimeMillis() + ".m4a") .getAbsolutePath(); recorder.setOutputFile(path); recorder.prepare(); recorder.start(); // Sample amplitudes every 80ms for the waveform handler.postDelayed(() -> amplitudes.add(recorder.getMaxAmplitude()), 80);
import AVFoundation let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 64000 ] let dir = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent("voice_messages") try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent("voice_\(Int(Date().timeIntervalSince1970)).m4a") recorder = try AVAudioRecorder(url: url, settings: settings) recorder.isMeteringEnabled = true // for amplitudes recorder.prepareToRecord() recorder.record() // Sample amplitudes every 0.08s Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in recorder.updateMeters() amplitudes.append(Int(recorder.averagePower(forChannel: 0) * 10000)) }
Envoi du message vocal (deux pistes)
{
"user_id": "42",
"channel_id": "gps_chat_user_42",
"audio_base64": "AAAAGGZ0eXBtcDQy..."
}
Le message côté Stream porte les métadonnées de lecture dans extraData :
{
"text": "🎙️ Voice",
"extraData": {
"is_voice": true,
"voice_local_path": "/data/.../voice_1716234567.m4a",
"voice_duration_ms": 4280,
"voice_amplitudes": [1240, 3890, 5120, 4200, 2100, ...]
}
}
| Champ extraData | Rôle |
|---|---|
is_voice |
RequisFlag booléen — le ViewHolder factory l'utilise pour rendre une bulle vocale au lieu d'une bulle texte. |
voice_local_path |
RequisChemin absolu vers le fichier audio local. Utilisé par MediaPlayer / AVAudioPlayer pour la lecture. |
voice_duration_ms |
RequisDurée totale en millisecondes (affichée en 00:04). |
voice_amplitudes |
OptionnelÉchantillons d'amplitude bruts pour la visualisation de la waveform. Chaque valeur est un pic capturé à intervalle de ~80 ms. |
private void sendVoiceMessage(String path, long durationMs, ArrayList<Integer> amps) { // ── TRACK 1: Stream message with extraData (instant UI) ── HashMap<String, Object> extra = new HashMap<>(); extra.put("is_voice", true); extra.put("voice_local_path", path); extra.put("voice_duration_ms", durationMs); extra.put("voice_amplitudes", amps); Message msg = new Message.Builder() .withText("🎙️ Voice") .withExtraData(extra) .build(); ChatClient.instance() .channel(channelType, channelId) .sendMessage(msg, false) .enqueue(r -> {}); // ── TRACK 2: base64 to backend (for AI processing) ── new Thread(() -> { String base64 = VoiceRecorder.fileToBase64(path); JSONObject body = new JSONObject(); body.put("user_id", gpsUserId); body.put("channel_id", channelId); body.put("audio_base64", base64); // POST { user_id, channel_id, audio_base64 } to ?action=voice // ⚠️ Do NOT delete the file here — local playback needs it! }).start(); }
func sendVoiceMessage(path: URL, durationMs: Int, amps: [Int]) { // ── TRACK 1: Stream message with extraData ── let extra: [String: RawJSON] = [ "is_voice": .bool(true), "voice_local_path": .string(path.path), "voice_duration_ms": .number(Double(durationMs)), "voice_amplitudes": .array(amps.map { .number(Double($0)) }) ] channelController.createNewMessage(text: "🎙️ Voice", extraData: extra) // ── TRACK 2: base64 to backend ── Task.detached { let data = try Data(contentsOf: path) let base64 = data.base64EncodedString() var req = URLRequest(url: URL(string: "\(backendURL)/chatbot_stream_backend.php?action=voice")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: [ "user_id": gpsUserId, "channel_id": channelId, "audio_base64": base64 ]) _ = try await URLSession.shared.data(for: req) } }
Lecture audio
Quand un message arrive avec extraData.is_voice == true,
affichez une bulle vocale (bouton play + waveform + durée). Au tap,
utilisez un singleton de lecture pour garantir qu'un seul audio
joue à la fois.
public class VoicePlayerManager { private static VoicePlayerManager INSTANCE; private MediaPlayer player; private String currentPath; public boolean toggle(String path, Listener listener) { // Same file → pause/resume; different file → stop old, play new if (path.equals(currentPath) && player != null) { if (player.isPlaying()) { player.pause(); return false; } player.start(); return true; } releaseInternal(); player = new MediaPlayer(); player.setDataSource(path); player.prepare(); player.start(); currentPath = path; return true; } } // In your VoiceUserHolder: btnPlayPause.setOnClickListener(v -> { boolean playing = VoicePlayerManager.get().toggle(localPath, listener); btnPlayPause.setImageResource(playing ? R.drawable.ic_pause : R.drawable.ic_play); });
class VoicePlayerManager { static let shared = VoicePlayerManager() private var player: AVAudioPlayer? private var currentPath: String? func toggle(_ path: String, listener: VoiceListener) -> Bool { if currentPath == path, let p = player { if p.isPlaying { p.pause(); return false } p.play(); return true } player?.stop() player = try? AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) player?.play() currentPath = path return true } }
Ce que le backend fait avec l'audio
- Reçoit
audio_base64+channel_id+user_id - Transfère l'audio base64 à l'AI Engine via
POST /chatbot/message(l'audio remplace le texte dans le champmessage) - L'AI Engine retourne
{ transcript: "...", message: "...", suggestions: [...] } - Le backend poste la transcription comme message Stream depuis l'utilisateur (
"🎙️ {transcript}") pour qu'elle apparaisse dans l'historique - Le backend poste la réponse du bot comme message Stream depuis
user_1
MIN_RECORDING_MS = 500 pour rejeter les
taps accidentels.
10Référence des endpoints API
Le client mobile n'appelle que ces deux endpoints — toutes les autres interactions passent par le SDK Stream.
| Méthode | Endpoint | Rôle |
|---|---|---|
| POST | ?action=token |
Obtenir JWT + api_key + bot_id |
| POST | ?action=voice |
Uploader l'audio pour transcription & réponse |
11Format des messages
Les réponses du bot arrivent comme des messages Stream standards. Le
champ text contient du Markdown — rendez-le (gras,
italique, liens) pour une meilleure UX.
📍 *voiture 4* 🏠 25 جويلية, معتمدية سيدي حسين, تونس 🏙 المدينة: تونس ⏱ آخر تحديث: 2026-05-20 14:32:18 📊 الحالة: En mouvement (4min 9s) _IMEI sélectionné: 355710091342167_ _Véhicule sélectionné: voiture 4_
Identifiez les messages du bot par leur expéditeur :
boolean isBot = "user_1".equals(message.getUser().getId());
Ou en Swift :
let isBot = message.author.id == "user_1"
Messages vocaux
Un message vocal se détecte par la présence de
extraData.is_voice == true. Le factory doit afficher une
bulle vocale (bouton play + waveform + durée) au lieu de la
bulle texte standard :
if (item instanceof MessageListItem.MessageItem) { Message m = ((MessageListItem.MessageItem) item).getMessage(); boolean isBot = botId.equals(m.getUser().getId()); Object voice = m.getExtraData() != null ? m.getExtraData().get("is_voice") : null; boolean isVoice = voice instanceof Boolean && (Boolean) voice; if (!isBot && isVoice) return TYPE_VOICE_USER; return isBot ? TYPE_BOT : TYPE_USER; }
La transcription postée par le backend après la STT arrive comme un
message texte classique préfixé par 🎙️. Traitez-le
comme n'importe quel message utilisateur — aucun traitement spécial
nécessaire.
12Pièces jointes du bot
Le bot enrichit ses réponses avec des attachments. Chacun
est un objet attachment Stream standard avec un champ type —
affichez-les selon votre design.
| Type | Contenu | UI suggérée |
|---|---|---|
image |
Aperçu OpenStreetMap de la position du véhicule | Image inline, tap → ouvre Maps via title_link |
url |
Lien vers Google Maps | Bouton avec title + tap = ouvre title_link |
Quick replies (champ custom)
Les prompts de relance arrivent dans un champ custom du message
message.extraData.quick_replies. Affichez-les comme des
chips sous la bulle ; au tap, envoyez la value comme
nouveau message.
{
"quick_replies": [
{ "label": "🚀 السرعة", "value": "قداش السرعة" },
{ "label": "📊 الحالة", "value": "الحالة" },
{ "label": "⏱ وقتاش خرجت", "value": "وقتاش خرجت" }
]
}
13Gestion des erreurs
| Scénario | Symptôme | Solution |
|---|---|---|
| Token expiré (24h) | connectUser renvoie une erreur d'auth |
Redemander un token via ?action=token |
| Le canal n'existe pas | 404 sur la requête de canal | Appeler create() avec le bot comme membre |
| Le bot ne répond pas | Message envoyé mais aucune réponse | Vérifier l'URL du webhook backend + dispo de l'AI Engine |
| Upload audio trop volumineux | HTTP 413 | Réduire l'enregistrement à < 60s, encoder AAC mono 16kHz |