first commit

This commit is contained in:
2026-07-18 01:25:01 +03:30
parent dee06ba093
commit c18a1c2298
22 changed files with 981 additions and 0 deletions

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# ==========================================================
# فایل تنظیمات مرکزی پروژه چت‌بات تلفنی
# این تنها فایلی است که باید برای راه‌اندازی ادیت کنی.
# بعد از تغییر، کافیه: docker compose up -d --build
# ==========================================================
# ---------- Ollama ----------
OLLAMA_URL=http://OLLAMA_HOST:11434/api/generate
OLLAMA_MODEL=llama3
# ---------- STT (faster-whisper) ----------
WHISPER_MODEL=medium
WHISPER_DEVICE=cpu
# ---------- TTS (Piper) ----------
PIPER_VOICE=/models/piper/fa_IR-model.onnx
# ---------- ترانک مرکز تماس شماره ۱ (SIP Trunk) ----------
TRUNK1_NAME=callcenter1
TRUNK1_HOST=CALLCENTER1_IP
TRUNK1_USERNAME=USERNAME
TRUNK1_PASSWORD=CALLCENTER1_PASS
# ---------- ترانک مرکز تماس شماره ۲ (اختیاری - اگر نداری خالی بذار) ----------
TRUNK2_NAME=
TRUNK2_HOST=
TRUNK2_USERNAME=
TRUNK2_PASSWORD=
# ---------- SIP User داخلی (برای تست با سافت‌فون) ----------
TEST_USER_EXTEN=1000
TEST_USER_PASSWORD=CHANGE_ME_STRONG_PASSWORD

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
models/
asterisk/tmp/

445
agi-scripts/voicebot.py Executable file
View File

@@ -0,0 +1,445 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import uuid
import requests
from asterisk.agi import AGI
TMP_DIR = "/tmp/voicebot"
STT_URL = "http://127.0.0.1:5001/transcribe"
TTS_URL = "http://127.0.0.1:5002/synthesize"
OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
OLLAMA_MODEL = "qwen3:14b"
def log(agi, msg):
agi.verbose(
"VOICEBOT: " + str(msg),
1
)
def tts_generate(agi, text, name):
try:
log(
agi,
"TTS request: " + text
)
r = requests.post(
TTS_URL,
json={
"text": text
},
timeout=60
)
r.raise_for_status()
path = TMP_DIR + "/" + name + ".wav"
with open(path, "wb") as f:
f.write(r.content)
log(
agi,
"TTS created " + path
)
return path
except Exception as e:
log(
agi,
"TTS ERROR " + str(e)
)
return None
def play_file(agi, path):
if not path:
return
try:
file = path.replace(
".wav",
""
)
agi.appexec(
"Playback",
file
)
except Exception as e:
log(
agi,
"PLAY ERROR " + str(e)
)
def record_audio(agi, callid):
filename = TMP_DIR + "/" + callid + "_record"
log(
agi,
"Recording user voice"
)
try:
result = agi.send_command(
f'RECORD FILE "{filename}" wav "#" 10000 0 s=5'
)
log(
agi,
"Record result: " + str(result)
)
except Exception as e:
log(
agi,
"Record ERROR " + str(e)
)
return None
wav = filename + ".wav"
if os.path.exists(wav):
size=os.path.getsize(wav)
log(
agi,
f"Recording saved {wav} size={size}"
)
return wav
log(
agi,
"Recording file missing"
)
return None
def stt_process(agi,wav):
try:
log(
agi,
"Sending audio to STT"
)
with open(wav,"rb") as f:
r=requests.post(
STT_URL,
files={
"audio":f
},
timeout=120
)
data=r.json()
text=data.get(
"text",
""
)
log(
agi,
"STT text: " + text
)
return text
except Exception as e:
log(
agi,
"STT ERROR " + str(e)
)
return ""
def ask_ai(agi,text):
try:
log(
agi,
"AI request: " + text
)
prompt = """
تو دستیار تلفنی هستی.
کوتاه و طبیعی جواب بده.
حداکثر دو جمله.
کاربر:
""" + text
r=requests.post(
OLLAMA_URL,
json={
"model":OLLAMA_MODEL,
"prompt":prompt,
"stream":False,
"options":{
"num_predict":80,
"temperature":0.3
}
},
timeout=180
)
data=r.json()
answer=data.get(
"response",
""
)
log(
agi,
"AI response: " + answer
)
return answer
except Exception as e:
log(
agi,
"AI ERROR " + str(e)
)
return "ارتباط با هوش مصنوعی برقرار نشد."
def main():
os.makedirs(
TMP_DIR,
exist_ok=True
)
agi=AGI()
callid=str(uuid.uuid4())[:8]
log(
agi,
"Call started " + callid
)
try:
agi.answer()
welcome=tts_generate(
agi,
"سلام، من دستیار صوتی هوشمند هستم. بفرمایید.",
callid+"_welcome"
)
play_file(
agi,
welcome
)
wav=record_audio(
agi,
callid
)
if not wav:
log(
agi,
"No recording"
)
return
text=stt_process(
agi,
wav
)
if text:
answer=ask_ai(
agi,
text
)
else:
answer="صدای شما را دریافت نکردم."
reply=tts_generate(
agi,
answer,
callid+"_reply"
)
play_file(
agi,
reply
)
log(
agi,
"Call completed"
)
except Exception as e:
log(
agi,
"MAIN ERROR " + str(e)
)
finally:
agi.hangup()
if __name__ == "__main__":
main()

31
asterisk/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM andrius/asterisk:latest
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
gettext-base \
curl \
file \
sox \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --break-system-packages \
requests \
pyst2
RUN mkdir -p \
/tmp/voicebot \
/var/lib/asterisk/sounds/custom \
/etc/asterisk-templates
COPY templates/ /etc/asterisk-templates/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 5060/udp
EXPOSE 5060/tcp
EXPOSE 10000-20000/udp
ENTRYPOINT ["/entrypoint.sh"]

0
asterisk/config/.gitkeep Normal file
View File

3
asterisk/config/agi.conf Normal file
View File

@@ -0,0 +1,3 @@
[general]
enabled = yes
agidir => /var/lib/asterisk/agi-bin

View File

@@ -0,0 +1,41 @@
[general]
static=yes
writeprotect=no
; ==========================================================
; Incoming calls from SIP Trunk
; ==========================================================
[from-trunk]
; Incoming call without DID
exten => s,1,NoOp(Incoming call from trunk - CallerID: ${CALLERID(num)})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(voicebot.py)
same => n,Hangup()
; Incoming call with DID
exten => _X.,1,NoOp(Incoming DID call: ${EXTEN} - CallerID: ${CALLERID(num)})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(voicebot.py)
same => n,Hangup()
; ==========================================================
; Internal test extension
; Used with Zoiper / Linphone for testing
; ==========================================================
[from-internal]
; Match any dialed extension
exten => _X.,1,NoOp(Test call from internal extension ${EXTEN})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(voicebot.py)
same => n,Hangup()

View File

@@ -0,0 +1,5 @@
[modules]
autoload=yes
; در صورتی که نمی‌خوای chan_sip قدیمی لود بشه (توصیه می‌شه فقط pjsip استفاده کنی)
noload => chan_sip.so

View File

@@ -0,0 +1,64 @@
[global]
type=global
user_agent=Pline-AI
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060
; ======================
; Test Extension
; ======================
[1000]
type=endpoint
context=from-internal
disallow=all
allow=ulaw,alaw
auth=1000-auth
aors=1000
[1000-auth]
type=auth
auth_type=userpass
username=1000
password=1000
[1000]
type=aor
max_contacts=5
[121-reg]
type=registration
transport=transport-udp
outbound_auth=121-auth
server_uri=sip:192.168.102.253
client_uri=sip:121@192.168.102.253
retry_interval=60
expiration=3600
[121-auth]
type=auth
auth_type=userpass
username=121
password=121
[121]
type=aor
contact=sip:192.168.102.253
qualify_frequency=60
[121]
type=endpoint
context=from-trunk
transport=transport-udp
disallow=all
allow=ulaw,alaw
outbound_auth=121-auth
aors=121
; --- اضافه کردن این بخش ---
[121-identify]
type=identify
endpoint=121
match=192.168.102.253

41
asterisk/entrypoint.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/sh
set -e
TEMPLATES_DIR="/etc/asterisk-templates"
CONFIG_DIR="/etc/asterisk"
echo "[entrypoint] generating asterisk configuration..."
# کپی فایل‌های ثابت
cp "$TEMPLATES_DIR/agi.conf" "$CONFIG_DIR/agi.conf"
cp "$TEMPLATES_DIR/modules.conf" "$CONFIG_DIR/modules.conf"
# تولید extensions.conf
envsubst '${TEST_USER_EXTEN}' \
< "$TEMPLATES_DIR/extensions.conf.template" \
> "$CONFIG_DIR/extensions.conf"
# تولید pjsip.conf پایه (بدون ترانک)
envsubst '${TEST_USER_EXTEN} ${TEST_USER_PASSWORD}' \
< "$TEMPLATES_DIR/pjsip-base.conf.template" \
> "$CONFIG_DIR/pjsip.conf"
# تابع افزودن ترانک
add_trunk() {
TRUNK_NAME=$1 TRUNK_HOST=$2 TRUNK_USERNAME=$3 TRUNK_PASSWORD=$4 \
envsubst '${TRUNK_NAME} ${TRUNK_HOST} ${TRUNK_USERNAME} ${TRUNK_PASSWORD}' \
< "$TEMPLATES_DIR/pjsip-trunk.conf.template" \
>> "$CONFIG_DIR/pjsip.conf"
}
# اضافه کردن ترانک ۱
add_trunk "$TRUNK1_NAME" "$TRUNK1_HOST" "$TRUNK1_USERNAME" "$TRUNK1_PASSWORD"
# اضافه کردن ترانک ۲ (اگر وجود داشت)
if [ -n "$TRUNK2_HOST" ]; then
echo "[entrypoint] adding second trunk..."
add_trunk "$TRUNK2_NAME" "$TRUNK2_HOST" "$TRUNK2_USERNAME" "$TRUNK2_PASSWORD"
fi
echo "[entrypoint] configuration generated"
exec asterisk -f -vvv

View File

@@ -0,0 +1,3 @@
[general]
enabled = yes
agidir => /var/lib/asterisk/agi-bin

View File

@@ -0,0 +1,41 @@
[general]
static=yes
writeprotect=no
; ==========================================================
; Incoming calls from SIP Trunk
; ==========================================================
[from-trunk]
; Incoming call without DID
exten => s,1,NoOp(Incoming call from trunk - CallerID: ${CALLERID(num)})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(voicebot.py)
same => n,Hangup()
; Incoming call with DID
exten => _X.,1,NoOp(Incoming DID call: ${EXTEN} - CallerID: ${CALLERID(num)})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(voicebot.py)
same => n,Hangup()
; ==========================================================
; Internal test extension
; Used with Zoiper / Linphone for testing
; ==========================================================
[from-internal]
; Match any dialed extension
exten => _X.,1,NoOp(Test call from internal extension ${EXTEN})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(voicebot.py)
same => n,Hangup()

View File

@@ -0,0 +1,5 @@
[modules]
autoload=yes
; در صورتی که نمی‌خوای chan_sip قدیمی لود بشه (توصیه می‌شه فقط pjsip استفاده کنی)
noload => chan_sip.so

View File

@@ -0,0 +1,30 @@
[global]
type=global
user_agent=Pline-AI
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060
; ======================
; Test Extension
; ======================
[${TEST_USER_EXTEN}]
type=endpoint
context=from-internal
disallow=all
allow=ulaw,alaw
auth=${TEST_USER_EXTEN}-auth
aors=${TEST_USER_EXTEN}
[${TEST_USER_EXTEN}-auth]
type=auth
auth_type=userpass
username=${TEST_USER_EXTEN}
password=${TEST_USER_PASSWORD}
[${TEST_USER_EXTEN}]
type=aor
max_contacts=5

View File

@@ -0,0 +1,34 @@
[${TRUNK_NAME}-reg]
type=registration
transport=transport-udp
outbound_auth=${TRUNK_NAME}-auth
server_uri=sip:${TRUNK_HOST}
client_uri=sip:${TRUNK_USERNAME}@${TRUNK_HOST}
retry_interval=60
expiration=3600
[${TRUNK_NAME}-auth]
type=auth
auth_type=userpass
username=${TRUNK_USERNAME}
password=${TRUNK_PASSWORD}
[${TRUNK_NAME}]
type=aor
contact=sip:${TRUNK_HOST}
qualify_frequency=60
[${TRUNK_NAME}]
type=endpoint
context=from-trunk
transport=transport-udp
disallow=all
allow=ulaw,alaw
outbound_auth=${TRUNK_NAME}-auth
aors=${TRUNK_NAME}
; --- اضافه کردن این بخش ---
[${TRUNK_NAME}-identify]
type=identify
endpoint=${TRUNK_NAME}
match=${TRUNK_HOST}

65
docker-compose.yml Normal file
View File

@@ -0,0 +1,65 @@
services:
asterisk:
image: pline-ai/asterisk:latest
build:
context: ./asterisk
container_name: pline-ai-asterisk
network_mode: host
env_file:
- .env
environment:
STT_URL: http://127.0.0.1:5001/transcribe
TTS_URL: http://127.0.0.1:5002/synthesize
volumes:
- ./asterisk/config:/etc/asterisk
- ./asterisk/templates:/etc/asterisk-templates
- ./agi-scripts:/var/lib/asterisk/agi-bin
- ./asterisk/sounds:/var/lib/asterisk/sounds/custom
- voice_storage:/tmp/voicebot # استفاده از volume مشترک
restart: unless-stopped
depends_on:
- stt-service
- tts-service
stt-service:
image: pline-ai/stt:latest
build:
context: ./stt
container_name: pline-ai-stt
network_mode: host
env_file:
- .env
environment:
PORT: 5001
WHISPER_MODEL: /models/whisper
WHISPER_DEVICE: cuda
WHISPER_COMPUTE_TYPE: float16
volumes:
- ./models/whisper:/models/whisper
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities:
- gpu
restart: unless-stopped
tts-service:
image: pline-ai/tts:latest
build:
context: ./tts
container_name: pline-ai-tts
network_mode: host
env_file:
- .env
environment:
PORT: 5002
volumes:
- ./models/piper:/models/piper
- voice_storage:/tmp/voicebot # اضافه شده برای دسترسی به مسیر مشترک
restart: unless-stopped
volumes:
voice_storage: # تعریف حجم مشترک

21
stt/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5001
CMD ["python3", "app.py"]

78
stt/app.py Normal file
View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
import os
import tempfile
from flask import Flask, request, jsonify
from faster_whisper import WhisperModel
# ==========================================================
# Configuration
# ==========================================================
MODEL_PATH = os.environ.get("WHISPER_MODEL", "/models/whisper")
DEVICE = os.environ.get("WHISPER_DEVICE", "cuda")
COMPUTE_TYPE = "float16" if DEVICE == "cuda" else "int8"
PORT = int(os.environ.get("PORT", 5001))
app = Flask(__name__)
# ==========================================================
# Load Whisper Model
# ==========================================================
print(f"[stt] Loading model from {MODEL_PATH} on {DEVICE} ({COMPUTE_TYPE})")
try:
model = WhisperModel(MODEL_PATH, device=DEVICE, compute_type=COMPUTE_TYPE)
print("[stt] Whisper model loaded successfully")
except Exception as e:
print(f"[stt] Fatal error loading model: {e}")
exit(1)
# ==========================================================
# Speech To Text Endpoint
# ==========================================================
@app.route("/transcribe", methods=["POST"])
def transcribe():
if "audio" not in request.files:
return jsonify({"error": "audio file missing"}), 400
audio_file = request.files["audio"]
# استفاده از NamedTemporaryFile با مدیریت بهتر برای محیط داکر
tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
audio_path = tmp_file.name
tmp_file.close()
try:
audio_file.save(audio_path)
# اجرای اینفرنس با تنظیمات بهینه شده
segments, info = model.transcribe(
audio_path,
language="fa",
beam_size=10,
vad_filter=True,
vad_parameters=dict(min_silence_duration_ms=500) # بهبود تشخیص سکوت
)
text = " ".join([segment.text.strip() for segment in segments])
return jsonify({
"text": text.strip(),
"language": info.language
})
except Exception as e:
print(f"[stt] Error during transcription: {e}")
return jsonify({"error": str(e)}), 500
finally:
if os.path.exists(audio_path):
os.remove(audio_path)
# ==========================================================
# Health Check
# ==========================================================
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT)

5
stt/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask==3.0.3
faster-whisper==1.0.3
ctranslate2
requests
numpy

14
tts/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y \
wget ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5002
CMD ["python3", "app.py"]

18
tts/app.py Normal file
View File

@@ -0,0 +1,18 @@
import os, subprocess, tempfile
from flask import Flask, request, send_file
app = Flask(__name__)
PIPER_VOICE = os.environ.get("PIPER_VOICE", "/models/piper/fa_IR-model.onnx")
@app.route("/synthesize", methods=["POST"])
def synthesize():
text = request.get_json().get("text", "")
p_out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
a_out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
subprocess.run(["piper", "--model", PIPER_VOICE, "--output_file", p_out], input=text.encode("utf-8"), capture_output=True)
subprocess.run(["ffmpeg", "-y", "-i", p_out, "-ar", "8000", "-ac", "1", "-acodec", "pcm_s16le", "-f", "wav", a_out], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return send_file(a_out, mimetype="audio/wav")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5002)

2
tts/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
flask==3.0.3
piper-tts==1.2.0