adding
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import BulkMessage, MessageRecipient
|
||||
|
||||
|
||||
class MessageRecipientInline(admin.TabularInline):
|
||||
model = MessageRecipient
|
||||
extra = 0
|
||||
readonly_fields = ("contact", "channel", "status", "rendered_body", "error", "updated_at")
|
||||
can_delete = False
|
||||
|
||||
|
||||
@admin.register(BulkMessage)
|
||||
class BulkMessageAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "user", "group", "channels", "status", "created_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("user__phone",)
|
||||
inlines = [MessageRecipientInline]
|
||||
|
||||
|
||||
@admin.register(MessageRecipient)
|
||||
class MessageRecipientAdmin(admin.ModelAdmin):
|
||||
list_display = ("contact", "channel", "status", "bulk_message", "updated_at")
|
||||
list_filter = ("channel", "status")
|
||||
search_fields = ("contact__name", "contact__phone")
|
||||
@@ -0,0 +1,47 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.messaging.models import BulkMessage, Channel, MessageRecipient
|
||||
|
||||
|
||||
class SendBulkMessageSerializer(serializers.Serializer):
|
||||
group_id = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
contact_ids = serializers.ListField(child=serializers.CharField(), required=False, default=list)
|
||||
template_id = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
body_override = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
channels = serializers.ListField(child=serializers.ChoiceField(choices=Channel.choices), min_length=1)
|
||||
|
||||
def validate(self, attrs):
|
||||
if not attrs.get("template_id") and not attrs.get("body_override"):
|
||||
raise serializers.ValidationError("یا template_id بده یا body_override.")
|
||||
return attrs
|
||||
|
||||
|
||||
class MessageRecipientSerializer(serializers.ModelSerializer):
|
||||
contact_name = serializers.CharField(source="contact.name", read_only=True)
|
||||
contact_phone = serializers.CharField(source="contact.phone", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MessageRecipient
|
||||
fields = [
|
||||
"id",
|
||||
"contact",
|
||||
"contact_name",
|
||||
"contact_phone",
|
||||
"channel",
|
||||
"status",
|
||||
"rendered_body",
|
||||
"error",
|
||||
]
|
||||
|
||||
|
||||
class BulkMessageSerializer(serializers.ModelSerializer):
|
||||
recipients = MessageRecipientSerializer(many=True, read_only=True)
|
||||
needs_manual = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = BulkMessage
|
||||
fields = ["id", "group", "template", "channels", "status", "created_at", "recipients", "needs_manual"]
|
||||
|
||||
def get_needs_manual(self, obj: BulkMessage):
|
||||
pending = obj.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL)
|
||||
return MessageRecipientSerializer(pending, many=True).data
|
||||
@@ -0,0 +1,166 @@
|
||||
import re
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from apps.account.models import User
|
||||
from apps.banker.models import Contact, ContactGroup, MessageTemplate
|
||||
from apps.messaging.models import BulkMessage, Channel, MessageRecipient
|
||||
from apps.messaging.providers.bale import send_bale
|
||||
from apps.messaging.providers.eitaa import send_eitaa
|
||||
from apps.messaging.providers.sms import send_bulk_sms
|
||||
from apps.messaging.providers.telegram import send_telegram
|
||||
from apps.messaging.providers.whatsapp import send_whatsapp
|
||||
|
||||
_FIELD_PATTERN = re.compile(r"\{(.*?)\}")
|
||||
|
||||
_BOT_CHANNEL_SENDERS = {
|
||||
Channel.TELEGRAM: send_telegram,
|
||||
Channel.BALE: send_bale,
|
||||
Channel.EITAA: send_eitaa,
|
||||
}
|
||||
|
||||
|
||||
class TemplateRenderer:
|
||||
@staticmethod
|
||||
def field_values(contact: Contact) -> dict[str, str]:
|
||||
"""برچسب فیلد -> مقدار، برای جایگزینی {برچسب} در متن قالب.
|
||||
|
||||
فیلدهای پایه همیشه هستند؛ فیلدهای سفارشی از گروه(های)ی میآیند که
|
||||
این مخاطب عضوشان است (ContactGroup.member_ids شامل id این مخاطب).
|
||||
"""
|
||||
values = {"نام": contact.name, "شماره": contact.phone}
|
||||
# فیلتر JSON-level (member_ids__contains) روی SQLite اصلا قابلکامپایل
|
||||
# نیست (فقط PostgreSQL پشتیبانی میکند) — چون تعداد گروههای هر کاربر
|
||||
# کم است، اینجا فیلتر را سمت پایتون انجام میدهیم تا روی هر دو دیتابیس
|
||||
# کار کند.
|
||||
groups = [
|
||||
g
|
||||
for g in ContactGroup.objects.filter(user_id=contact.user_id)
|
||||
if contact.id in g.member_ids
|
||||
]
|
||||
for group in groups:
|
||||
for field in group.fields.all():
|
||||
value = contact.field_values.get(field.id)
|
||||
if value:
|
||||
values[field.label] = value
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def render(contact: Contact, template: MessageTemplate | None, body_override: str) -> str:
|
||||
body = template.body if template else body_override
|
||||
values = TemplateRenderer.field_values(contact)
|
||||
return _FIELD_PATTERN.sub(lambda m: values.get(m.group(1), m.group(0)), body)
|
||||
|
||||
|
||||
class BulkMessageService:
|
||||
@staticmethod
|
||||
def _resolve_contacts(user: User, group_id: str | None, contact_ids: list[str] | None) -> list[Contact]:
|
||||
if contact_ids:
|
||||
return list(Contact.objects.filter(user=user, id__in=contact_ids))
|
||||
if group_id:
|
||||
group = ContactGroup.objects.get(user=user, id=group_id)
|
||||
return list(Contact.objects.filter(user=user, id__in=group.member_ids))
|
||||
return list(Contact.objects.filter(user=user))
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_and_dispatch(
|
||||
user: User,
|
||||
channels: list[str],
|
||||
group_id: str | None = None,
|
||||
contact_ids: list[str] | None = None,
|
||||
template_id: str | None = None,
|
||||
body_override: str = "",
|
||||
) -> BulkMessage:
|
||||
template = MessageTemplate.objects.filter(user=user, id=template_id).first() if template_id else None
|
||||
contacts = BulkMessageService._resolve_contacts(user, group_id, contact_ids)
|
||||
|
||||
bulk = BulkMessage.objects.create(
|
||||
user=user,
|
||||
group_id=group_id or None,
|
||||
template=template,
|
||||
body_override=body_override,
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
rendered_by_contact = {c.id: TemplateRenderer.render(c, template, body_override) for c in contacts}
|
||||
|
||||
if Channel.SMS in channels:
|
||||
BulkMessageService._dispatch_sms(bulk, contacts, rendered_by_contact)
|
||||
for channel in (Channel.TELEGRAM, Channel.BALE, Channel.EITAA):
|
||||
if channel in channels:
|
||||
BulkMessageService._dispatch_bot_channel(bulk, contacts, rendered_by_contact, channel)
|
||||
if Channel.WHATSAPP in channels:
|
||||
BulkMessageService._dispatch_whatsapp(bulk, contacts, rendered_by_contact)
|
||||
|
||||
has_manual = bulk.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL).exists()
|
||||
bulk.status = BulkMessage.Status.PARTIALLY_MANUAL if has_manual else BulkMessage.Status.DISPATCHED
|
||||
bulk.save(update_fields=["status"])
|
||||
return bulk
|
||||
|
||||
@staticmethod
|
||||
def _dispatch_sms(bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict) -> None:
|
||||
pairs = [(c.phone, rendered_by_contact[c.id]) for c in contacts]
|
||||
results = send_bulk_sms(pairs)
|
||||
MessageRecipient.objects.bulk_create(
|
||||
MessageRecipient(
|
||||
bulk_message=bulk,
|
||||
contact=c,
|
||||
channel=Channel.SMS,
|
||||
status=MessageRecipient.Status.SENT if results.get(c.phone) else MessageRecipient.Status.FAILED,
|
||||
rendered_body=rendered_by_contact[c.id],
|
||||
)
|
||||
for c in contacts
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _dispatch_bot_channel(
|
||||
bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict, channel: str
|
||||
) -> None:
|
||||
sender = _BOT_CHANNEL_SENDERS[channel]
|
||||
recipients = []
|
||||
for c in contacts:
|
||||
chat_id = c.channel_ids.get(channel)
|
||||
body = rendered_by_contact[c.id]
|
||||
if not chat_id:
|
||||
status = MessageRecipient.Status.NEEDS_MANUAL
|
||||
else:
|
||||
status = MessageRecipient.Status.SENT if sender(chat_id, body) else MessageRecipient.Status.FAILED
|
||||
recipients.append(
|
||||
MessageRecipient(
|
||||
bulk_message=bulk, contact=c, channel=channel, status=status, rendered_body=body
|
||||
)
|
||||
)
|
||||
MessageRecipient.objects.bulk_create(recipients)
|
||||
|
||||
@staticmethod
|
||||
def _dispatch_whatsapp(bulk: BulkMessage, contacts: list[Contact], rendered_by_contact: dict) -> None:
|
||||
from django.conf import settings
|
||||
|
||||
recipients = []
|
||||
configured = bool(settings.WHATSAPP_ACCESS_TOKEN and settings.WHATSAPP_PHONE_NUMBER_ID)
|
||||
for c in contacts:
|
||||
body = rendered_by_contact[c.id]
|
||||
if not configured:
|
||||
status = MessageRecipient.Status.NEEDS_MANUAL
|
||||
else:
|
||||
status = (
|
||||
MessageRecipient.Status.SENT
|
||||
if send_whatsapp(c.phone, body)
|
||||
else MessageRecipient.Status.FAILED
|
||||
)
|
||||
recipients.append(
|
||||
MessageRecipient(
|
||||
bulk_message=bulk, contact=c, channel=Channel.WHATSAPP, status=status, rendered_body=body
|
||||
)
|
||||
)
|
||||
MessageRecipient.objects.bulk_create(recipients)
|
||||
|
||||
@staticmethod
|
||||
def confirm_manual(user: User, bulk_id: int, recipient_id: int) -> MessageRecipient:
|
||||
recipient = MessageRecipient.objects.get(
|
||||
id=recipient_id, bulk_message_id=bulk_id, bulk_message__user=user
|
||||
)
|
||||
recipient.status = MessageRecipient.Status.MANUAL_CONFIRMED
|
||||
recipient.save(update_fields=["status"])
|
||||
return recipient
|
||||
@@ -0,0 +1,92 @@
|
||||
from django.db.models import Count
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.generics import RetrieveAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.banker.models import ContactGroup
|
||||
from apps.billing.permissions import HasActiveFeature
|
||||
from apps.messaging.models import BulkMessage, MessageRecipient
|
||||
|
||||
from .serializers import BulkMessageSerializer, SendBulkMessageSerializer
|
||||
from .services import BulkMessageService
|
||||
|
||||
|
||||
class SendBulkMessageView(APIView):
|
||||
"""POST: میسازد و بلافاصله dispatch میکند (پیامک/کانالهای Business-API-دار
|
||||
خودکار، بقیه needs_manual برای صف deep-link سمت اپ)."""
|
||||
|
||||
permission_classes = [IsAuthenticated, HasActiveFeature]
|
||||
feature_key = "sms_bank"
|
||||
throttle_scope = "messaging_send"
|
||||
|
||||
def post(self, request):
|
||||
serializer = SendBulkMessageSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
if data["group_id"] and not ContactGroup.objects.filter(user=request.user, id=data["group_id"]).exists():
|
||||
raise ValidationError({"group_id": "گروه یافت نشد."})
|
||||
|
||||
bulk = BulkMessageService.create_and_dispatch(
|
||||
user=request.user,
|
||||
channels=data["channels"],
|
||||
group_id=data["group_id"],
|
||||
contact_ids=data["contact_ids"],
|
||||
template_id=data["template_id"],
|
||||
body_override=data["body_override"],
|
||||
)
|
||||
return Response(BulkMessageSerializer(bulk).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class BulkMessageDetailView(RetrieveAPIView):
|
||||
serializer_class = BulkMessageSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return BulkMessage.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
class MessagingStatsView(APIView):
|
||||
"""آمار ارسال: چند پیام موفق به گیتوی/API فرستاده شده، چند تا ناموفق
|
||||
بوده، چند تا نیاز به تایید دستی داشته/دارد — بر اساس MessageRecipient
|
||||
(نه رسید تحویل واقعی گوشی، چون آن نیاز به webhook از سرویسدهندهی
|
||||
پیامک دارد که در این نسخه وصل نشده؛ «ارسالشده» یعنی سرور با موفقیت آن
|
||||
را به API/گیتوی تحویل داده)."""
|
||||
|
||||
permission_classes = [IsAuthenticated, HasActiveFeature]
|
||||
feature_key = "sms_bank"
|
||||
|
||||
def get(self, request):
|
||||
rows = (
|
||||
MessageRecipient.objects.filter(bulk_message__user=request.user)
|
||||
.values("status")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
counts = {row["status"]: row["count"] for row in rows}
|
||||
total = sum(counts.values())
|
||||
return Response(
|
||||
{
|
||||
"total": total,
|
||||
"sent": counts.get(MessageRecipient.Status.SENT, 0),
|
||||
"failed": counts.get(MessageRecipient.Status.FAILED, 0),
|
||||
"needs_manual": counts.get(MessageRecipient.Status.NEEDS_MANUAL, 0),
|
||||
"manual_confirmed": counts.get(MessageRecipient.Status.MANUAL_CONFIRMED, 0),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConfirmManualSendView(APIView):
|
||||
"""اپ بعد از اینکه کاربر دستی در واتساپ/تلگرام/بله/ایتا زد «ارسال»، این را صدا میزند."""
|
||||
|
||||
permission_classes = [IsAuthenticated, HasActiveFeature]
|
||||
feature_key = "sms_bank"
|
||||
|
||||
def post(self, request, bulk_id, recipient_id):
|
||||
try:
|
||||
recipient = BulkMessageService.confirm_manual(request.user, bulk_id, recipient_id)
|
||||
except MessageRecipient.DoesNotExist:
|
||||
raise NotFound("گیرنده یافت نشد.")
|
||||
return Response({"success": True, "status": recipient.status})
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MessagingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.messaging"
|
||||
verbose_name = "ارسال چندکاناله"
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-22 15:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('banker', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BulkMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('body_override', models.TextField(blank=True, verbose_name='متن آزاد (اگر قالب انتخاب نشده)')),
|
||||
('channels', models.JSONField(default=list, verbose_name='کانال\u200cهای انتخاب\u200cشده')),
|
||||
('status', models.CharField(choices=[('dispatched', 'ارسال\u200cشده'), ('partially_manual', 'بخشی نیاز به تایید دستی')], default='dispatched', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.contactgroup')),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.messagetemplate')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ارسال گروهی',
|
||||
'verbose_name_plural': 'ارسال\u200cهای گروهی',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageRecipient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('channel', models.CharField(choices=[('sms', 'پیامک'), ('whatsapp', 'واتساپ'), ('telegram', 'تلگرام'), ('bale', 'بله'), ('eitaa', 'ایتا')], max_length=10)),
|
||||
('status', models.CharField(choices=[('sent', 'ارسال\u200cشده'), ('failed', 'ناموفق'), ('needs_manual', 'نیاز به تایید دستی'), ('manual_confirmed', 'تایید دستی شد')], max_length=20)),
|
||||
('rendered_body', models.TextField()),
|
||||
('error', models.CharField(blank=True, max_length=255)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('bulk_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='messaging.bulkmessage')),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='banker.contact')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'گیرنده\u200cی پیام',
|
||||
'verbose_name_plural': 'گیرنده\u200cهای پیام',
|
||||
'indexes': [models.Index(fields=['bulk_message', 'status'], name='messaging_m_bulk_me_a7a1eb_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.banker.models import Contact, ContactGroup, MessageTemplate
|
||||
|
||||
# این اپ فقط از مسیرهای رسمی هر پلتفرم برای ارسال استفاده میکند: پیامک از
|
||||
# طریق درگاه بالک کاوهنگار (سمت سرور، خودکار)، واتساپ از طریق WhatsApp
|
||||
# Business Cloud API رسمی، تلگرام/بله/ایتا از طریق Bot API رسمی هرکدام —
|
||||
# فقط برای مخاطبینی که قبلا چت با بات را شروع کردهاند (opt-in). برای بقیهی
|
||||
# موارد، MessageRecipient با status=needs_manual برمیگردد تا اپ یک صف
|
||||
# deep-link یکلمسی به کاربر نشان دهد؛ هیچ اتوماسیون/شبیهسازی رفتار انسانی
|
||||
# برای دور زدن ضدهرزنامهی این پلتفرمها در این اپ وجود ندارد.
|
||||
|
||||
|
||||
class Channel(models.TextChoices):
|
||||
SMS = "sms", _("پیامک")
|
||||
WHATSAPP = "whatsapp", _("واتساپ")
|
||||
TELEGRAM = "telegram", _("تلگرام")
|
||||
BALE = "bale", _("بله")
|
||||
EITAA = "eitaa", _("ایتا")
|
||||
|
||||
|
||||
class BulkMessage(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
DISPATCHED = "dispatched", _("ارسالشده")
|
||||
PARTIALLY_MANUAL = "partially_manual", _("بخشی نیاز به تایید دستی")
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bulk_messages")
|
||||
# null = همهی مخاطبین کاربر
|
||||
group = models.ForeignKey(ContactGroup, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
||||
template = models.ForeignKey(
|
||||
MessageTemplate, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
|
||||
)
|
||||
body_override = models.TextField(blank=True, verbose_name=_("متن آزاد (اگر قالب انتخاب نشده)"))
|
||||
channels = models.JSONField(default=list, verbose_name=_("کانالهای انتخابشده"))
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DISPATCHED)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("ارسال گروهی")
|
||||
verbose_name_plural = _("ارسالهای گروهی")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.pk} — {self.user.phone} — {', '.join(self.channels)}"
|
||||
|
||||
|
||||
class MessageRecipient(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
SENT = "sent", _("ارسالشده")
|
||||
FAILED = "failed", _("ناموفق")
|
||||
NEEDS_MANUAL = "needs_manual", _("نیاز به تایید دستی")
|
||||
MANUAL_CONFIRMED = "manual_confirmed", _("تایید دستی شد")
|
||||
|
||||
bulk_message = models.ForeignKey(BulkMessage, on_delete=models.CASCADE, related_name="recipients")
|
||||
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="+")
|
||||
channel = models.CharField(max_length=10, choices=Channel.choices)
|
||||
status = models.CharField(max_length=20, choices=Status.choices)
|
||||
rendered_body = models.TextField()
|
||||
error = models.CharField(max_length=255, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("گیرندهی پیام")
|
||||
verbose_name_plural = _("گیرندههای پیام")
|
||||
indexes = [models.Index(fields=["bulk_message", "status"])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.contact.name} — {self.channel} — {self.status}"
|
||||
@@ -0,0 +1,11 @@
|
||||
# بله Bot API تلگرامسازگار است. اگر base_url حساب تو فرق دارد، همین یک
|
||||
# مقدار را عوض کن — بقیهی منطق در bot_api.py مشترک است.
|
||||
from django.conf import settings
|
||||
|
||||
from .bot_api import send_bot_message
|
||||
|
||||
BALE_BASE_URL = "https://tapi.bale.ai"
|
||||
|
||||
|
||||
def send_bale(chat_id: str, text: str) -> bool:
|
||||
return send_bot_message(BALE_BASE_URL, settings.BALE_BOT_TOKEN, chat_id, text)
|
||||
@@ -0,0 +1,39 @@
|
||||
# apps/messaging/providers/bot_api.py
|
||||
"""
|
||||
پایهی مشترک ارسال از طریق Bot API — تلگرام، بله و ایتا هرکدام یک Bot API
|
||||
تلگرامسازگار دارند (endpoint یکسان: POST {base}/bot{token}/sendMessage با
|
||||
بدنهی {chat_id, text}). این یعنی این سه پلتفرم فقط base_url و token
|
||||
متفاوت دارند؛ منطق ارسال یکی است.
|
||||
|
||||
نکتهی مهم — opt-in: بات فقط میتواند به chat_id هایی پیام بدهد که قبلا
|
||||
خودشان چت با بات را با /start شروع کردهاند (دقیقا مثل قانون خودِ تلگرام).
|
||||
یعنی این تابع فقط برای مخاطبینی کار میکند که chat_id شان قبلا (از طریق
|
||||
webhook خود بات، خارج از این فایل) در Contact.channel_ids ثبت شده باشد؛ برای
|
||||
بقیه، صدازننده باید needs_manual برگرداند — نه اینکه اینجا حدس بزند.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def send_bot_message(base_url: str, token: str, chat_id: str, text: str) -> bool:
|
||||
if not base_url or not token:
|
||||
logger.error("توکن یا آدرس Bot API تنظیم نشده است")
|
||||
return False
|
||||
|
||||
url = f"{base_url.rstrip('/')}/bot{token}/sendMessage"
|
||||
try:
|
||||
response = requests.post(
|
||||
url, json={"chat_id": chat_id, "text": text}, timeout=REQUEST_TIMEOUT_SECONDS
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
return bool(body.get("ok", True))
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error(f"❌ خطا در ارسال Bot API به {url}: {exc}")
|
||||
return False
|
||||
@@ -0,0 +1,11 @@
|
||||
# ایتا هم یک Bot API تلگرامسازگار (Eitaa Payam) دارد. اگر ساختار حساب تو
|
||||
# فرق دارد (مثلا آدرس دیگری برای endpoint)، همین base_url را عوض کن.
|
||||
from django.conf import settings
|
||||
|
||||
from .bot_api import send_bot_message
|
||||
|
||||
EITAA_BASE_URL = "https://eitaayar.ir/api"
|
||||
|
||||
|
||||
def send_eitaa(chat_id: str, text: str) -> bool:
|
||||
return send_bot_message(EITAA_BASE_URL, settings.EITAA_BOT_TOKEN, chat_id, text)
|
||||
@@ -0,0 +1,64 @@
|
||||
# apps/messaging/providers/sms.py
|
||||
"""
|
||||
ارسال پیامک گروهی با متن آزاد (نه پترن OTP) — از همان کاوهنگاری که
|
||||
apps/account/send_sms.py برای OTP استفاده میکند، ولی از API متفاوتی: پترن
|
||||
OTP فقط اجازهی ارسال یک قالب از پیش تاییدشده را میدهد، برای متن دلخواه باید
|
||||
از API عادی ارسال (sms/sendarray.json برای چندگیرندهی همزمان با متنهای
|
||||
مجزا) استفاده کرد. این دو مسیر کاملا مستقلاند؛ send_sms.py دستنخورده ماند.
|
||||
|
||||
فقط دو مقدار از .env لازم است: KAVENEGAR_API_KEY و KAVENEGAR_SENDER_LINE.
|
||||
اگر ساختار پاسخ/درخواست کاوهنگار حساب تو فرق دارد، فقط تابع
|
||||
`_call_sendarray` را با مستندات https://kavenegar.com/rest.html هماهنگ کن.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_TIMEOUT_SECONDS = 15
|
||||
KAVENEGAR_BASE = "https://api.kavenegar.com/v1"
|
||||
|
||||
|
||||
def _call_sendarray(receptors: list[str], messages: list[str]) -> dict[str, bool]:
|
||||
"""ارسال بالک؛ خروجی: {receptor: True/False} برای هر گیرنده."""
|
||||
api_key = settings.KAVENEGAR_API_KEY
|
||||
sender = settings.KAVENEGAR_SENDER_LINE
|
||||
result = {r: False for r in receptors}
|
||||
|
||||
if not api_key or not sender:
|
||||
logger.error("KAVENEGAR_API_KEY یا KAVENEGAR_SENDER_LINE در .env تنظیم نشده است")
|
||||
return result
|
||||
|
||||
url = f"{KAVENEGAR_BASE}/{api_key}/sms/sendarray.json"
|
||||
payload = {
|
||||
"receptor": json.dumps(receptors),
|
||||
"sender": json.dumps([sender] * len(receptors)),
|
||||
"message": json.dumps(messages),
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=payload, timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if body.get("return", {}).get("status") == 200:
|
||||
for r in receptors:
|
||||
result[r] = True
|
||||
else:
|
||||
logger.error(f"❌ کاوهنگار خطا برگرداند: {body.get('return')}")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error(f"❌ خطا در ارسال پیامک بالک: {exc}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def send_bulk_sms(recipients: list[tuple[str, str]]) -> dict[str, bool]:
|
||||
"""recipients: [(phone, message), ...] -> {phone: sent_ok}"""
|
||||
if not recipients:
|
||||
return {}
|
||||
phones = [p for p, _ in recipients]
|
||||
messages = [m for _, m in recipients]
|
||||
return _call_sendarray(phones, messages)
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.conf import settings
|
||||
|
||||
from .bot_api import send_bot_message
|
||||
|
||||
TELEGRAM_BASE_URL = "https://api.telegram.org"
|
||||
|
||||
|
||||
def send_telegram(chat_id: str, text: str) -> bool:
|
||||
return send_bot_message(TELEGRAM_BASE_URL, settings.TELEGRAM_BOT_TOKEN, chat_id, text)
|
||||
@@ -0,0 +1,63 @@
|
||||
# apps/messaging/providers/whatsapp.py
|
||||
"""
|
||||
ارسال از طریق WhatsApp Business Cloud API رسمی (Meta) —
|
||||
https://developers.facebook.com/docs/whatsapp/cloud-api
|
||||
|
||||
برای ارسال گروهی/تبلیغاتی (business-initiated، نه پاسخ به پیام کاربر)،
|
||||
واتساپ فقط اجازهی ارسال «Template Message» از قبل تاییدشده را میدهد؛ پیام
|
||||
آزاد فقط داخل پنجرهی ۲۴ ساعتهی پاسخ به مشتری مجاز است. یعنی این تابع یک
|
||||
Template با یک متغیر ({{1}}) صدا میزند که همان متن رندرشدهی قالب داخل اپ
|
||||
در آن جای میگیرد — این Template باید از قبل در Meta Business Manager ثبت و
|
||||
تایید شده باشد.
|
||||
|
||||
سه مقدار از .env لازم است: WHATSAPP_ACCESS_TOKEN، WHATSAPP_PHONE_NUMBER_ID،
|
||||
WHATSAPP_TEMPLATE_NAME. اگر ساختار Template حساب تو (تعداد متغیرها، زبان)
|
||||
فرق دارد، فقط بدنهی payload در send_whatsapp را هماهنگ کن.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_TIMEOUT_SECONDS = 15
|
||||
GRAPH_API_VERSION = "v20.0"
|
||||
|
||||
|
||||
def send_whatsapp(phone: str, text: str) -> bool:
|
||||
token = settings.WHATSAPP_ACCESS_TOKEN
|
||||
phone_number_id = settings.WHATSAPP_PHONE_NUMBER_ID
|
||||
template_name = settings.WHATSAPP_TEMPLATE_NAME
|
||||
|
||||
if not token or not phone_number_id or not template_name:
|
||||
logger.error("تنظیمات WhatsApp Business API کامل نیست")
|
||||
return False
|
||||
|
||||
url = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{phone_number_id}/messages"
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": phone,
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": template_name,
|
||||
"language": {"code": "fa"},
|
||||
"components": [
|
||||
{"type": "body", "parameters": [{"type": "text", "text": text}]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"❌ خطا در ارسال WhatsApp به {phone}: {exc}")
|
||||
return False
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.urls import path
|
||||
|
||||
from .api.views import (
|
||||
BulkMessageDetailView,
|
||||
ConfirmManualSendView,
|
||||
MessagingStatsView,
|
||||
SendBulkMessageView,
|
||||
)
|
||||
|
||||
app_name = "messaging"
|
||||
urlpatterns = [
|
||||
path("send/", SendBulkMessageView.as_view(), name="send"),
|
||||
path("stats/", MessagingStatsView.as_view(), name="stats"),
|
||||
path("<int:pk>/", BulkMessageDetailView.as_view(), name="detail"),
|
||||
path("<int:bulk_id>/confirm/<int:recipient_id>/", ConfirmManualSendView.as_view(), name="confirm"),
|
||||
]
|
||||
Reference in New Issue
Block a user