This commit is contained in:
2026-08-24 15:54:08 +03:30
commit 236002a87c
133 changed files with 5946 additions and 0 deletions
View File
+25
View File
@@ -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")
View File
+47
View File
@@ -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
+166
View File
@@ -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
+92
View File
@@ -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})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class MessagingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.messaging"
verbose_name = "ارسال چندکاناله"
+54
View File
@@ -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')],
},
),
]
+70
View File
@@ -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}"
+11
View File
@@ -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)
+39
View File
@@ -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
+11
View File
@@ -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)
+64
View File
@@ -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)
+9
View File
@@ -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)
+63
View File
@@ -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
+16
View File
@@ -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"),
]