From 969d6a4e7cce1a8a2e3220866b9758d88a262426 Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:08:29 +0300 Subject: [PATCH 1/9] Add files via upload Update README.md with other services especially how to setup a flask server for running voice applications. In addition, how to setup docker to run all these services together. --- DOCKER_VOICE_SETUP.md | 180 +++++++++++++++++++++++++++ README.md | 180 ++++++++++++++++++++++++--- docker-compose.yml | 26 +++- setup_voice_server.md | 131 ++++++++++++++++++++ voice_callback_server.py | 261 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 763 insertions(+), 15 deletions(-) create mode 100644 DOCKER_VOICE_SETUP.md create mode 100644 setup_voice_server.md create mode 100644 voice_callback_server.py diff --git a/DOCKER_VOICE_SETUP.md b/DOCKER_VOICE_SETUP.md new file mode 100644 index 0000000..518a455 --- /dev/null +++ b/DOCKER_VOICE_SETUP.md @@ -0,0 +1,180 @@ +# Docker Setup for Voice Functionality + +## Overview + +The Docker setup has been updated to support the new voice functionality with Africa's Talking. This includes a dedicated voice callback server that handles text-to-speech and audio playback features. + +## Services + +### 1. Ollama Service (`ollama`) +- **Container**: `ollama-server` +- **Port**: `11434` +- **Purpose**: Provides the LLM backend for function calling + +### 2. Voice Callback Server (`voice-server`) +- **Container**: `voice-callback-server` +- **Port**: `5001` +- **Purpose**: Handles voice callback requests from Africa's Talking API +- **Features**: + - Text-to-speech message storage and serving + - Audio file playback coordination + - Health check endpoint + - CORS support for cross-origin requests + +### 3. Gradio App (`app`) +- **Container**: `gradio-app` +- **Port**: `7860` +- **Purpose**: Main web interface for the application +- **Dependencies**: Requires both `ollama` and `voice-server` to be running + +## Environment Variables + +Create a `.env` file in the project root with the following variables: + +```env +# Africa's Talking API credentials +AT_USERNAME=your_username +AT_API_KEY=your_api_key + +# Langtrace API key (optional) +LANGTRACE_API_KEY=your_langtrace_key + +# Groq API key (optional) +GROQ_API_KEY=your_groq_key + +# Voice callback URL (automatically set for Docker) +VOICE_CALLBACK_URL=http://voice-server:5001 +``` + +## Running the Services + +### Development Mode +```bash +# Start all services +docker-compose up --build + +# Start specific service +docker-compose up --build voice-server +docker-compose up --build app + +# View logs +docker-compose logs -f voice-server +docker-compose logs -f app +``` + +### Production Mode +```bash +# Start in detached mode +docker-compose up -d --build + +# Check service health +docker-compose ps + +# Scale voice server if needed +docker-compose up -d --scale voice-server=2 +``` + +## Networking + +- **Internal Network**: All services communicate via Docker's internal network +- **External Access**: + - Gradio App: `http://localhost:7860` + - Voice Server: `http://localhost:5001` + - Ollama: `http://localhost:11434` + +## Voice Callback Setup + +### For Development (with ngrok) +1. Start the services: `docker-compose up --build` +2. Install ngrok: `brew install ngrok` +3. Expose the voice server: `ngrok http 5001` +4. Update the environment variable with the ngrok URL: + ```bash + export VOICE_CALLBACK_URL="https://abc123.ngrok.io" + ``` +5. Restart the app service: + ```bash + docker-compose restart app + ``` + +### For Production +1. Deploy to a cloud provider with a proper domain +2. Set up HTTPS with a valid SSL certificate +3. Configure the `VOICE_CALLBACK_URL` environment variable to your domain: + ```env + VOICE_CALLBACK_URL=https://your-domain.com + ``` + +## Health Checks + +The voice callback server includes health checks: +- **Endpoint**: `GET /health` +- **Interval**: Every 30 seconds +- **Timeout**: 10 seconds +- **Retries**: 3 attempts + +## Troubleshooting + +### Voice Server Not Starting +```bash +# Check logs +docker-compose logs voice-server + +# Restart service +docker-compose restart voice-server + +# Rebuild if needed +docker-compose up --build voice-server +``` + +### Callback Not Received +1. Verify the voice server is accessible: + ```bash + curl http://localhost:5001/health + ``` +2. Check if the callback URL is correctly set: + ```bash + docker-compose exec app env | grep VOICE_CALLBACK_URL + ``` +3. Ensure ngrok tunnel is active (for development) + +### Port Conflicts +If ports are already in use, modify the `docker-compose.yml`: +```yaml +ports: + - "5002:5001" # Map to different external port +``` + +## File Structure + +``` +. +├── docker-compose.yml # Main Docker Compose configuration +├── Dockerfile.app # Dockerfile for Gradio app +├── Dockerfile.voice # Dockerfile for voice callback server +├── Dockerfile.ollama # Dockerfile for Ollama service +├── voice_callback_server.py # Voice callback server implementation +├── app.py # Main Gradio application +└── utils/ + ├── communication_apis.py # Africa's Talking API functions + └── function_call.py # Function calling logic +``` + +## Security Considerations + +- API keys are passed as environment variables (never hardcoded) +- Voice server runs with restricted privileges +- Health checks ensure service availability +- CORS is properly configured for the voice server +- Phone numbers are masked in logs for privacy + +## Scaling + +To handle multiple concurrent voice calls: +```bash +# Scale the voice server +docker-compose up -d --scale voice-server=3 + +# Use a load balancer in production +# Configure Africa's Talking webhook to distribute load +``` diff --git a/README.md b/README.md index 0dbdba8..86d8526 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ -[![Agent Continuous Integration/Continuous Delivery](https://github.com/Shuyib/tool_calling_api/actions/workflows/python-app.yml/badge.svg)](https://github.com/Shuyib/tool_calling_api/blob/main/.github/workflows/python-app.yml) +[![Agent Continuous Integration/Continuos Delivery](https://github.com/Shuyib/tool_calling_api/actions/workflows/python-app.yml/badge.svg)](https://github.com/Shuyib/tool_calling_api/blob/main/.github/workflows/python-app.yml) # Exploring function calling 🗣️ 🤖 🔉 with Python and ollama 🦙 -Function-calling with Python and ollama. We are going to use the Africa's Talking API to send airtime and messages to a phone number using Natural language. Thus, creating an generative ai agent. Here are examples of prompts you can use to send airtime to a phone number: +Function-calling with Python and ollama. We are going to use the Africa's Talking API to send airtime and messages to a phone number using Natural language. Thus, creating an generative ai agent. + +## Available Services +✅ **Currently Working**: SMS/Messages, Airtime, Voice Calls with Text-to-Speech** +⚠️ **Requires Approval**: Voice Calls (production account), Mobile Data (business approval) + +Here are examples of prompts you can use: - Send airtime to xxxxxxxxx2046 and xxxxxxxxx3524 with an amount of 10 in currency KES - Send a message to xxxxxxxxx2046 and xxxxxxxxx3524 with a message "Hello, how are you?", using the username "username". +- Dial a USSD code like *123# on xxxxxxxxx2046 *(requires approval)* +- Send 500MB of data to xxxxxxxxx2046 on provider safaricom *(requires business approval)* +- Call xxxxxxxxx2046 from +254700000001 *(requires production account with registered caller ID)* +- **NEW**: Make a voice call from +254700000001 to +254712345678 and say "Hello, this is a test message" *(with callback server setup)* NB: The phone numbers are placeholders for the actual phone numbers. You need some VRAM to run this project. You can get VRAM from [here](https://vast.ai/) or [here](https://runpod.io?ref=46wgtjpg) @@ -19,7 +29,9 @@ Learn more about tool calling - [Attribution](#atrribution) - [Installation](#installation) - [Run in Docker](#run-in-docker) +- [Voice Functionality Setup](#voice-functionality-setup) - [Usage](#usage) +- [Africa's Talking Service Status](#africas-talking-service-status-🚦) - [Use cases](#use-cases) - [Responsible AI Practices](#responsible-ai-practices) - [Limitations](#limitations) @@ -31,8 +43,10 @@ Learn more about tool calling . ├── Dockerfile.app - template to run the gradio dashboard. ├── Dockerfile.ollama - template to run the ollama server. -├── docker-compose.yml - use the ollama project and gradio dashboard. +├── Dockerfile.voice - template to run the voice callback server. +├── docker-compose.yml - use the ollama project, gradio dashboard, and voice server. ├── docker-compose-codecarbon.yml - use the codecarbon project, ollama and gradio dashboard. +├── DOCKER_VOICE_SETUP.md - Comprehensive guide for Docker voice functionality setup. ├── .env - This file contains the environment variables for the project. (Not included in the repository) ├── app.py - the function_call.py using gradio as the User Interface. ├── Makefile - This file contains the commands to run the project. @@ -40,6 +54,8 @@ Learn more about tool calling ├── requirements.txt - This file contains the dependencies for the project. ├── requirements-dev.txt - This filee contains the dependecies for the devcontainer referencing `requirements.txt` ├── summary.png - How function calling works with a diagram. +├── setup_voice_server.md - Step-by-step guide for setting up voice callbacks with text-to-speech. +├── voice_callback_server.py - Flask server that handles voice callbacks for custom text-to-speech messages. ├── tests - This directory contains the test files for the project. │ ├── __init__.py - This file initializes the tests directory as a package. │ ├── test_cases.py - This file contains the test cases for the project. @@ -117,6 +133,7 @@ python ../app.py ## Run in Docker To run the project in Docker, follow the steps below: +### Standard Setup NB: You'll need to have deployed ollama elsewhere as an example [here](https://vast.ai/) or [here](https://runpod.io/). Make edits to the app.py file to point to the ollama server. You can use the OpenAI SDK to interact with the ollama server. An example can be found [here](https://github.com/pooyahrtn/RunpodOllama). - Linting dockerfile @@ -131,6 +148,42 @@ make docker_run_test make docker_run ``` +### Voice Functionality Setup +For the new voice features (text-to-speech and audio playback), additional setup is required: + +1. **Basic Docker Setup**: Follow the standard setup above +2. **Voice Server Configuration**: The updated Docker setup includes a dedicated voice callback server +3. **External Access**: For production use, set up ngrok or deploy with proper domain + +See [DOCKER_VOICE_SETUP.md](DOCKER_VOICE_SETUP.md) for detailed voice functionality setup instructions. + +**Quick Voice Setup for Development:** +```bash +# Start all services including voice server +docker-compose up --build + +# In a separate terminal, expose voice server with ngrok +ngrok http 5001 + +# Update environment variable with ngrok URL +export VOICE_CALLBACK_URL="https://your-ngrok-url.ngrok.io" + +# Restart the app to use new callback URL +docker-compose restart app +``` + +- Linting dockerfile + +```bash +make docker_run_test +``` + +- Build and run the Docker image + +```bash +make docker_run +``` + Notes: - The .env file contains the environment variables for the project. You can create a .env file and add the following environment variables: @@ -139,6 +192,7 @@ echo "AT_API_KEY = yourapikey" >> .env echo "AT_USERNAME = yourusername" >> .env echo "GROQ_API_KEY = yourgroqapikey" >> .env echo "LANGTRACE_API_KEY= yourlangtraceapikey" >> .env +echo "VOICE_CALLBACK_URL = https://your-ngrok-url.ngrok.io" >> .env echo "TEST_PHONE_NUMBER = yourphonenumber" >> .env echo "TEST_PHONE_NUMBER_2 = yourphonenumber" >> .env echo "TEST_PHONE_NUMBER_3 = yourphonenumber" >> .env @@ -172,6 +226,7 @@ export AT_API_KEY=yourapikey export AT_USERNAME=yourusername export GROQ_API_KEY=yourgroqapikey export LANGTRACE_API_KEY=yourlangtraceapikey +export VOICE_CALLBACK_URL=https://your-ngrok-url.ngrok.io export TEST_PHONE_NUMBER=yourphonenumber export TEST_PHONE_NUMBER_2=yourphonenumber export TEST_PHONE_NUMBER_3=yourphonenumber @@ -193,15 +248,102 @@ This project uses LLMs to send airtime to a phone number. The difference is that - An autogen agent has been added to assist with generating translations to other languages. Note that this uses an evaluator-optimizer model and may not always provide accurate translations. However, this paradigm can be used for code generation, summarization, and other tasks. - Text-to-Speech (TTS) has been added to the app. You can listen to the output of the commands. -### Responsible AI Practices -This project implements several responsible AI practices: -- All test data is anonymized to protect privacy. -- Input validation to prevent misuse (negative amounts, spam detection). -- Handling of sensitive content and edge cases. -- Comprehensive test coverage for various scenarios. -- Secure handling of credentials and personal information. - -![Process Summary](summary.png) +### NEW: Voice Calls with Custom Text-to-Speech 🔊 +The app now supports making voice calls that speak custom messages instead of the default Africa's Talking greeting. This provides a more personalized experience for the call recipient. + +**How it Works:** + +1. **Initiate the Call**: When you use a command like: + `"Make a voice call from +254700000001 to +254712345678 and say 'Hello, this is a test message from our new system!'"`, + the `make_voice_call_with_text` function in `utils/communication_apis.py` is triggered. + +2. **Message Storage**: + * This function first generates a unique session ID for the call. + * It then makes an HTTP POST request to a local Flask server (`voice_callback_server.py`) to store the message ("Hello, this is a test message...") and the chosen voice type (e.g., "woman") associated with this session ID. + * The `VOICE_CALLBACK_URL` environment variable (defaulting to `http://localhost:5001` if not set) tells `make_voice_call_with_text` where to send this information (e.g., `http://localhost:5001/voice/store`). + +3. **Africa's Talking Call Placement**: + * `make_voice_call_with_text` then instructs the Africa's Talking API to place the call. + * Crucially, your Africa's Talking account **must be configured with a public callback URL** for voice services. This is where the Africa's Talking platform will send a request when the call is answered. + +4. **Fetching the Custom Message (Callback)**: + * When the recipient answers, the Africa's Talking platform makes an HTTP GET request to your configured public callback URL (e.g., `https://your-unique-ngrok-id.ngrok.io/voice/callback?sessionId=xxx...`). + * This public URL should be an ngrok (or similar tunneling service) endpoint that forwards the request to your local `voice_callback_server.py` running on port 5001 (or the port you've configured). + * The `voice_callback_server.py` (specifically its `/voice/callback` endpoint) receives this request, extracts the `sessionId`, retrieves the stored message and voice type for that session, and dynamically generates an XML response. + +5. **Text-to-Speech**: + * The XML response tells Africa's Talking to use its Text-to-Speech engine to say your custom message to the recipient in the specified voice. + ```xml + + Hello, this is a test message from our new system! + + ``` + +**Setup Requirements**: + +1. **Run the Local Callback Server**: + Start the Flask server: + ```bash + python voice_callback_server.py + ``` + This server typically runs on `http://localhost:5001`. + +2. **Expose the Local Server Publicly**: + Use ngrok (or a similar service) to create a public URL that tunnels to your local server: + ```bash + ngrok http 5001 + ``` + Ngrok will provide you with a public HTTPS URL (e.g., `https://.ngrok.io`). + +3. **Configure Africa's Talking Dashboard**: + * Log in to your Africa's Talking account. + * Go to Voice > Settings (or similar section for callback URLs). + * Set your **Voice Callback URL** to the public ngrok URL, ensuring it points to the correct endpoint, typically `/voice/callback`. For example: `https://.ngrok.io/voice/callback`. + * **Important**: The `make_voice_call_with_text` function itself *does not* send this public callback URL to Africa's Talking when placing the call; it relies on your dashboard configuration. + +4. **Environment Variable (Optional but Recommended)**: + While `make_voice_call_with_text` defaults to `http://localhost:5001` for *storing* the message locally, you can set the `VOICE_CALLBACK_URL` environment variable if your `voice_callback_server.py` runs on a different local address or port. This variable is for the *internal* communication between `communication_apis.py` and `voice_callback_server.py`, not for the Africa's Talking platform callback. + Example for `.env` file: + ``` + VOICE_CALLBACK_URL=http://localhost:5001 + ``` + +**Using the Feature**: +Once set up, you can use natural language commands like: +`"Call +254712345678 from my Africa's Talking number +254700000000 and tell them 'Your package has arrived.' using a male voice."` + +The system will handle the rest, ensuring your custom message is played. + +##### NEW: Voice Calls with Custom Text-to-Speech ✨ +- **Status**: Fully functional with callback server setup +- **Features**: + - Speak custom messages instead of default Africa's Talking greeting + - Support for both "man" and "woman" voice types + - Real-time message storage and retrieval via callback system +- **Setup**: Requires `voice_callback_server.py` to be running, ngrok (or similar) for a public callback URL, and correct configuration in the Africa's Talking dashboard. See detailed instructions above and in [setup_voice_server.md](setup_voice_server.md). +- **Note**: This works even with test credentials for the voice call initiation part, as long as the callback mechanism is correctly configured. + +#### Airtime Distribution 💰 +- **Status**: Limited sandbox functionality - requires production credentials for full operation +- **Issue**: Airtime connector only works with production applications, not sandbox API keys +- **Requirements**: + 1. Production application with valid API credentials + 2. Manual activation by emailing airtime@africastalking.com + 3. Testing limited to Africa's Talking simulator (not real devices) +- **Note**: While airtime functions exist in sandbox, actual distribution requires production setup + +#### Mobile Data Bundles 📱 +- **Status**: Requires formal business approval +- **Issue**: Service disabled for all accounts for security reasons +- **Approval Process**: + 1. Write formal letter on company letterhead (stamped & signed by management) + 2. Fill out Africa's Talking contact form + 3. Sign service agreement + 4. Submit documentation to airtime@africastalking.com +- **Reference**: [Mobile Data Activation Guide](https://help.africastalking.com/en/articles/8287530-how-do-i-activate-mobile-data-on-my-account) + +### Technical Implementation Status 🔧 +All API integrations are **technically correct** and ready for production use once account approvals are obtained. The code implements proper parameter handling and error management for all services. ## Use cases * Non-Technical User Interfaces: Simplifies the process for non-coders to interact with APIs, making it easier for them to send airtime and messages without needing to understand the underlying code. @@ -212,10 +354,20 @@ This project implements several responsible AI practices: * Multilingual Support: Supports multiple languages when sending messages and airtime, making it accessible to a diverse range of users. Testing for Arabic, French, English and Portuguese. ## Limitations -- The project is limited to sending airtime, searching for news, and messages using the Africa's Talking API. The functionality can be expanded to include other APIs and services. -- The jailbreaking of the LLMS is a limitation. The LLMS are not perfect and can be manipulated to produce harmful outputs. This can be mitigated by using a secure environment and monitoring the outputs for any malicious content. However, the Best of N technique and prefix injection were effective in changing model behavior. +### Africa's Talking API Limitations +- **Voice Calls**: Requires production account with registered caller ID numbers. Test credentials will return "Invalid callerId" errors. +- **Airtime Distribution**: While sandbox testing is possible, actual airtime distribution requires production credentials and manual activation. Test accounts have limited functionality. +- **Mobile Data**: Requires formal business approval process including company documentation and service agreements. Currently disabled for all accounts for security reasons. +- **SMS**: Fully functional with both test and production credentials. + +### Technical Limitations +- The project is primarily designed for Africa's Talking API services. While the functionality can be expanded to include other APIs and services, current implementation focuses on communication services. + +### LLM Security Considerations +- The jailbreaking of the LLMs is a limitation. The LLMs are not perfect and can be manipulated to produce harmful outputs. This can be mitigated by using a secure environment and monitoring the outputs for any malicious content. However, the Best of N technique and prefix injection were effective in changing model behavior. +### Testing Coverage - A small number of test cases were used to test the project. More test cases can be added to cover a wider range of scenarios and edge cases. ## Contributing diff --git a/docker-compose.yml b/docker-compose.yml index 26e447a..9dc8a2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,28 @@ services: volumes: - ollama_models:/models + # Voice callback server service + voice-server: + # Build the voice server image from the Dockerfile.voice + build: + context: . + dockerfile: Dockerfile.voice + container_name: voice-callback-server + # Environment variables for the voice callback server + environment: + - AT_USERNAME=${AT_USERNAME} + - AT_API_KEY=${AT_API_KEY} + # Expose port 5001 for the voice callback server + ports: + - "5001:5001" + # Add health check + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + # Gradio app service app: # Build the Gradio app image from the Dockerfile.app @@ -34,12 +56,14 @@ services: - LANGTRACE_API_KEY=${LANGTRACE_API_KEY} - GROQ_API_KEY=${GROQ_API_KEY} - OLLAMA_HOST=http://ollama:11434 + - VOICE_CALLBACK_URL=${VOICE_CALLBACK_URL:-http://voice-server:5001} # Expose port 7860 for the Gradio web interface ports: - "7860:7860" - # Ensure the Ollama service is started before the Gradio app + # Ensure the Ollama service and voice server are started before the Gradio app depends_on: - ollama + - voice-server # Define a volume for model persistence volumes: diff --git a/setup_voice_server.md b/setup_voice_server.md new file mode 100644 index 0000000..06c271d --- /dev/null +++ b/setup_voice_server.md @@ -0,0 +1,131 @@ +# Voice Server Setup Guide + +This guide explains how to set up the voice callback server for text-to-speech functionality with Africa's Talking. + +## Prerequisites + +1. Install dependencies: +```bash +pip install -r requirements.txt +``` + +2. Install ngrok (if not already installed): +```bash +# macOS with Homebrew +brew install ngrok + +# Or download from https://ngrok.com/download +``` + +## Setup Steps + +### 1. Start the Voice Callback Server + +```bash +python voice_callback_server.py +``` + +The server will start on `http://localhost:5000` + +### 2. Expose Server with ngrok + +In a new terminal, run: +```bash +ngrok http 5000 +``` + +You'll see output like: +``` +Forwarding https://abc123.ngrok.io -> http://localhost:5000 +``` + +Copy the HTTPS URL (e.g., `https://abc123.ngrok.io`) + +### 3. Configure Environment Variable + +Set the callback URL environment variable: +```bash +export VOICE_CALLBACK_URL="https://abc123.ngrok.io" +``` + +Or add it to your `.env` file: +``` +VOICE_CALLBACK_URL=https://abc123.ngrok.io +``` + +### 4. Configure Africa's Talking Dashboard (Optional) + +1. Log in to your Africa's Talking dashboard +2. Go to Voice settings +3. Set the callback URL to: `https://abc123.ngrok.io/voice/callback` + +### 5. Test the Setup + +Now when you use `make_voice_call_with_text`, it should: +1. Store the message in the callback server +2. Make the voice call with the callback URL +3. When Africa's Talking calls back, serve the XML with your message + +## Usage Example + +```python +from utils.communication_apis import make_voice_call_with_text + +# This will now speak your custom message instead of the default greeting +result = make_voice_call_with_text( + from_number="+254700000001", + to_number="+254712345678", + message="Hello, this is a test message", + voice_type="woman" +) +``` + +## Debugging + +### Check server status: +```bash +curl http://localhost:5000/health +``` + +### List stored messages: +```bash +curl http://localhost:5000/voice/messages +``` + +### Test storing a message: +```bash +curl -X POST http://localhost:5000/voice/store \ + -H "Content-Type: application/json" \ + -d '{ + "session_id": "test123", + "to_number": "+254712345678", + "message": "Test message", + "voice_type": "woman" + }' +``` + +## Production Deployment + +For production, instead of ngrok: + +1. Deploy the voice server to a cloud provider (AWS, GCP, etc.) +2. Use a proper domain with HTTPS +3. Set up proper logging and monitoring +4. Use Redis or a database instead of in-memory storage + +## Troubleshooting + +**Issue**: Still hearing default Africa's Talking message +- Ensure ngrok is running and accessible +- Check that `VOICE_CALLBACK_URL` is set correctly +- Verify the callback server is receiving requests (check logs) +- Make sure the callback URL is accessible from the internet + +**Issue**: Voice server not starting +- Check that port 5000 is available +- Install missing dependencies: `pip install flask flask-cors` + +**Issue**: Callback not received +- Verify ngrok tunnel is active +- Check Africa's Talking dashboard callback URL settings +- Ensure firewall allows incoming connections \ No newline at end of file diff --git a/voice_callback_server.py b/voice_callback_server.py new file mode 100644 index 0000000..100a039 --- /dev/null +++ b/voice_callback_server.py @@ -0,0 +1,261 @@ +""" +Voice Callback Server for Africa's Talking Voice API + +This Flask server handles voice callback requests from Africa's Talking +and serves the appropriate XML responses for text-to-speech functionality. + +Usage: + 1. Start this server: python voice_callback_server.py + 2. Expose it via ngrok: ngrok http 5000 + 3. Configure the ngrok URL in your Africa's Talking dashboard + 4. Use the make_voice_call_with_text function with callback URL + +The server stores voice messages temporarily and serves them via XML responses. +""" + +import os +import logging +from flask import Flask, request, Response +from flask_cors import CORS +import json +from datetime import datetime, timedelta +from typing import Dict, Optional + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize Flask app +app = Flask(__name__) +CORS(app) + +# In-memory storage for voice messages (in production, use Redis or database) +voice_messages: Dict[str, dict] = {} +# In-memory storage for audio play info (session_id -> audio_url) +audio_play_info: Dict[str, dict] = {} + +# Cleanup old messages every hour +CLEANUP_INTERVAL = timedelta(hours=1) + + +def cleanup_old_messages(): + """Remove voice messages older than 1 hour.""" + current_time = datetime.now() + expired_keys = [ + key + for key, data in voice_messages.items() + if current_time - data["created_at"] > CLEANUP_INTERVAL + ] + for key in expired_keys: + del voice_messages[key] + # Also cleanup old audio play info + expired_audio = [ + key + for key, data in audio_play_info.items() + if current_time - data.get("created_at", current_time) > CLEANUP_INTERVAL + ] + for key in expired_audio: + del audio_play_info[key] + logger.info(f"Cleaned up {len(expired_keys)} expired voice messages") + logger.info(f"Cleaned up {len(expired_audio)} expired audio play info entries") + + +@app.route("/health", methods=["GET"]) +def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "timestamp": datetime.now().isoformat()} + + +@app.route("/voice/callback", methods=["POST"]) +def voice_callback(): + """ + Handle voice callback from Africa's Talking. + + This endpoint receives the voice call event and returns the appropriate + XML response for text-to-speech. + """ + try: + # Log the incoming request + logger.info("Received voice callback") + logger.info("Headers: %s", dict(request.headers)) + logger.info("Form data: %s", dict(request.form)) + + # Get call details from Africa's Talking + caller_number = request.form.get("callerNumber", "") + session_id = request.form.get("sessionId", "") + is_active = request.form.get("isActive", "0") + # If we have audio to play for this session, serve + if session_id and session_id in audio_play_info: + audio_data = audio_play_info.pop(session_id) + audio_url = audio_data.get("audio_url") + logger.info(f"Serving audio for session {session_id}: {audio_url}") + xml_response = f""" + + {audio_url} +""" + return Response(xml_response, mimetype="application/xml") + + logger.info( + f"Voice callback - Caller: {caller_number}, Session: {session_id}, Active: {is_active}" + ) + + # Look for a stored message for this session or caller + message_data = None + + # First try to find by session ID + if session_id and session_id in voice_messages: + message_data = voice_messages[session_id] + # Then try to find by caller number (fallback) + elif caller_number: + for key, data in voice_messages.items(): + if data.get("to_number") == caller_number: + message_data = data + break + + if message_data: + message = message_data["message"] + voice_type = message_data.get("voice_type", "woman") + logger.info(f"Found message for session {session_id}: {message[:50]}...") + + # Create XML response + xml_response = f""" + + {message} +""" + + # Clean up the message after use + if session_id in voice_messages: + del voice_messages[session_id] + + return Response(xml_response, mimetype="application/xml") + else: + # Default response if no message found + logger.warning( + f"No message found for session {session_id} or caller {caller_number}" + ) + xml_response = """ + + Hello, this is a test message from Africa's Talking +""" + return Response(xml_response, mimetype="application/xml") + + except Exception as e: + logger.error(f"Error in voice callback: {str(e)}") + # Return a safe default response + xml_response = """ + + Sorry, there was an error processing your call +""" + return Response(xml_response, mimetype="application/xml") + + +@app.route("/voice/store", methods=["POST"]) +def store_voice_message(): + """ + Store a voice message for later retrieval during callback. + + Expected JSON payload: + { + "session_id": "unique_session_id", + "to_number": "+254712345678", + "message": "Hello, this is a test message", + "voice_type": "woman" + } + """ + try: + data = request.get_json() + if not data: + return {"error": "No JSON data provided"}, 400 + + session_id = data.get("session_id") + to_number = data.get("to_number") + message = data.get("message") + voice_type = data.get("voice_type", "woman") + + if not all([session_id, to_number, message]): + return {"error": "session_id, to_number, and message are required"}, 400 + + # Store the message + voice_messages[session_id] = { + "to_number": to_number, + "message": message, + "voice_type": voice_type, + "created_at": datetime.now(), + } + + logger.info(f"Stored voice message for session {session_id}: {message[:50]}...") + + # Cleanup old messages + cleanup_old_messages() + + return {"success": True, "session_id": session_id} + + except Exception as e: + logger.error(f"Error storing voice message: {str(e)}") + return {"error": str(e)}, 500 + + +@app.route("/voice/store_play_info", methods=["POST"]) +def store_play_info(): + """ + Store audio URL for playback during callback. + Expected JSON: {"session_id": str, "audio_url": str} + """ + try: + data = request.get_json() + session_id = data.get("session_id") + audio_url = data.get("audio_url") + if not session_id or not audio_url: + return {"error": "session_id and audio_url are required"}, 400 + # Store the audio info + audio_play_info[session_id] = { + "audio_url": audio_url, + "created_at": datetime.now(), + } + logger.info(f"Stored audio play info for session {session_id}: {audio_url}") + cleanup_old_messages() + return {"success": True, "session_id": session_id} + except Exception as e: + logger.error(f"Error storing play info: {str(e)}") + return {"error": str(e)}, 500 + + +@app.route("/voice/messages", methods=["GET"]) +def list_voice_messages(): + """List all stored voice messages (for debugging).""" + return { + "count": len(voice_messages), + "messages": { + k: { + "to_number": v["to_number"], + "message": ( + v["message"][:50] + "..." + if len(v["message"]) > 50 + else v["message"] + ), + "voice_type": v["voice_type"], + "created_at": v["created_at"].isoformat(), + } + for k, v in voice_messages.items() + }, + } + + +@app.route("/", methods=["GET"]) +def index(): + return { + "status": "ok", + "message": "Africa's Talking Voice Callback Server is running.", + "endpoints": ["/health", "/voice/callback", "/voice/store", "/voice/messages"], + } + + +if __name__ == "__main__": + logger.info("Starting Voice Callback Server...") + logger.info("Make sure to:") + logger.info("1. Expose this server via ngrok: ngrok http 5000") + logger.info("2. Configure the ngrok URL in Africa's Talking dashboard") + logger.info("3. Set the callback URL in your voice calls") + + # Run Flask server + app.run(host="0.0.0.0", port=5001, debug=False) # Set to True for development From ea80c32a77a06a3dafe69870d596a867a8585db3 Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:10:11 +0300 Subject: [PATCH 2/9] Add files via upload Update utils to have other products which are used in the function calling schema. --- utils/communication_apis.py | 943 +++++++++++++++++++++++++++++++++++- utils/function_call.py | 904 ++++++++++++++++++++++++++++++++-- 2 files changed, 1796 insertions(+), 51 deletions(-) diff --git a/utils/communication_apis.py b/utils/communication_apis.py index 5ce1368..48fd262 100644 --- a/utils/communication_apis.py +++ b/utils/communication_apis.py @@ -18,12 +18,32 @@ 'discount': 'KES 0.4000', 'errorMessage': 'None', 'phoneNumber': 'xxxxxxxx2046', 'requestId': 'ATQid_xxxx', 'status': 'Sent'}], 'totalAmount': 'KES 10.0000', 'totalDiscount': 'KES 0.4000'} + +Mobile data response +'{"entries": [{"phoneNumber": "+254728303524", "provider": "Safaricom", "status": + "Queued", "transactionId": "ATPid_xxxx", "value": "KES 15.0000"}]}' + +Make a voice call +'{"entries": [{"phoneNumber": "+254728303524", "sessionId": "ATVId_xxxx", "status": + "Queued"}], "errorMessage": "None"}' + +Make a voice call with text +{'entries': [{'phoneNumber': '+254728303524', 'sessionId': 'ATVId_xxxx', +'status': 'Queued'}], 'errorMessage': 'None', +'xml_response': '\n\n +Hello, this is a test message\n', +'session_id': '1dbd2e4e-20be-4971-9455-dfed5fe5552c', 'callback_url': +'https://80c4-165-73-248-94.ngrok-free.app/voice/callback'} """ import os +import json +import requests import logging from importlib.metadata import version import africastalking +from pydantic import BaseModel, field_validator +from typing import Union, List, Dict, Any, Optional # set up the logger @@ -45,11 +65,99 @@ logger.addHandler(file_handler) logger.addHandler(stream_handler) + +class SendMobileDataRequest(BaseModel): + phone_number: str + bundle: str + provider: Optional[str] = None + plan: Optional[str] = None + + @field_validator("phone_number") + @classmethod + def validate_phone_number(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + @field_validator("bundle") + @classmethod + def validate_bundle(cls, v): + if not v: + raise ValueError("Bundle amount is required") + return v + + +class SendUSSDRequest(BaseModel): + phone_number: str + code: str + + @field_validator("phone_number") + @classmethod + def validate_phone_number(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + +class MakeVoiceCallRequest(BaseModel): + from_number: str + to_number: str + + @field_validator("from_number", "to_number") + @classmethod + def validate_phone_numbers(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + +class MakeVoiceCallWithTextRequest(BaseModel): + from_number: str + to_number: str + message: str + voice: Optional[str] = "woman" + + @field_validator("from_number", "to_number") + @classmethod + def validate_phone_numbers(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + @field_validator("voice") + @classmethod + def validate_voice(cls, v): + if v not in ["man", "woman"]: + raise ValueError("Voice must be either 'man' or 'woman'") + return v + + +class MakeVoiceCallAndPlayAudioRequest(BaseModel): + from_number: str + to_number: str + audio_url: str + + @field_validator("from_number", "to_number") + @classmethod + def validate_phone_numbers(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + @field_validator("audio_url") + @classmethod + def validate_audio_url(cls, v): + # Basic URL validation, can be expanded + if not v.startswith("http://") and not v.startswith("https://"): + raise ValueError("audio_url must be a valid HTTP/HTTPS URL") + return v + + # log the start of the script -logger.info("Starting the airtime script") +logger.info("Starting the communication api script") # log versions of the libraries -pkgs = ["africastalking"] +pkgs = ["africastalking", "requests", "pydantic", "importlib-metadata"] # see the versions of the libraries for pkg in pkgs: @@ -107,7 +215,7 @@ def mask_api_key(api_key): return "x" * (len(api_key) - 4) + api_key[-4:] -def send_airtime(phone_number: str, currency_code: str, amount: str) -> None: +def send_airtime(phone_number: str, currency_code: str, amount: str) -> str: """Allows you to send airtime to a phone number. Parameters @@ -124,10 +232,10 @@ def send_airtime(phone_number: str, currency_code: str, amount: str) -> None: The amount of airtime to send. It should be a string. eg. "10" That means you'll send airtime worth 10 currency units. - Returns ------- - None + str + JSON response from the API Examples -------- @@ -155,11 +263,13 @@ def send_airtime(phone_number: str, currency_code: str, amount: str) -> None: phone_number=phone_number, amount=amount, currency_code=currency_code ) logger.info("The response is %s", responses) + return json.dumps(responses) except Exception as e: logger.error("Encountered an error while sending airtime: %s", str(e)) + return json.dumps({"error": str(e)}) -def send_message(phone_number: str, message: str, username: str) -> None: +def send_message(phone_number: str, message: str, username: str) -> str: """Allows you to send a message to a phone number. Parameters @@ -178,10 +288,10 @@ def send_message(phone_number: str, message: str, username: str) -> None: this is the username you used to sign up for the Africa's Talking account. - Returns ------- - None + str + JSON response from the API Examples -------- @@ -206,13 +316,827 @@ def send_message(phone_number: str, message: str, username: str) -> None: logger.info("Sending message to %s", masked_number) logger.info("Message: %s", message) - # try-except block to catch any errors try: # Send message response = SMS.send(message, [phone_number]) logger.info("The response is %s", response) + return json.dumps(response) except Exception as e: logger.error("Encountered an error while sending message: %s", str(e)) + return json.dumps({"error": str(e)}) + + +def send_ussd(phone_number: str, code: str) -> str: + """Send a USSD code to a phone number. + + Note: USSD typically works for interactive sessions rather than sending codes. + This function may not work as expected with the Africa\'s Talking API + for initiating outgoing USSD pushes. + Consider using USSD for handling incoming USSD sessions instead. + + Parameters + ---------- + phone_number : str + The phone number to dial the USSD code on. + code : str + The USSD code to send, e.g. ``*123#``. + + Returns + ------- + str + JSON response from the API. + + Examples + -------- + send_ussd("+254712345678", "*123#") + """ + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + + africastalking.initialize(username, api_key) + + try: + ussd_service = africastalking.USSD + except AttributeError: + ussd_service = None + + if ussd_service is None or not hasattr(ussd_service, "send"): + logger.error( + "Africa's Talking USSD service (africastalking.USSD) is None, does not exist, or lacks a 'send' method after initialization." + ) + logger.error( + "This strongly suggests the SDK may not support sending outgoing USSD codes this way, " + "or the USSD product might not be enabled/configured for your account " + "for this type of operation (e.g., Push USSD)." + ) + logger.error( + "Please verify your Africa's Talking account settings and consult their " + "official Python SDK documentation for 'Push USSD' or application-initiated USSD features." + ) + return json.dumps( + { + "error": "USSD service not available, not supported for sending outgoing codes via this SDK method, or not correctly accessed." + } + ) + + masked_number = mask_phone_number(phone_number) + logger.info("Attempting to send USSD %s to %s", code, masked_number) + logger.warning( + "USSD typically handles incoming interactive sessions. " + "Initiating outgoing USSD codes via API might have limitations or require specific AT products." + ) + + try: + response = ussd_service.send(phone_number=phone_number, text=code) + logger.info("USSD response: %s", response) + return json.dumps(response) + except AttributeError as e: + logger.error( + "AttributeError encountered while sending USSD: %s. " + "This often means the 'ussd_service' object is None or lacks the 'send' method, " + "or the arguments are incorrect.", + str(e), + ) + logger.error( + "Ensure 'africastalking.USSD' is the correct way to access the USSD push service " + "and that it's initialized. Check the official AT Python SDK documentation." + ) + return json.dumps( + {"error": f"AttributeError: {str(e)} - USSD send operation failed."} + ) + except Exception as e: + logger.error("Encountered an unexpected error while sending USSD: %s", str(e)) + logger.error( + "This could be due to network issues, invalid parameters, or API errors." + ) + return json.dumps({"error": f"API Error: {str(e)}"}) + + +def send_mobile_data_wrapper( + phone_number: str, bundle: Union[str, int], provider: str, plan: str +) -> str: + """ + Wrapper function for send_mobile_data that handles parameter conversion. + + Parameters + ---------- + phone_number : str + The recipient phone number in international format (e.g., "+254728303524") + bundle : Union[str, int] + The data bundle amount as integer MB or string with unit (e.g., 50, "100MB", "1GB") + If no unit is specified, MB is assumed + provider : str + The telecom provider (e.g., "Safaricom", "Airtel") + plan : str + The plan duration (e.g., "daily", "weekly", "monthly") + + Returns + ------- + str + JSON response from the API + + Examples + -------- + >>> send_mobile_data_wrapper("+254728303524", 50, "Safaricom", "daily") + >>> send_mobile_data_wrapper("+254712345678", "100MB", "Airtel", "weekly") + >>> send_mobile_data_wrapper("+254798765432", "1GB", "Safaricom", "monthly") + """ + try: + # Handle integer input (assumed MB) + if isinstance(bundle, (int, float)): + quantity = int(bundle) + unit = "MB" + else: + # Parse string bundle format + bundle_lower = str(bundle).lower().strip() + if "gb" in bundle_lower: + unit = "GB" + quantity = int("".join(filter(str.isdigit, bundle_lower))) + else: + # Default to MB if no unit or if MB specified + unit = "MB" + quantity = int("".join(filter(str.isdigit, bundle_lower))) + if quantity <= 0: + raise ValueError(f"Bundle quantity must be positive: {quantity}") + + # Map plan to validity period + plan_mapping = { + "daily": "Day", + "weekly": "Week", + "monthly": "Month", + "day": "Day", + "week": "Week", + "month": "Month", + } + plan_lower = plan.lower().strip() + if plan_lower not in plan_mapping: + raise ValueError( + f"Invalid plan duration: {plan}. Must be daily, weekly, or monthly." + ) + validity = plan_mapping[plan_lower] + + # Use a consistent product name format + product_name = f"{provider.strip()}_mobile_data" + + # Log the parsed parameters + logger.info( + "Parsed mobile data parameters: quantity=%s, unit=%s, validity=%s, product=%s", + quantity, + unit, + validity, + product_name, + ) + + return send_mobile_data_original( + phone_number=phone_number, + quantity=quantity, # Now passing as int + unit=unit, + validity=validity, + product_name=product_name, + ) + + except Exception as e: + error_msg = f"Error in send_mobile_data_wrapper: {str(e)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + +def get_wallet_balance() -> str: + """ + Fetch the current wallet balance from Africa's Talking account using the documented API endpoint. + """ + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + url = f"https://bundles.africastalking.com/query/wallet/balance?username={username}" + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "apiKey": api_key, + } + logger.info("Fetching wallet balance from documented endpoint") + try: + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + logger.info("Wallet balance response: %s", data) + return json.dumps(data) + except Exception as e: + logger.error("Encountered an error while fetching wallet balance: %s", str(e)) + return json.dumps({"error": str(e)}) + + +def send_mobile_data_original( + phone_number: str, + quantity: Optional[Union[int, str]], + unit: str, + validity: str, + product_name: str, +) -> str: + """ + Send mobile data to a phone number using Africa's Talking API. + + Parameters + ---------- + phone_number : str + The recipient phone number in international format (e.g., "+254728303524") + quantity : Union[int, str] + The amount of data as an integer or string (e.g., 50, "100") + Will be converted to int internally + unit : str + The data unit ("MB" or "GB") + validity : str + The validity period ("Day", "Week", "Month") + product_name : str + Your Africa's Talking app product name (e.g., "mobiledata") + + Returns + ------- + str + JSON response from the API + + Examples + -------- + >>> send_mobile_data_original("+254728303524", 50, "MB", "Month", "mobiledata") + >>> send_mobile_data_original("+254712345678", "100", "MB", "Week", "myapp") + >>> send_mobile_data_original("+254798765432", 1, "GB", "Month", "data_service") + + Notes + ------- + The Day plan has been phased out by Africa's Talking. + """ + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + + if not username or not api_key: + error_msg = "Missing AT_USERNAME or AT_API_KEY environment variables" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + + # Check wallet balance before proceeding + try: + balance_response = get_wallet_balance() + balance_data = json.loads(balance_response) + if balance_data.get("status") == "Success" and "balance" in balance_data: + balance_str = balance_data["balance"].split(" ")[1] + balance = float(balance_str) + if balance <= 0: + error_msg = f"Insufficient wallet balance: {balance}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + else: + error_msg = "Could not fetch wallet balance" + logger.error(f"{error_msg}. Response: {balance_data}") + return json.dumps({"error": error_msg}) + except Exception as e: + error_msg = f"Error checking wallet balance: {str(e)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + # Validate input parameters + if not all([phone_number, quantity, unit, validity, product_name]): + error_msg = "Missing required parameters" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + if not phone_number.startswith("+"): + error_msg = f"Invalid phone number format: {mask_phone_number(phone_number)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + if unit not in ["MB", "GB"]: + error_msg = f"Invalid unit: {unit}. Must be 'MB' or 'GB'" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + if validity not in ["Day", "Week", "Month"]: + error_msg = f"Invalid validity: {validity}. Must be 'Day', 'Week', or 'Month'" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + # Convert quantity to integer as per API requirements + try: + quantity_int = int(quantity) + if quantity_int <= 0: + raise ValueError("Quantity must be positive") + except ValueError as e: + error_msg = f"Invalid quantity value: {quantity}. Error: {str(e)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + # Always use the live endpoint + url = "https://bundles.africastalking.com/mobile/data/request" + + # Prepare recipients with required metadata + recipients = [ + { + "phoneNumber": phone_number, + "quantity": quantity_int, + "unit": unit, + "validity": validity, + "metadata": { # Always include metadata + "phoneNumber": phone_number, + "product": product_name, + "quantity": str(quantity_int), + "unit": unit, + "validity": validity, + }, + } + ] + + # Prepare the request payload + request_payload = { + "username": username, + "productName": product_name, + "recipients": recipients, + } + + # Set proper headers + headers = { + "apiKey": api_key, + "Accept": "application/json", + "Content-Type": "application/json", + } + + masked_number = mask_phone_number(phone_number) + logger.info( + f"Sending {quantity}{unit} data to {masked_number} (validity: {validity})" + ) + logger.debug(f"Request payload: {json.dumps(request_payload, indent=2)}") + logger.debug(f"Headers: {json.dumps(dict(headers), indent=2)}") + + try: + response = requests.post( + url, + json=request_payload, # Use json parameter for proper serialization + headers=headers, + timeout=10, + ) + + logger.info(f"Response status: {response.status_code}") + logger.debug(f"Response headers: {dict(response.headers)}") + + try: + response_json = response.json() + logger.info(f"Response JSON: {json.dumps(response_json, indent=2)}") + except ValueError: + logger.error(f"Non-JSON response: {response.text}") + return json.dumps({"error": f"Invalid JSON response: {response.text}"}) + + if not response.ok: + error_msg = f"API error: {response.status_code} - {response_json.get('error', 'Unknown error')}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + return json.dumps(response_json) + + except requests.exceptions.RequestException as e: + error_msg = f"Request failed: {str(e)}" + if hasattr(e, "response") and e.response is not None: + error_msg += f"\nResponse: {e.response.text}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + +def make_voice_call(from_number: str, to_number: str) -> str: + """Initiate a voice call between two numbers. + + Parameters + ---------- + from_number : str + The caller ID for the voice call. + to_number : str + The recipient of the call. + + Returns + ------- + str + JSON response from the API. + + Examples + -------- + make_voice_call("+254700000001", "+254712345678") + """ + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + + # Validate phone numbers + try: + request = MakeVoiceCallRequest(from_number=from_number, to_number=to_number) + except ValueError as e: + error_msg = f"Phone number validation failed: {str(e)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + africastalking.initialize(username, api_key) + voice = africastalking.Voice + masked_number = mask_phone_number(to_number) + logger.info("Calling %s from %s", masked_number, from_number) + + try: + # Pass to_number as a list and use consistent parameter names + response = voice.call( + callFrom=from_number, + callTo=[to_number], # Pass as list as required by AT API + ) + logger.info("Voice call response: %s", response) + return json.dumps(response) + except Exception as e: + error_msg = f"Encountered an error while making voice call: {str(e)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + +def make_voice_call_with_text( + from_number: str, to_number: str, message: str, voice_type: str = "woman" +) -> str: + """Make a voice call and say a message using text-to-speech. + + This function initiates a voice call and uses Africa\'s Talking text-to-speech + engine to read the provided message to the recipient. It relies on the + voice_callback_server.py to store the message and serve the XML. + The callback URL must be configured in the Africa\'s Talking dashboard. + + Parameters + ---------- + from_number : str + The caller ID for the voice call (must start with +) + to_number : str + The recipient phone number (must start with +) + message : str + The text message to be spoken during the call + voice_type : str, optional + The voice type to use ("man" or "woman"), defaults to "woman" + + Returns + ------- + str + JSON response from the API containing call details + + Examples + -------- + make_voice_call_with_text("+254700000001", "+254712345678", "Hello, this is a test message", "woman") + """ + import requests + import uuid + + try: + # Validate inputs + request = MakeVoiceCallWithTextRequest( + from_number=from_number, + to_number=to_number, + message=message, + voice=voice_type, + ) + + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + # Ensure this matches the port your voice_callback_server.py is running on + callback_server_url = os.getenv("VOICE_CALLBACK_URL", "http://localhost:5001") + + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + logger.info("Callback server URL for storing message: %s", callback_server_url) + + # Generate a unique session ID for this call + session_id = str(uuid.uuid4()) + + # Store the message in the callback server + try: + store_response = requests.post( + f"{callback_server_url}/voice/store", + json={ + "session_id": session_id, + "to_number": to_number, + "message": message, + "voice_type": voice_type, + }, + timeout=5, + ) + if store_response.status_code != 200: + logger.warning( + "Failed to store message in callback server: %s", + store_response.text, + ) + # Optionally, you might want to return an error here if storing the message is critical + # return json.dumps({"error": f"Failed to store message in callback server: {store_response.text}"}) + except requests.exceptions.RequestException as e: + logger.warning( + "Could not connect to callback server to store message: %s", str(e) + ) + # Optionally, return an error if connection to callback server is critical + # return json.dumps({"error": f"Could not connect to callback server: {str(e)}"}) + # Continue with the call anyway, but it won't have custom message if store failed + + africastalking.initialize(username, api_key) + voice = africastalking.Voice + masked_number = mask_phone_number(to_number) + logger.info( + "Making voice call with text to %s from %s", masked_number, from_number + ) + logger.info( + "Message: %s", message[:50] + "..." if len(message) > 50 else message + ) + logger.info("Session ID: %s", session_id) + + # Make the voice call - callback URL is configured in AT dashboard + response = voice.call( + callFrom=from_number, + callTo=[to_number], # Pass as list, as required by africastalking API + ) + + # Create XML response for reference + xml_response = f""" + + {message} +""" + + # Add additional info to the response + if isinstance(response, dict): + response["xml_response"] = xml_response + response["session_id"] = session_id + + logger.info("Voice call with text response: %s", response) + return json.dumps(response) + + except Exception as e: + logger.error( + "Encountered an error while making voice call with text: %s", str(e) + ) + return json.dumps({"error": str(e)}) + + +def make_voice_call_and_play_audio( + from_number: str, to_number: str, audio_url: str +) -> str: + """Make a voice call and play an audio file from a URL. + + This function initiates a voice call. The actual playback of the audio + is handled by your voice_callback_server.py, which should serve an XML + with the action when Africa's Talking requests call instructions. + + Parameters + ---------- + from_number : str + The caller ID for the voice call (must start with +) + to_number : str + The recipient phone number (must start with +) + audio_url : str + The public URL of the audio file to play (e.g., MP3, WAV). + + Returns + ------- + str + JSON response from the API containing call details. + + Examples + -------- + make_voice_call_and_play_audio("+254700000001", "+254712345678", "http://example.com/audio.mp3") + """ + import requests + import uuid + + try: + # Validate inputs + request_data = MakeVoiceCallAndPlayAudioRequest( + from_number=from_number, to_number=to_number, audio_url=audio_url + ) + + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + callback_server_url = os.getenv( + "VOICE_CALLBACK_URL", "http://localhost:5001" + ) # Ensure this is your callback server + + logger.info( + "Loaded the credentials: %s %s", + username, + mask_api_key(api_key if api_key else ""), + ) + logger.info( + "Callback server URL for storing play info: %s", callback_server_url + ) + + if not username or not api_key: + logger.error("AT_USERNAME or AT_API_KEY missing.") + return json.dumps({"error": "Authentication credentials missing."}) + + session_id = str(uuid.uuid4()) + + # Store the audio URL and session ID in the callback server + # This endpoint /voice/store_play_info needs to be implemented in voice_callback_server.py + store_play_info_endpoint = f"{callback_server_url}/voice/store_play_info" + try: + store_response = requests.post( + store_play_info_endpoint, + json={"session_id": session_id, "audio_url": request_data.audio_url}, + timeout=5, + ) + if store_response.status_code != 200: + logger.warning( + "Failed to store play info in callback server (%s): %s", + store_play_info_endpoint, + store_response.text, + ) + # Decide if you want to proceed if storing fails + except requests.exceptions.RequestException as e: + logger.warning( + "Could not connect to callback server to store play info (%s): %s", + store_play_info_endpoint, + str(e), + ) + + africastalking.initialize(username, api_key) + voice = africastalking.Voice + masked_to_number = mask_phone_number(request_data.to_number) + logger.info( + "Making voice call to %s from %s to play audio: %s", + masked_to_number, + request_data.from_number, + request_data.audio_url, + ) + logger.info("Session ID for play audio call: %s", session_id) + + # Make the voice call. The callback URL configured in your AT dashboard + # will be called by AT to get instructions (e.g., the XML). + response = voice.call( + callFrom=request_data.from_number, callTo=[request_data.to_number] + ) + + # Add session_id and audio_url to the response for reference + if isinstance(response, dict): + response["session_id"] = session_id + response["audio_url_to_play"] = request_data.audio_url + response["notes"] = ( + "Call initiated. Playback is controlled by the XML from your callback server " + "which should use the stored audio_url for this session_id." + ) + + logger.info("Voice call (for play audio) response: %s", response) + return json.dumps(response) + + except Exception as e: + logger.error( + "Encountered an error while making voice call to play audio: %s", str(e) + ) + return json.dumps({"error": str(e)}) + + +def get_application_balance(sandbox: bool = False) -> str: + """ + Fetch the general application balance from Africa's Talking using the Application Data endpoint. + + Parameters + ---------- + username : str + Your Africa's Talking application username. + api_key : str + Your Africa's Talking API key. + sandbox : bool + Whether to use the sandbox endpoint (default: False). + + Returns + ------- + str + JSON response containing application balance information. + + Examples + -------- + get_application_balance("my_username", "my_api_key") + """ + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + if sandbox: + url = ( + f"https://api.sandbox.africastalking.com/version1/user?username={username}" + ) + else: + url = f"https://api.africastalking.com/version1/user?username={username}" + headers = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "apiKey": api_key, + } + logger.info("Fetching application balance from Application Data endpoint: %s", url) + try: + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + logger.info("Application balance response: %s", data) + return json.dumps(data) + except Exception as e: + logger.error( + "Encountered an error while fetching application balance: %s", str(e) + ) + return json.dumps({"error": str(e)}) + + +def send_whatsapp_message( + username: str, + api_key: str, + wa_number: str, + phone_number: str, + message: str = None, + media_type: str = None, + url: str = None, + caption: str = None, + body: list = None, + action: list = None, + buttons: list = None, + sandbox: bool = False, +) -> str: + """ + Send a WhatsApp message using Africa's Talking API. + + Parameters + ---------- + username : str + Your Africa's Talking application username. + api_key : str + Your Africa's Talking API key. + wa_number : str + The WhatsApp phone number associated with your Africa's Talking account (sender). + phone_number : str + The recipient's phone number. + message : str, optional + The text message to send. + media_type : str, optional + The type of media (Image, Video, Audio, Voice). + url : str, optional + The hosted URL of the media. + caption : str, optional + The caption for the media. + body : list, optional + List for interactive messages. + action : list, optional + List of actions for interactive messages. + buttons : list, optional + List of buttons for interactive messages. + sandbox : bool, optional + Use sandbox endpoint if True. + + Returns + ------- + str + JSON response from the API. + + Examples + -------- + send_whatsapp_message("my_username", "my_api_key", "+254799999999", "+254700000000", message="Hello!") + """ + if sandbox: + url_endpoint = "https://chat.sandbox.africastalking.com/whatsapp/message/send" + else: + url_endpoint = "https://chat.africastalking.com/whatsapp/message/send" + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "apiKey": api_key, + } + + body_dict = {} + if message: + body_dict["message"] = message + if media_type: + body_dict["mediaType"] = media_type + if url: + body_dict["url"] = url + if caption: + body_dict["caption"] = caption + if body: + body_dict["body"] = body + if action: + body_dict["action"] = action + if buttons: + body_dict["buttons"] = buttons + + payload = { + "username": username, + "waNumber": wa_number, + "phoneNumber": phone_number, + "body": body_dict, + } + + logger.info(f"Sending WhatsApp message to {phone_number} via {wa_number}") + logger.info(f"Payload: {payload}") + + try: + response = requests.post( + url_endpoint, headers=headers, json=payload, timeout=10 + ) + response.raise_for_status() + data = response.json() + logger.info(f"WhatsApp message response: {data}") + return json.dumps(data) + except Exception as e: + logger.error(f"Error sending WhatsApp message: {str(e)}") + return json.dumps({"error": str(e)}) if __name__ == "__main__": @@ -220,3 +1144,4 @@ def send_message(phone_number: str, message: str, username: str) -> None: send_airtime("phone_number", "KES", "10") # replace phone_number with the phone number you want to send a message to send_message("phone_number", "Hello, this is a test message", "my_username") + print("Your wallet balance is: ", get_wallet_balance()) diff --git a/utils/function_call.py b/utils/function_call.py index a4ade0f..0eb8192 100644 --- a/utils/function_call.py +++ b/utils/function_call.py @@ -28,7 +28,13 @@ import asyncio import africastalking import ollama +import requests from autogen import ConversableAgent +from pydantic import BaseModel, field_validator, ValidationError +from typing import Union +from typing import Optional +import re +from .communication_apis import send_mobile_data_wrapper, send_mobile_data_original # from codecarbon import EmissionsTracker # Import the EmissionsTracker from duckduckgo_search import DDGS @@ -81,6 +87,179 @@ os.environ["CODECARBON_REGION"] = "africa_east" +class SendMobileDataRequest(BaseModel): + phone_number: str + bundle: str + provider: str + plan: str + + @field_validator("phone_number") + @classmethod + def validate_phone_number(cls, v): + if not v or not v.startswith("+"): + raise ValueError( + "phone_number must be in international format, e.g. +254712345678" + ) + return v + + @field_validator("bundle") + @classmethod + def validate_bundle(cls, v): + # Allow numeric values or strings with MB/GB + if not re.match(r"^\d+(?:MB|GB)?$", str(v), re.IGNORECASE): + raise ValueError( + "bundle must be a number or a string with unit, e.g. 50, 500MB, 1GB" + ) + return v + + @field_validator("provider") + @classmethod + def validate_provider(cls, v): + if not v: + raise ValueError("provider must not be empty") + return v + + @field_validator("plan") + @classmethod + def validate_plan(cls, v): + valid_plans = ["daily", "weekly", "monthly", "day", "week", "month"] + if v.lower() not in valid_plans: + raise ValueError(f"plan must be one of: {', '.join(valid_plans)}") + return v + + +class SendUSSDRequest(BaseModel): + phone_number: str + code: str + + @field_validator("phone_number") + @classmethod + def validate_phone_number(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + +class MakeVoiceCallRequest(BaseModel): + from_number: str + to_number: str + + @field_validator("from_number", "to_number") + @classmethod + def validate_phone_numbers(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + +class MakeVoiceCallWithTextRequest(BaseModel): + from_number: str + to_number: str + message: str + voice: Optional[str] = "woman" + + @field_validator("from_number", "to_number") + @classmethod + def validate_phone_numbers(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + @field_validator("voice") + @classmethod + def validate_voice(cls, v): + if v not in ["man", "woman"]: + raise ValueError("Voice must be either 'man' or 'woman'") + return v + + +class MakeVoiceCallAndPlayAudioRequest(BaseModel): + from_number: str + to_number: str + audio_url: str + + @field_validator("from_number", "to_number") + @classmethod + def validate_phone_numbers(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + @field_validator("audio_url") + @classmethod + def validate_audio_url(cls, v): + # Basic URL validation, can be expanded + if not v.startswith("http://") and not v.startswith("https://"): + raise ValueError("audio_url must be a valid HTTP/HTTPS URL") + return v + + +class GetApplicationBalanceRequest(BaseModel): + """Request model for getting application balance""" + + sandbox: Optional[bool] = False + + @field_validator("sandbox") + @classmethod + def validate_sandbox(cls, v): + return bool(v) + + +class SendWhatsAppMessageRequest(BaseModel): + """Request model for sending WhatsApp messages""" + + wa_number: str + phone_number: str + message: Optional[str] = None + media_type: Optional[str] = None + url: Optional[str] = None + caption: Optional[str] = None + sandbox: Optional[bool] = False + + @field_validator("wa_number", "phone_number") + @classmethod + def validate_phone_numbers(cls, v): + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + return v + + @field_validator("media_type") + @classmethod + def validate_media_type(cls, v): + if v and v not in ["Image", "Video", "Audio", "Voice"]: + raise ValueError("media_type must be one of: Image, Video, Audio, Voice") + return v + + +class SendAirtimeRequest(BaseModel): + phone_number: str + currency_code: str + amount: str + + @field_validator("phone_number") + @classmethod + def validate_phone_number(cls, v): + if not v or not v.startswith("+") or not v[1:].isdigit(): + raise ValueError( + "phone_number must be in international format, e.g. +254712345678" + ) + return v + + @field_validator("currency_code") + @classmethod + def validate_currency_code(cls, v): + if not v or len(v) != 3 or not v.isalpha(): + raise ValueError("currency_code must be a 3-letter ISO code, e.g. KES") + return v + + @field_validator("amount") + @classmethod + def validate_amount(cls, v): + if not v or not re.match(r"^\d+(\.\d{1,2})?$", v): + raise ValueError("amount must be a valid decimal number, e.g. 10 or 10.50") + return v + + # Mask phone number and API key for the logs def mask_phone_number(phone_number): """Hide the first digits of a phone number. @@ -148,50 +327,47 @@ def send_airtime(phone_number: str, currency_code: str, amount: str, **kwargs) - Returns ------- - None + str + JSON response from the API Examples -------- send_airtime("+254712345678", "KES", "10") """ - # Load credentials from environment variables - username = os.getenv("AT_USERNAME") - api_key = os.getenv("AT_API_KEY") - logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + try: + validated = SendAirtimeRequest( + phone_number=phone_number, currency_code=currency_code, amount=amount + ) + except ValidationError as ve: + logger.error(f"Airtime parameter validation failed: {ve}") + return str(ve) - # Initialize the SDK - africastalking.initialize(username, api_key) + try: + # Delegate to the standardized implementation in communication_apis + from .communication_apis import send_airtime as comm_send_airtime - # Get the airtime service - airtime = africastalking.Airtime + masked_number = mask_phone_number(phone_number) + logger.info("Delegating airtime sending to %s", masked_number) + logger.info("Amount: %s %s", amount, currency_code) - # Mask the phone number for logging - masked_number = mask_phone_number(phone_number) - logger.info("Sending airtime to %s", masked_number) + response = comm_send_airtime(phone_number, currency_code, amount) + logger.debug("Airtime delegation response: %s", response) + return response - try: - # Send airtime - responses = airtime.send( - phone_number=phone_number, amount=amount, currency_code=currency_code - ) - logger.debug("The response from sending airtime: %s", responses) - return json.dumps(responses) except Exception as e: logger.error("Encountered an error while sending airtime: %s", str(e)) return json.dumps({"error": str(e)}) -def send_message(phone_number: str, message: str, username: str, **kwargs) -> None: +def send_message(phone_number: str, message: str, username: str, **kwargs) -> str: """Allows you to send a message to a phone number. Parameters ---------- - phone_number: str : - The phone number to send the message to. + phone_number: str : The phone number to send the message to. It should be in the international format. - eg. +254712345678 (Kenya) - - +254 is the country code. 712345678 is the phone number. + eg. +254712345678 (Kenya) - +254 is the country code. 712345678 is the phone number. message: str : The message to send. It should be a string. eg. "Hello, this is a test message" @@ -201,34 +377,398 @@ def send_message(phone_number: str, message: str, username: str, **kwargs) -> No Returns ------- - None + str + JSON response from the API Examples -------- send_message("+254712345678", "Hello there", "jak2") """ - # Load API key from environment variables - api_key = os.getenv("AT_API_KEY") - logger.info("Loaded the API key: %s", mask_api_key(api_key)) + try: + validated = SendSMSRequest( + phone_number=phone_number, message=message, username=username + ) + except ValidationError as ve: + logger.error(f"SMS parameter validation failed: {ve}") + return str(ve) + + try: + from .communication_apis import send_message as comm_send_message + + masked_number = mask_phone_number(phone_number) + logger.info("Delegating message sending to %s", masked_number) + logger.info("Message: %s", message) + response = comm_send_message(phone_number, message, username) + logger.debug("Message delegation response: %s", response) + return response + except Exception as e: + logger.error("Encountered an error while sending message: %s", str(e)) + return json.dumps({"error": str(e)}) - # Initialize the SDK - africastalking.initialize(username, api_key) - # Get the SMS service - sms = africastalking.SMS +def send_ussd(phone_number: str, code: str, **kwargs) -> str: + """Send a USSD code to a phone number. + + Parameters + ---------- + phone_number : str + The phone number to dial the USSD code on. + code : str + The USSD code to send, e.g. ``*123#``. - # Mask the phone number for logging - masked_number = mask_phone_number(phone_number) - logger.info("Sending message to %s", masked_number) + Returns + ------- + str + JSON response from the API. + + Examples + -------- + send_ussd("+254712345678", "*123#") + """ + logger.info("send_ussd received: phone_number=%r, code=%r", phone_number, code) + if not phone_number or not code: + error_msg = ( + f"Missing required arguments: phone_number={phone_number}, code={code}" + ) + logger.error(error_msg) + return json.dumps({"error": error_msg}) try: - # Send the message - response = sms.send(message, [phone_number]) - logger.debug("Message sent to %s. Response: %s", masked_number, response) - return json.dumps(response) + # Delegate to the standardized implementation in communication_apis + from .communication_apis import send_ussd as comm_send_ussd + + masked_number = mask_phone_number(phone_number) + logger.info("Delegating USSD sending to %s", masked_number) + + response = comm_send_ussd(phone_number, code) + logger.debug("USSD delegation response: %s", response) + return response + except Exception as e: - logger.error("Encountered an error while sending the message: %s", str(e)) + error_msg = f"Encountered an error while sending USSD: {str(e)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}) + + +def get_wallet_balance(**kwargs) -> str: + """Fetch the current wallet balance from Africa's Talking account using the documented API endpoint.""" + try: + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + url = f"https://bundles.africastalking.com/query/wallet/balance?username={username}" + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "apiKey": api_key, + } + logger.info("Fetching wallet balance from documented endpoint") + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + logger.info("Wallet balance response: %s", data) + return json.dumps(data) + except Exception as e: + logger.error("Encountered an error while fetching wallet balance: %s", str(e)) + return json.dumps({"error": str(e)}) + + +def make_voice_call(from_number: str, to_number: str, **kwargs) -> str: + """Initiate a voice call between two numbers. + + Parameters + ---------- + from_number : str + The caller ID for the voice call. + to_number : str + The recipient of the call. + + Returns + ------- + str + JSON response from the API. + + Examples + -------- + make_voice_call("+254700000001", "+254712345678") + """ + logger.info( + "make_voice_call received: from_number=%r, to_number=%r", from_number, to_number + ) + # Defensive check for argument validity + if not from_number or not to_number or not to_number.startswith("+"): + logger.error( + "Invalid phone numbers: from_number=%r, to_number=%r", + from_number, + to_number, + ) + return json.dumps( + { + "error": f"Invalid phone numbers: from_number={from_number}, to_number={to_number}" + } + ) + try: + # Delegate to the standardized implementation in communication_apis + from .communication_apis import make_voice_call as comm_make_voice_call + + masked_to_number = mask_phone_number(to_number) + masked_from_number = mask_phone_number(from_number) + logger.info( + "Delegating voice call to %s from %s", masked_to_number, masked_from_number + ) + + response = comm_make_voice_call(from_number, to_number) + logger.debug("Voice call delegation response: %s", response) + return response + + except Exception as e: + logger.error("Encountered an error while making voice call: %s", str(e)) + return json.dumps({"error": str(e)}) + + +def make_voice_call_with_text( + from_number: str, to_number: str, message: str, voice_type: str = "woman", **kwargs +) -> str: + """Make a voice call and say a message using text-to-speech. + + This function initiates a voice call and uses Africa's Talking text-to-speech + engine to read the provided message to the recipient. + + Parameters + ---------- + from_number : str + The caller ID for the voice call (must start with +) + to_number : str + The recipient phone number (must start with +) + message : str + The text message to be spoken during the call + voice_type : str, optional + The voice type to use ("man" or "woman"), defaults to "woman" + + Returns + ------ + str + JSON response from the API containing call details + + Examples + -------- + make_voice_call_with_text("+254700000001", "+254712345678", "Hello, this is a test message", "woman") + """ + try: + # Validate inputs + request = MakeVoiceCallWithTextRequest( + from_number=from_number, + to_number=to_number, + message=message, + voice=voice_type, + ) + + from .communication_apis import ( + make_voice_call_with_text as comm_make_voice_call_with_text, + ) + + masked_to_number = mask_phone_number(to_number) + masked_from_number = mask_phone_number(from_number) + logger.info( + "Making voice call with text to %s from %s", + masked_to_number, + masked_from_number, + ) + logger.info( + "Message: %s", message[:50] + "..." if len(message) > 50 else message + ) + + # Call the communication_apis function + response = comm_make_voice_call_with_text( + from_number, to_number, message, voice_type + ) + + logger.debug("Voice call with text response: %s", response) + return response + + except Exception as e: + logger.error( + "Encountered an error while making voice call with text: %s", str(e) + ) + return json.dumps({"error": str(e)}) + + +def make_voice_call_and_play_audio( + from_number: str, to_number: str, audio_url: str, **kwargs +) -> str: + """Make a voice call and play an audio file from a URL. + + This function initiates a voice call and plays an audio file from a publicly + accessible URL. The actual playback is handled by the voice_callback_server.py + which should serve an XML with the action when Africa's Talking requests + call instructions. + + Parameters + ---------- + from_number : str + The caller ID for the voice call (must start with +) + to_number : str + The recipient phone number (must start with +) + audio_url : str + The public URL of the audio file to play (e.g., MP3, WAV). + Must be a direct HTTP/HTTPS URL to the audio file. + + Returns + ------- + str + JSON response from the API containing call details. + + Examples + -------- + make_voice_call_and_play_audio("+254700000001", "+254712345678", "https://example.com/audio.mp3") + """ + try: + # Validate inputs using pydantic + request = MakeVoiceCallAndPlayAudioRequest( + from_number=from_number, to_number=to_number, audio_url=audio_url + ) + + from .communication_apis import ( + make_voice_call_and_play_audio as comm_make_voice_call_and_play_audio, + ) + + masked_to_number = mask_phone_number(to_number) + masked_from_number = mask_phone_number(from_number) + logger.info( + "Making voice call to %s from %s to play audio: %s", + masked_to_number, + masked_from_number, + audio_url, + ) + + # Call the communication_apis function + response = comm_make_voice_call_and_play_audio( + from_number, to_number, audio_url + ) + logger.debug("Voice call (for play audio) response: %s", response) + return response + + except Exception as e: + logger.error( + "Encountered an error while making voice call to play audio: %s", str(e) + ) + return json.dumps({"error": str(e)}) + + +def get_application_balance(sandbox: bool = False, **kwargs) -> str: + """Fetch the general application balance from Africa's Talking using the Application Data endpoint.""" + try: + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + logger.info("Loaded the credentials: %s %s", username, mask_api_key(api_key)) + if sandbox: + url = f"https://api.sandbox.africastalking.com/version1/user?username={username}" + else: + url = f"https://api.africastalking.com/version1/user?username={username}" + headers = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "apiKey": api_key, + } + logger.info( + "Fetching application balance from Application Data endpoint: %s", url + ) + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + logger.info("Application balance response: %s", data) + return json.dumps(data) + except Exception as e: + logger.error( + "Encountered an error while fetching application balance: %s", str(e) + ) + return json.dumps({"error": str(e)}) + + +def send_whatsapp_message( + wa_number: str, + phone_number: str, + message: str = None, + media_type: str = None, + url: str = None, + caption: str = None, + sandbox: bool = False, + **kwargs, +) -> str: + """Send a WhatsApp message using Africa's Talking API. + + Parameters + ---------- + wa_number : str + The WhatsApp phone number associated with your AT account (sender) + phone_number : str + The recipient's phone number + message : str, optional + The text message to send + media_type : str, optional + The type of media (Image, Video, Audio, Voice) + url : str, optional + The hosted URL of the media + caption : str, optional + The caption for the media + sandbox : bool, optional + Use sandbox endpoint if True + + Returns + ------- + str + JSON response from the API + + Examples + -------- + send_whatsapp_message("+254799999999", "+254700000000", message="Hello!") + send_whatsapp_message("+254799999999", "+254700000000", media_type="Image", url="https://example.com/image.jpg", caption="Check this out!") + """ + try: + from .communication_apis import send_whatsapp_message as send_whatsapp + + # Validate inputs + request = SendWhatsAppMessageRequest( + wa_number=wa_number, + phone_number=phone_number, + message=message, + media_type=media_type, + url=url, + caption=caption, + sandbox=sandbox, + ) + + username = os.getenv("AT_USERNAME") + api_key = os.getenv("AT_API_KEY") + + if not username or not api_key: + logger.error("AT_USERNAME or AT_API_KEY missing") + return '{"error": "Authentication credentials missing"}' + + logger.info( + "Sending WhatsApp message from %s to %s", + mask_phone_number(request.wa_number), + mask_phone_number(request.phone_number), + ) + + result = send_whatsapp( + username=username, + api_key=api_key, + wa_number=request.wa_number, + phone_number=request.phone_number, + message=request.message, + media_type=request.media_type, + url=request.url, + caption=request.caption, + sandbox=request.sandbox, + ) + + logger.info("WhatsApp message sent successfully") + return result + + except Exception as e: + logger.error("Error sending WhatsApp message: %s", str(e)) return json.dumps({"error": str(e)}) @@ -289,7 +829,17 @@ def translate_text(text: str, target_language: str) -> str: 'Bonjour, comment ça va?' """ - if target_language.lower() not in ["french", "arabic", "portuguese"]: + language_map = { + "french": "French", + "fr": "French", + "arabic": "Arabic", + "ar": "Arabic", + "portuguese": "Portuguese", + "pt": "Portuguese", + } + normalized_language = language_map.get(target_language.lower()) + + if not normalized_language: raise ValueError("Target language must be French, Arabic, or Portuguese.") config = [ @@ -323,7 +873,7 @@ def translate_text(text: str, target_language: str) -> str: human_input_mode="NEVER", ) - message = f"Zoe, translate '{text}' to {target_language.capitalize()}" + message = f"Zoe, translate '{text}' to {normalized_language}" result = joe.initiate_chat(zoe, message=message, max_turns=2) return result @@ -457,6 +1007,214 @@ async def run(model: str, user_input: str): }, }, }, + { + "type": "function", + "function": { + "name": "send_ussd", + "description": "Send a USSD code to a phone number using the Africa's Talking API", + "parameters": { + "type": "object", + "properties": { + "phone_number": { + "type": "string", + "description": "The phone number in international format", + }, + "code": { + "type": "string", + "description": "The USSD code to dial", + }, + }, + "required": ["phone_number", "code"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_mobile_data", # Change from "send_mobile_data_wrapper" to "send_mobile_data" + "description": "Send a mobile data bundle using the Africa's Talking API", + "parameters": { + "type": "object", + "properties": { + "phone_number": { + "type": "string", + "description": "The phone number in international format", + }, + "bundle": { + "type": "string", + "description": "The bundle amount, e.g. '100MB', '1GB', or just '50' for 50MB", + }, + "provider": { + "type": "string", + "description": "The mobile network provider, e.g. 'Safaricom', 'Airtel'", + }, + "plan": { + "type": "string", + "description": "The bundle plan duration, e.g. 'daily', 'weekly', 'monthly'", + }, + "product_name": { + "type": "string", + "description": "The name of the product to be used for the bundle, eg. 'data_bundle'", + }, + }, + "required": [ + "phone_number", + "bundle", + "provider", + "plan", + "product_name", + ], + }, + }, + }, + { + "type": "function", + "function": { + "name": "make_voice_call", + "description": "Initiate a voice call between two numbers using Africa's Talking API", + "parameters": { + "type": "object", + "properties": { + "from_number": { + "type": "string", + "description": "The caller ID", + }, + "to_number": { + "type": "string", + "description": "The recipient phone number", + }, + }, + "required": ["from_number", "to_number"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_wallet_balance", + "description": "Fetch the current wallet balance from Africa's Talking account", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "make_voice_call_with_text", + "description": "Make a voice call and say a message using text-to-speech with Africa's Talking API", + "parameters": { + "type": "object", + "properties": { + "from_number": { + "type": "string", + "description": "The caller ID", + }, + "to_number": { + "type": "string", + "description": "The recipient phone number", + }, + "message": { + "type": "string", + "description": "The text message to be spoken during the call", + }, + "voice_type": { + "type": "string", + "description": "The voice type to use ('man' or 'woman')", + "default": "woman", + }, + }, + "required": ["from_number", "to_number", "message"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "make_voice_call_and_play_audio", + "description": "Make a voice call and play a message using Africa's Talking API", + "parameters": { + "type": "object", + "properties": { + "from_number": { + "type": "string", + "description": "The caller ID", + }, + "to_number": { + "type": "string", + "description": "The recipient phone number", + }, + "audio_url": { + "type": "string", + "description": "The URL of the audio file to play", + }, + }, + "required": ["from_number", "to_number", "audio_url"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_application_balance", + "description": "Get application balance from Africa's Talking account", + "parameters": { + "type": "object", + "properties": { + "sandbox": { + "type": "boolean", + "description": "Whether to use sandbox endpoint", + "default": False, + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_whatsapp_message", + "description": "Send a WhatsApp message using Africa's Talking API", + "parameters": { + "type": "object", + "properties": { + "wa_number": { + "type": "string", + "description": "The WhatsApp phone number associated with your AT account (sender)", + }, + "phone_number": { + "type": "string", + "description": "The recipient's phone number", + }, + "message": { + "type": "string", + "description": "The text message to send", + }, + "media_type": { + "type": "string", + "description": "The type of media (Image, Video, Audio, Voice)", + }, + "url": { + "type": "string", + "description": "The hosted URL of the media", + }, + "caption": { + "type": "string", + "description": "The caption for the media", + }, + "sandbox": { + "type": "boolean", + "description": "Use sandbox endpoint if True", + "default": False, + }, + }, + "required": ["wa_number", "phone_number"], + }, + }, + }, ], ) # Add the model's response to the conversation history @@ -475,6 +1233,14 @@ async def run(model: str, user_input: str): "send_message": send_message, "search_news": search_news, "translate_text": translate_text, + "send_ussd": send_ussd, + "send_mobile_data": send_mobile_data_wrapper, # Use the wrapper function here + "make_voice_call": make_voice_call, + "make_voice_call_with_text": make_voice_call_with_text, + "make_voice_call_and_play_audio": make_voice_call_and_play_audio, + "get_wallet_balance": get_wallet_balance, + "get_application_balance": get_application_balance, + "send_whatsapp_message": send_whatsapp_message, } for tool in response["message"]["tool_calls"]: # Get the function to call based on the tool name @@ -509,6 +1275,60 @@ async def run(model: str, user_input: str): tool["function"]["arguments"]["target_language"], ) logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "send_ussd": + function_response = function_to_call( + tool["function"]["arguments"]["phone_number"], + tool["function"]["arguments"]["code"], + ) + logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "send_mobile_data": + function_response = function_to_call( + tool["function"]["arguments"]["phone_number"], + tool["function"]["arguments"]["bundle"], + tool["function"]["arguments"]["provider"], + tool["function"]["arguments"]["plan"], + ) + logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "make_voice_call": + function_response = function_to_call( + tool["function"]["arguments"]["from_number"], + tool["function"]["arguments"]["to_number"], + ) + logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "make_voice_call_with_text": + function_response = function_to_call( + tool["function"]["arguments"]["from_number"], + tool["function"]["arguments"]["to_number"], + tool["function"]["arguments"]["message"], + voice_type=tool["function"]["arguments"].get("voice_type", "woman"), + ) + logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "get_wallet_balance": + function_response = function_to_call() + logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "make_voice_call_and_play_audio": + function_response = function_to_call( + tool["function"]["arguments"]["from_number"], + tool["function"]["arguments"]["to_number"], + tool["function"]["arguments"]["audio_url"], + ) + logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "get_application_balance": + function_response = function_to_call( + tool["function"]["arguments"].get("sandbox", False), + ) + logger.debug("function response: %s", function_response) + elif tool["function"]["name"] == "send_whatsapp_message": + function_response = function_to_call( + tool["function"]["arguments"]["wa_number"], + tool["function"]["arguments"]["phone_number"], + tool["function"]["arguments"].get("message"), + tool["function"]["arguments"].get("media_type"), + tool["function"]["arguments"].get("url"), + tool["function"]["arguments"].get("caption"), + tool["function"]["arguments"].get("sandbox", False), + ) + logger.debug("function response: %s", function_response) # Add the function response to the conversation history messages.append( From 9de1cf1706ca5737089352de23a0b2f6b733e705 Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:11:06 +0300 Subject: [PATCH 3/9] Add files via upload Update requirements.txt --- requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d235075..980e9cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,7 @@ pyautogen==0.2.18 flaml[automl] edge-tts==7.0.0 deprecated==1.2.18 -pydantic==2.9.2 \ No newline at end of file +pydantic==2.9.2 +flask==3.0.0 +flask-cors==4.0.0 +requests==2.31.0 \ No newline at end of file From eed2c0ad115e556d2575fe3098c79d1fefa3db9e Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:14:24 +0300 Subject: [PATCH 4/9] Rename docker-compose.yml to docker-compose-all.yml rename to offer all services. --- docker-compose.yml => docker-compose-all.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docker-compose.yml => docker-compose-all.yml (100%) diff --git a/docker-compose.yml b/docker-compose-all.yml similarity index 100% rename from docker-compose.yml rename to docker-compose-all.yml From e1cef29cbbe9969793022e7fde7d642566ddda80 Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:15:01 +0300 Subject: [PATCH 5/9] Add files via upload format files. From 60932365c937faee86d831994733ac9e7395338f Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:16:16 +0300 Subject: [PATCH 6/9] Create docker-compose.yml add original docker-compose. --- docker-compose.yml | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..26e447a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + # Ollama service + ollama: + # Build the Ollama image from the Dockerfile.ollama + build: + context: . + dockerfile: Dockerfile.ollama + container_name: ollama-server + # Security options to enhance container security + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + # Expose port 11434 for the Ollama service + ports: + - "11434:11434" + # Mount a volume for model persistence + volumes: + - ollama_models:/models + + # Gradio app service + app: + # Build the Gradio app image from the Dockerfile.app + build: + context: . + dockerfile: Dockerfile.app + container_name: gradio-app + # Environment variables for the Gradio app + environment: + - AT_USERNAME=${AT_USERNAME} + - AT_API_KEY=${AT_API_KEY} + - LANGTRACE_API_KEY=${LANGTRACE_API_KEY} + - GROQ_API_KEY=${GROQ_API_KEY} + - OLLAMA_HOST=http://ollama:11434 + # Expose port 7860 for the Gradio web interface + ports: + - "7860:7860" + # Ensure the Ollama service is started before the Gradio app + depends_on: + - ollama + +# Define a volume for model persistence +volumes: + ollama_models: From 390952d70fcb118a9ab074bdd5736ff1f288a2a0 Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:23:00 +0300 Subject: [PATCH 7/9] Add files via upload From 863ddce23b4ec30235dddba3cd65891900d92664 Mon Sep 17 00:00:00 2001 From: Legendrea Date: Tue, 1 Jul 2025 06:51:15 +0300 Subject: [PATCH 8/9] Add files via upload From 0514ae59e1577dd0595cd6fc2d799e1de6041d60 Mon Sep 17 00:00:00 2001 From: Shuyib Date: Tue, 1 Jul 2025 07:16:57 +0300 Subject: [PATCH 9/9] Fix formatting of voice call response in communication_apis.py --- utils/communication_apis.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/utils/communication_apis.py b/utils/communication_apis.py index 48fd262..8be2dae 100644 --- a/utils/communication_apis.py +++ b/utils/communication_apis.py @@ -28,12 +28,12 @@ "Queued"}], "errorMessage": "None"}' Make a voice call with text -{'entries': [{'phoneNumber': '+254728303524', 'sessionId': 'ATVId_xxxx', -'status': 'Queued'}], 'errorMessage': 'None', -'xml_response': '\n\n -Hello, this is a test message\n', -'session_id': '1dbd2e4e-20be-4971-9455-dfed5fe5552c', 'callback_url': -'https://80c4-165-73-248-94.ngrok-free.app/voice/callback'} +{'entries': [{'phoneNumber': '+254728303524', 'sessionId': 'ATVId_xxxx', +'status': 'Queued'}], 'errorMessage': 'None', +'xml_response': '\n\n +Hello, this is a test message\n', +'session_id': '1dbd2e4e-20be-4971-9455-dfed5fe5552c', 'callback_url': +'https://.ngrok-free.app/voice/callback'} """ import os