voip announcement

This commit is contained in:
javad committed 2026-09-28 19:37:19 +03:30
1 parent 38b1cf06df
commit c7993aae44
18 files changed
+679 -151

No files matched your search

+20 -1
View File
@@ -1,4 +1,5 @@
from django.contrib import admin
from django import forms
from .models import PageTitle, ProductionSeedState, SiteBranding, VoipConfiguration
@@ -31,9 +32,27 @@ class SiteBrandingAdmin(admin.ModelAdmin):
@admin.register(VoipConfiguration)
class VoipConfigurationAdmin(admin.ModelAdmin):
fields = ("voip_call_url", "updated_at")
fields = ("voip_call_url", "announcement_url", "announcement_id", "announcement_api_key", "clear_announcement_api_key", "announcement_delay_minutes", "updated_at")
readonly_fields = ("updated_at",)
class ConfigurationForm(forms.ModelForm):
announcement_api_key = forms.CharField(
required=False, widget=forms.PasswordInput,
help_text="برای حفظ کلید فعلی خالی بگذارید؛ برای تغییر، کلید جدید را وارد کنید.",
)
clear_announcement_api_key = forms.BooleanField(required=False, label="حذف کلید فعلی")
class Meta:
model = VoipConfiguration
fields = "__all__"
def clean_announcement_api_key(self):
if self.data.get("clear_announcement_api_key"):
return ""
return self.cleaned_data["announcement_api_key"] or (self.instance.announcement_api_key if self.instance.pk else "")
form = ConfigurationForm
def has_add_permission(self, request):
return not VoipConfiguration.objects.exists()
@@ -0,0 +1,33 @@
# Generated by Django 5.1.1 on 2026-09-28 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_voipconfiguration'),
]
operations = [
migrations.AddField(
model_name='voipconfiguration',
name='announcement_api_key',
field=models.CharField(blank=True, max_length=500, verbose_name='ANNOUNCEMENT_API_KEY'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_delay_minutes',
field=models.PositiveIntegerField(default=90, verbose_name='تأخیر تماس اطلاع\u200cرسانی (دقیقه)'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_id',
field=models.CharField(blank=True, max_length=200, verbose_name='ANNOUNCEMENT_ID'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_url',
field=models.URLField(blank=True, verbose_name='ANNOUNCEMENT_URL'),
),
]
+4
View File
@@ -124,6 +124,10 @@ class VoipConfiguration(SingletonConfiguration):
blank=True,
help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call",
)
announcement_url = models.URLField("ANNOUNCEMENT_URL", blank=True)
announcement_id = models.CharField("ANNOUNCEMENT_ID", max_length=200, blank=True)
announcement_api_key = models.CharField("ANNOUNCEMENT_API_KEY", max_length=500, blank=True)
announcement_delay_minutes = models.PositiveIntegerField("تأخیر تماس اطلاع‌رسانی (دقیقه)", default=90)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
+26
View File
@@ -0,0 +1,26 @@
"""Outbound prerecorded announcement calls."""
import requests
from django.conf import settings
class AnnouncementRejected(ValueError):
"""The provider explicitly refused the requested announcement."""
def place_announcement_call(configuration, *, mobile, idempotency_key):
headers = {"Idempotency-Key": idempotency_key}
if configuration.announcement_api_key:
headers["X-API-Key"] = configuration.announcement_api_key
response = requests.post(
configuration.announcement_url,
json={"customer_number": mobile, "announcement_id": configuration.announcement_id},
headers=headers,
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
)
response.raise_for_status()
if response.content:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Invalid announcement provider response")
if payload.get("success") is False:
raise AnnouncementRejected("Announcement provider rejected the request")
+1 -1
View File
@@ -49,7 +49,7 @@ def place_voip_call(*, agent, customer, method):
response.raise_for_status()
if response.content:
body = response.json()
if isinstance(body, dict) and body.get("success") is False:
if not isinstance(body, dict) or body.get("success") is False:
return VoipCallResult(False, "سرویس تماس درخواست را نپذیرفت.")
return VoipCallResult(True)
except (requests.RequestException, ValueError):
+11 -1
View File
@@ -1,7 +1,17 @@
from django.contrib import admin
from .models import NotificationJob,NotificationPolicy,SMSTemplate
from .models import AnnouncementJob,NotificationJob,NotificationPolicy,SMSTemplate
@admin.register(NotificationJob)
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","delivery_retry_source","provider_message_id","created_at"); list_filter=("channel","status","delivery_status","delivery_is_final"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","delivery_is_final","delivery_retry_source","dedupe_key","created_at","sent_at")
@admin.register(AnnouncementJob)
class AnnouncementJobAdmin(admin.ModelAdmin):
list_display = ("message", "status", "attempts", "next_attempt_at", "sent_at")
list_filter = ("status",)
search_fields = ("message__ticket__ticket_number", "message__ticket__customer__mobile")
readonly_fields = ("message", "status", "attempts", "next_attempt_at", "idempotency_key", "last_error", "created_at", "sent_at")
def has_add_permission(self, request):
return False
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at")
+94
View File
@@ -0,0 +1,94 @@
"""Process due announcement calls from the notification worker."""
import logging
from datetime import timedelta
import requests
from django.db import transaction
from django.utils import timezone
from apps.core.models import VoipConfiguration
from apps.integrations.announcements import AnnouncementRejected, place_announcement_call
from apps.messaging.models import MessageReadReceipt
from .models import AnnouncementJob
logger = logging.getLogger(__name__)
MAX_ATTEMPTS = 3
def _failure_code(exc):
if isinstance(exc, requests.Timeout):
return "service_timeout"
if isinstance(exc, requests.ConnectionError):
return "service_unreachable"
if isinstance(exc, requests.HTTPError):
return "service_http_error"
if isinstance(exc, AnnouncementRejected):
return "service_rejected"
if isinstance(exc, ValueError):
return "invalid_service_response"
return "unexpected_error"
def _cancellation_reason(job, configuration):
message = job.message
customer = message.ticket.customer
if message.is_internal or message.deleted_at or message.author.account_type != "STAFF":
return "message_unavailable"
if MessageReadReceipt.objects.filter(message=message, user=customer).exists():
return "customer_read"
if message.ticket.ticket_messages.filter(author=customer, created_at__gt=message.created_at, is_internal=False).exists():
return "customer_replied"
if not customer.is_active or not customer.local_mobile:
return "customer_mobile_unavailable"
if not configuration.announcement_url or not configuration.announcement_id:
return "announcement_not_configured"
return ""
def _deliver(job):
try:
configuration = VoipConfiguration.load()
reason = _cancellation_reason(job, configuration)
if reason:
job.status = AnnouncementJob.Status.CANCELLED
job.last_error = reason
return
place_announcement_call(
configuration,
mobile=job.message.ticket.customer.local_mobile,
idempotency_key=job.idempotency_key,
)
except Exception as exc:
job.attempts += 1
job.last_error = _failure_code(exc)
job.status = AnnouncementJob.Status.FAILED if job.attempts >= MAX_ATTEMPTS else AnnouncementJob.Status.PENDING
job.next_attempt_at = timezone.now() + timedelta(minutes=3)
logger.exception("Announcement call failed for job %s", job.pk)
else:
job.attempts += 1
job.status = AnnouncementJob.Status.SENT
job.sent_at = timezone.now()
job.last_error = ""
def process_due_announcements():
now = timezone.now()
# A crashed worker can safely retry with the same provider idempotency key.
AnnouncementJob.objects.filter(
status=AnnouncementJob.Status.PROCESSING,
next_attempt_at__lt=now - timedelta(minutes=10),
).update(status=AnnouncementJob.Status.PENDING, next_attempt_at=now)
with transaction.atomic():
jobs = list(
AnnouncementJob.objects.select_for_update(skip_locked=True)
.filter(status=AnnouncementJob.Status.PENDING, next_attempt_at__lte=now)
.select_related("message__ticket__customer")
.order_by("next_attempt_at", "pk")[:20]
)
AnnouncementJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
status=AnnouncementJob.Status.PROCESSING, next_attempt_at=now,
)
for job in jobs:
_deliver(job)
job.save(update_fields=["status", "attempts", "next_attempt_at", "sent_at", "last_error"])
@@ -14,6 +14,7 @@ from apps.integrations.bale.providers import get_bale_provider
from apps.integrations.sms.providers import get_sms_provider
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
from apps.notifications.announcement_worker import process_due_announcements
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
from apps.tickets.models import Ticket
@@ -343,6 +344,8 @@ class Command(BaseCommand):
if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
if not options.get("job_id") and not options.get("channel") and not options.get("min_id"):
process_due_announcements()
_process_delivery_checks(options)
if options["once"] or options.get("job_id"): break
time.sleep(5)
@@ -0,0 +1,32 @@
# Generated by Django 5.1.1 on 2026-09-28 15:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('messaging', '0003_messagereadreceipt'),
('notifications', '0014_backfill_safe_0098_delivery_retries'),
]
operations = [
migrations.CreateModel(
name='AnnouncementJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال تماس'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق'), ('CANCELLED', 'لغوشده')], db_index=True, default='PENDING', max_length=15)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('next_attempt_at', models.DateTimeField(db_index=True)),
('idempotency_key', models.CharField(max_length=100, unique=True)),
('last_error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='announcement_job', to='messaging.message')),
],
options={
'indexes': [models.Index(fields=['status', 'next_attempt_at'], name='announce_due_idx')],
},
),
]
+46
View File
@@ -70,3 +70,49 @@ class NotificationJob(models.Model):
)
class Meta:
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
class AnnouncementJob(models.Model):
"""One durable call reminder for each public message written by staff."""
class Status(models.TextChoices):
PENDING = "PENDING", "در صف"
PROCESSING = "PROCESSING", "در حال تماس"
SENT = "SENT", "ارسال‌شده"
FAILED = "FAILED", "ناموفق"
CANCELLED = "CANCELLED", "لغوشده"
message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job")
status = models.CharField(max_length=15, choices=Status.choices, default=Status.PENDING, db_index=True)
attempts = models.PositiveSmallIntegerField(default=0)
next_attempt_at = models.DateTimeField(db_index=True)
idempotency_key = models.CharField(max_length=100, unique=True)
last_error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [models.Index(fields=["status", "next_attempt_at"], name="announce_due_idx")]
@property
def status_label(self):
if self.status == self.Status.SENT:
return "درخواست تماس پذیرفته شد"
if self.status == self.Status.PENDING and self.attempts:
return "در انتظار تلاش مجدد"
return self.get_status_display()
@property
def detail_label(self):
return {
"customer_read": "مشتری پیام را مشاهده کرد.",
"customer_replied": "مشتری پس از پیام پاسخ داد.",
"message_unavailable": "پیام برای اطلاع‌رسانی در دسترس نیست.",
"customer_mobile_unavailable": "شماره همراه مشتری در دسترس نیست.",
"announcement_not_configured": "تنظیمات تماس اطلاع‌رسانی کامل نیست.",
"service_timeout": "سرویس تماس در مهلت مقرر پاسخ نداد.",
"service_unreachable": "ارتباط با سرویس تماس برقرار نشد.",
"service_http_error": "سرویس تماس درخواست را با خطا پاسخ داد.",
"service_rejected": "سرویس تماس درخواست را نپذیرفت.",
"invalid_service_response": "پاسخ سرویس تماس معتبر نبود.",
"unexpected_error": "خطای غیرمنتظره در پردازش درخواست تماس رخ داد.",
}.get(self.last_error, "وضعیت درخواست تماس نیاز به بررسی دارد." if self.last_error else "")
+13 -1
View File
@@ -1,10 +1,22 @@
from datetime import timedelta
from uuid import uuid4
from django.utils import timezone
from .models import NotificationJob,NotificationPolicy
from apps.core.models import VoipConfiguration
from .models import AnnouncementJob,NotificationJob,NotificationPolicy
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
def schedule_announcement(message):
"""The ticket transaction also commits this one-message reminder."""
delay = VoipConfiguration.load().announcement_delay_minutes
return AnnouncementJob.objects.get_or_create(
message=message,
defaults={
"next_attempt_at": message.created_at + timedelta(minutes=delay),
"idempotency_key": f"staff-message-{message.public_id.hex}",
},
)[0]
def enqueue_unit_supervisor_ticket_sms(ticket):
"""Queue one concise new-ticket SMS for each configured unit supervisor."""
unit = ticket.owning_unit
+3 -1
View File
@@ -7,7 +7,7 @@ from apps.attachments.services import save_attachments
from apps.core.persian_text import normalize_persian_text
from apps.messaging.models import Message,MessageMention
from apps.notifications.models import NotificationJob
from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_customer_reply_bale,schedule_staff_reply_sms
from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_announcement,schedule_customer_reply_bale,schedule_staff_reply_sms
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent
from .policies import DispatchPolicy, TicketPolicy
@@ -37,6 +37,7 @@ def create_ticket(*,customer,category,subject,selected_expert=None,body="",files
ticket.full_clean(); ticket.save(); ticket.tags.set(tags); message=Message.objects.create(ticket=ticket,author=actor,body=body); save_attachments(message,files,actor); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.CREATED,actor=actor,metadata={"staff_initiated":staff_initiated,"group_dispatch_id":group_dispatch.pk if group_dispatch else None,"unit_id":owning_unit.pk if owning_unit else None})
if staff_initiated:
enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer)
schedule_announcement(message)
else:
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
if owning_unit and owning_unit.bale_chat_id:
@@ -214,6 +215,7 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
ticket.last_staff_message_at=now; ticket.status=Ticket.Status.WAITING_CUSTOMER
if not ticket.first_response_at: ticket.first_response_at=now
schedule_staff_reply_sms(ticket,msg)
schedule_announcement(msg)
ticket.save(); return msg
@transaction.atomic
+3 -2
View File
@@ -23,7 +23,7 @@ from apps.accounts.models import TicketMagicLink, User, normalize_mobile
from apps.accounts.magic_access import get_ticket_magic_access
from apps.customers.models import ContactGroup, CustomerProfile
from apps.messaging.models import Message,MessageReadReceipt
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.notifications.models import AnnouncementJob, NotificationJob, SMSTemplate
from apps.core.iran_locations import location_data
from apps.core.persian_text import normalize_persian_text
from apps.integrations.voip import place_voip_call
@@ -444,6 +444,7 @@ def staff_detail(request,public_id):
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
announcement_jobs=list(AnnouncementJob.objects.filter(message__ticket=ticket).select_related("message").order_by("-message__created_at", "-pk"))
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
for sms_job in sms_jobs:
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
@@ -451,7 +452,7 @@ def staff_detail(request,public_id):
group_dispatch = ticket.group_dispatch
can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket")))
group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required
@require_POST
def staff_sms_retry(request,public_id,job_id):