MicProtocol / apps /api /src /network /sms.service.ts
Benaah's picture
Upload folder using huggingface_hub
6abfd9d verified
Raw History Blame Contribute Delete
23.6 kB
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
/**
* SMS/WhatsApp Message Templates
* Localized for African markets
*/
const MESSAGE_TEMPLATES = {
// Booking request notification
bookingRequest: {
en: (data: { podcastName: string; message: string; actionUrl: string }) =>
`🎙️ New booking request from ${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nReply YES to accept or visit: ${data.actionUrl}`,
sw: (data: { podcastName: string; message: string; actionUrl: string }) =>
`🎙️ Ombi jipya la kuhudhuria kutoka ${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nJibu NDIO kukubali au tembelea: ${data.actionUrl}`,
yo: (data: { podcastName: string; message: string; actionUrl: string }) =>
`🎙️ Ìbéèrè tuntun láti ${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nDáhùn BÉÈNI láti gbà tàbí ṣàbẹ̀wò: ${data.actionUrl}`,
zu: (data: { podcastName: string; message: string; actionUrl: string }) =>
`🎙️ Isicelo esisha esivela ku-${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nPhendula YE ukuvuma noma uvakashele: ${data.actionUrl}`,
},
// Booking accepted notification
bookingAccepted: {
en: (data: { guestName: string; podcastName: string }) =>
`✅ ${data.guestName} has accepted your booking request for ${data.podcastName}! Check your email for next steps.`,
sw: (data: { guestName: string; podcastName: string }) =>
`✅ ${data.guestName} amekubali ombi lako la kuhudhuria kwa ${data.podcastName}! Angalia barua pepe yako kwa hatua zinazofuata.`,
yo: (data: { guestName: string; podcastName: string }) =>
`✅ ${data.guestName} ti gba ibeere rẹ fun ${data.podcastName}! Ṣayẹwo imeeli rẹ fun awọn igbesẹ atẹle.`,
zu: (data: { guestName: string; podcastName: string }) =>
`✅ U-${data.guestName} usamukele isicelo sakho sokubhukha se-${data.podcastName}! Bheka i-imeyli yakho ukuze uthole izinyathelo ezilandelayo.`,
},
// Booking declined notification
bookingDeclined: {
en: (data: { guestName: string; podcastName: string }) =>
`❌ ${data.guestName} is not available for ${data.podcastName} at this time. Try another guest from the network.`,
sw: (data: { guestName: string; podcastName: string }) =>
`❌ ${data.guestName} hapatikani kwa ${data.podcastName} kwa sasa. Jaribu mgeni mwingine kutoka kwa mtandao.`,
yo: (data: { guestName: string; podcastName: string }) =>
`❌ ${data.guestName} ko si fun ${data.podcastName} ni akoko yii. Gbiyanju alejo miiran lati nẹtiwọọki.`,
zu: (data: { guestName: string; podcastName: string }) =>
`❌ U-${data.guestName} akatholakali ku-${data.podcastName} okwamanje. Zama enye isivakashi kusuka kunethiwekhi.`,
},
// Reminder notification
reminder: {
en: (data: { type: string; time: string; actionUrl: string }) =>
`⏰ Reminder: ${data.type} scheduled for ${data.time}. ${data.actionUrl}`,
sw: (data: { type: string; time: string; actionUrl: string }) =>
`⏰ Ukumbusho: ${data.type} umepangwa kwa ${data.time}. ${data.actionUrl}`,
yo: (data: { type: string; time: string; actionUrl: string }) =>
`⏰ Ìránnilétí: ${data.type} ti ṣeto fun ${data.time}. ${data.actionUrl}`,
zu: (data: { type: string; time: string; actionUrl: string }) =>
`⏰ Isikhumbuzo: U-${data.type} uhlelelwe u-${data.time}. ${data.actionUrl}`,
},
// Profile view notification (premium feature)
profileView: {
en: (data: { count: number }) =>
`👀 Your profile has ${data.count} new views this week! Upgrade to see who's interested.`,
sw: (data: { count: number }) =>
`👀 Profaili yako ina maoni ${data.count} mapya wiki hii! Pandisha hadhi ili uone nani ana nia.`,
yo: (data: { count: number }) =>
`👀 Profaili rẹ ni awọn iwo ${data.count} tuntun ni ọsẹ yii! Ṣe igbesoke lati ri ẹni ti o nifẹ.`,
zu: (data: { count: number }) =>
`👀 Iphrofayili yakho inokubukwa okusha oku-${data.count} kuleli sonto! Thuthukisa ukuze ubone ukuthi ubani onentshisekelo.`,
},
// USSD fallback for basic phones
ussdPrompt: {
en: () =>
`MicProtocol Network: Press 1 for pending requests, 2 for new bookings, 3 for your stats`,
sw: () =>
`Mtandao wa MicProtocol: Bonyeza 1 kwa maombi yaliyosubiri, 2 kwa uhifadhi mpya, 3 kwa takwimu zako`,
yo: () =>
`Nẹtiwọọki MicProtocol: Tẹ 1 fun awọn ibeere isunmọtẹlẹ, 2 fun awọn ifiṣura tuntun, 3 fun awọn iṣiro rẹ`,
zu: () =>
`Inethiwekhi ye-MicProtocol: Cindezela 1 ukuze uthole izicelo ezilindile, 2 ukuze uthole ukubhukha okusha, 3 ukuze uthole izibalo zakho`,
},
};
/**
* SMS/WhatsApp Service
*
* Handles mobile notifications for the African market context:
* - WhatsApp Business API integration (primary)
* - SMS fallback for non-smartphone users
* - USSD codes for feature phones
* - Mobile Money integration hooks
* - Multi-language support (English, Swahili, Yoruba, Zulu)
*/
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
private readonly twilioEnabled: boolean;
private readonly twilioAccountSid: string | undefined;
private readonly twilioAuthToken: string | undefined;
private readonly twilioPhoneNumber: string | undefined;
private readonly whatsappEnabled: boolean;
private readonly africasTalkingEnabled: boolean;
private readonly africasTalkingFrom: string | undefined;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.twilioEnabled = this.configService.get<boolean>("TWILIO_ENABLED") || false;
this.twilioAccountSid = this.configService.get<string>("TWILIO_ACCOUNT_SID");
this.twilioAuthToken = this.configService.get<string>("TWILIO_AUTH_TOKEN");
this.twilioPhoneNumber = this.configService.get<string>("TWILIO_PHONE_NUMBER");
this.whatsappEnabled = this.configService.get<boolean>("WHATSAPP_ENABLED") || false;
this.africasTalkingEnabled = this.configService.get<boolean>("AFRICAS_TALKING_ENABLED") || false;
this.africasTalkingFrom = this.configService.get<string>("AFRICAS_TALKING_FROM");
}
/**
* Send booking request notification
* Primary channel: WhatsApp, Fallback: SMS
*/
async sendBookingRequest(
to: string,
data: {
podcastName: string;
message: string;
actionUrl: string;
},
language = "en"
): Promise<{ success: boolean; messageId?: string; channel: string }> {
const template = MESSAGE_TEMPLATES.bookingRequest[language as keyof typeof MESSAGE_TEMPLATES.bookingRequest]
|| MESSAGE_TEMPLATES.bookingRequest.en;
const message = template(data);
// Try WhatsApp first
if (this.whatsappEnabled) {
try {
const result = await this.sendWhatsApp(to, message);
return { success: true, messageId: result.messageId, channel: "whatsapp" };
} catch (error) {
this.logger.warn(`WhatsApp failed, falling back to SMS: ${error}`);
}
}
// Fallback to SMS
try {
const result = await this.sendSMS(to, message);
return { success: true, messageId: result.messageId, channel: "sms" };
} catch (error) {
this.logger.error(`SMS failed: ${error}`);
return { success: false, channel: "none" };
}
}
/**
* Send booking response notification (to host)
*/
async sendBookingResponse(
to: string,
data: {
guestName: string;
podcastName: string;
accepted: boolean;
},
language = "en"
): Promise<{ success: boolean }> {
const template = data.accepted
? MESSAGE_TEMPLATES.bookingAccepted[language as keyof typeof MESSAGE_TEMPLATES.bookingAccepted] || MESSAGE_TEMPLATES.bookingAccepted.en
: MESSAGE_TEMPLATES.bookingDeclined[language as keyof typeof MESSAGE_TEMPLATES.bookingDeclined] || MESSAGE_TEMPLATES.bookingDeclined.en;
const message = template(data);
try {
if (this.whatsappEnabled) {
await this.sendWhatsApp(to, message);
} else {
await this.sendSMS(to, message);
}
return { success: true };
} catch (error) {
this.logger.error(`Failed to send booking response: ${error}`);
return { success: false };
}
}
/**
* Send reminder notification
*/
async sendReminder(
to: string,
data: {
type: string;
time: string;
actionUrl: string;
},
language = "en"
): Promise<{ success: boolean }> {
const template = MESSAGE_TEMPLATES.reminder[language as keyof typeof MESSAGE_TEMPLATES.reminder] || MESSAGE_TEMPLATES.reminder.en;
const message = template(data);
try {
if (this.whatsappEnabled) {
await this.sendWhatsApp(to, message);
} else {
await this.sendSMS(to, message);
}
return { success: true };
} catch (error) {
this.logger.error(`Failed to send reminder: ${error}`);
return { success: false };
}
}
/**
* Send profile view notification (weekly digest)
*/
async sendProfileViewNotification(
to: string,
viewCount: number,
language = "en"
): Promise<{ success: boolean }> {
const template = MESSAGE_TEMPLATES.profileView[language as keyof typeof MESSAGE_TEMPLATES.profileView] || MESSAGE_TEMPLATES.profileView.en;
const message = template({ count: viewCount });
try {
// This is a non-urgent notification, use SMS to save costs
await this.sendSMS(to, message);
return { success: true };
} catch (error) {
this.logger.error(`Failed to send view notification: ${error}`);
return { success: false };
}
}
/**
* Send WhatsApp message via Twilio WhatsApp Business API
*/
private async sendWhatsApp(
to: string,
message: string
): Promise<{ messageId: string }> {
if (!this.twilioEnabled || !this.twilioAccountSid || !this.twilioAuthToken) {
this.logger.log(`[WHATSAPP MOCK] To: ${to}, Message: ${message.substring(0, 50)}...`);
return { messageId: `mock_${Date.now()}` };
}
// Format number for WhatsApp (E.164)
const formattedTo = this.formatPhoneNumber(to, true);
const from = `whatsapp:${this.twilioPhoneNumber}`;
try {
const response = await firstValueFrom(
this.httpService.post(
`https://api.twilio.com/2010-04-01/Accounts/${this.twilioAccountSid}/Messages.json`,
new URLSearchParams({
To: formattedTo,
From: from,
Body: message,
}),
{
auth: {
username: this.twilioAccountSid,
password: this.twilioAuthToken,
},
}
)
);
return { messageId: (response.data as any).sid };
} catch (error) {
this.logger.error("WhatsApp send failed", error);
throw error;
}
}
/**
* Send SMS via Twilio or Africa's Talking
*/
private async sendSMS(
to: string,
message: string
): Promise<{ messageId: string }> {
if (this.africasTalkingEnabled) {
return this.sendSMSViaAfricasTalking(to, message);
}
if (!this.twilioEnabled || !this.twilioAccountSid || !this.twilioAuthToken) {
this.logger.log(`[SMS MOCK] To: ${to}, Message: ${message.substring(0, 50)}...`);
return { messageId: `mock_${Date.now()}` };
}
const formattedTo = this.formatPhoneNumber(to, false);
try {
const response = await firstValueFrom(
this.httpService.post(
`https://api.twilio.com/2010-04-01/Accounts/${this.twilioAccountSid}/Messages.json`,
new URLSearchParams({
To: formattedTo,
From: this.twilioPhoneNumber!,
Body: message,
}),
{
auth: {
username: this.twilioAccountSid,
password: this.twilioAuthToken,
},
}
)
);
return { messageId: (response.data as any).sid };
} catch (error) {
this.logger.error("SMS send failed", error);
throw error;
}
}
/**
* Send SMS via Africa's Talking (optimized for African markets)
*/
private async sendSMSViaAfricasTalking(
to: string,
message: string
): Promise<{ messageId: string }> {
const apiKey = this.configService.get<string>("AFRICAS_TALKING_API_KEY");
const username = this.configService.get<string>("AFRICAS_TALKING_USERNAME") || "sandbox";
if (!apiKey) {
this.logger.log(`[AFRICAS_TALKING MOCK] To: ${to}, Message: ${message.substring(0, 50)}...`);
return { messageId: `mock_at_${Date.now()}` };
}
const formattedTo = this.formatPhoneNumber(to, false);
const isSandbox = username.toLowerCase() === "sandbox";
const url = isSandbox
? "https://api.sandbox.africastalking.com/version1/messaging"
: "https://api.africastalking.com/version1/messaging";
const params: Record<string, string> = {
username,
to: formattedTo,
message,
};
if (this.africasTalkingFrom) {
params.from = this.africasTalkingFrom;
}
try {
const response = await firstValueFrom(
this.httpService.post(
url,
new URLSearchParams(params),
{
headers: {
apiKey,
Accept: "application/json",
},
}
)
);
const data = response.data as any;
return { messageId: data.SMSMessageData.Recipients[0].messageId };
} catch (error) {
this.logger.error("Africa's Talking SMS failed", error);
throw error;
}
}
/**
* Format phone number to E.164
*/
private formatPhoneNumber(phone: string, forWhatsApp: boolean): string {
// Remove all non-numeric characters
let cleaned = phone.replace(/\D/g, "");
// Add country code if missing (assume African numbers)
if (cleaned.length < 10) {
// Probably a local number, need to add country code
// Default to Kenya (+254) if starts with 0
if (cleaned.startsWith("0")) {
cleaned = "254" + cleaned.substring(1);
}
}
// Ensure it starts with +
if (!cleaned.startsWith("+")) {
cleaned = "+" + cleaned;
}
// WhatsApp format requires "whatsapp:" prefix
if (forWhatsApp) {
return `whatsapp:${cleaned}`;
}
return cleaned;
}
/**
* Detect language based on phone number country code
*/
detectLanguage(phoneNumber: string): string {
const countryCode = this.extractCountryCode(phoneNumber);
const languageMap: Record<string, string> = {
"254": "sw", // Kenya - Swahili
"255": "sw", // Tanzania - Swahili
"234": "yo", // Nigeria - Yoruba (or Igbo, Hausa)
"233": "en", // Ghana - English
"27": "zu", // South Africa - Zulu (or Afrikaans, Xhosa)
"256": "en", // Uganda - English
"250": "en", // Rwanda - English
"251": "en", // Ethiopia - English
"20": "en", // Egypt - English
"212": "en", // Morocco - English
};
return languageMap[countryCode] || "en";
}
/**
* Extract country code from phone number
*/
private extractCountryCode(phone: string): string {
const cleaned = phone.replace(/\D/g, "");
// Try common African country codes (longer first)
const codes = ["254", "255", "234", "233", "27", "256", "250", "251", "20", "212"];
for (const code of codes) {
if (cleaned.startsWith(code) || cleaned.startsWith("0" + code)) {
return code;
}
}
// Try with + prefix removed
const withoutPlus = cleaned.replace(/^\+/, "");
for (const code of codes) {
if (withoutPlus.startsWith(code)) {
return code;
}
}
return "";
}
/**
* Validate phone number format
*/
validatePhoneNumber(phone: string): boolean {
// Basic validation for African phone numbers
const cleaned = phone.replace(/\D/g, "");
// Should be 9-15 digits (including country code)
if (cleaned.length < 9 || cleaned.length > 15) {
return false;
}
// Check for valid African country codes
const validCodes = ["254", "255", "234", "233", "27", "256", "250", "251", "20", "212", "267", "265", "258", "260"];
const withoutPlus = cleaned.replace(/^\+/, "");
return validCodes.some(code => withoutPlus.startsWith(code));
}
/**
* Process USSD request
* For feature phone users: *384*88#
*/
async processUSSD(
sessionId: string,
phoneNumber: string,
text: string
): Promise<string> {
this.logger.log(`USSD request from ${phoneNumber}: ${text}`);
const lang = this.detectLanguage(phoneNumber);
const promptTemplate = MESSAGE_TEMPLATES.ussdPrompt[lang as keyof typeof MESSAGE_TEMPLATES.ussdPrompt] || MESSAGE_TEMPLATES.ussdPrompt.en;
const responses = {
"": promptTemplate(),
"1": await this.getPendingRequestsUSSD(phoneNumber),
"2": "CON New bookings:\n1. Search guests\n2. My bookmarks\n3. Back",
"3": await this.getStatsUSSD(phoneNumber),
};
return responses[text as keyof typeof responses] || responses[""];
}
/**
* Get pending requests for USSD display
*/
private async getPendingRequestsUSSD(phoneNumber: string): Promise<string> {
try {
// Find profile by phone number
const profile = await this.prisma.guestPublicProfile.findFirst({
where: {
OR: [
{ phoneNumber: { contains: phoneNumber.replace(/\D/g, "") } },
{ whatsappNumber: { contains: phoneNumber.replace(/\D/g, "") } },
],
},
select: { id: true },
});
if (!profile) {
return "END Profile not found. Please register at https://MicProtocol.io/network";
}
// Get pending booking requests
const pendingRequests = await this.prisma.networkBookingRequest.findMany({
where: {
toProfileId: profile.id,
status: "pending",
},
include: {
fromPodcast: {
select: { title: true },
},
},
take: 3,
orderBy: { createdAt: "desc" },
});
if (pendingRequests.length === 0) {
return "CON You have no pending requests.\n0. Back";
}
let response = `CON You have ${pendingRequests.length} pending request(s):\n`;
pendingRequests.forEach((req, idx) => {
response += `${idx + 1}. ${req.fromPodcast.title.substring(0, 20)}\n`;
});
response += "0. Back";
return response;
} catch (error) {
this.logger.error(`Failed to get pending requests for USSD: ${error}`);
return "END Error loading requests. Please try again later.";
}
}
/**
* Get stats for USSD display
*/
private async getStatsUSSD(phoneNumber: string): Promise<string> {
try {
// Find profile by phone number
const profile = await this.prisma.guestPublicProfile.findFirst({
where: {
OR: [
{ phoneNumber: { contains: phoneNumber.replace(/\D/g, "") } },
{ whatsappNumber: { contains: phoneNumber.replace(/\D/g, "") } },
],
},
select: {
id: true,
totalAppearances: true,
reliabilityScore: true,
},
});
if (!profile) {
return "END Profile not found. Please register at https://MicProtocol.io/network";
}
// Get profile views from last 30 days
const viewsLastMonth = await this.prisma.profileView.count({
where: {
profileId: profile.id,
viewedAt: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
},
});
const rating = profile.reliabilityScore
? (Number(profile.reliabilityScore) / 20).toFixed(1)
: "N/A";
return `CON Your stats:\n` +
`Appearances: ${profile.totalAppearances}\n` +
`Views (30d): ${viewsLastMonth}\n` +
`Rating: ${rating}/5\n` +
`0. Back`;
} catch (error) {
this.logger.error(`Failed to get stats for USSD: ${error}`);
return "END Error loading stats. Please try again later.";
}
}
/**
* Get service health status
*/
getHealth(): {
whatsapp: boolean;
sms: boolean;
ussd: boolean;
} {
return {
whatsapp: this.whatsappEnabled && !!this.twilioAccountSid,
sms: (this.twilioEnabled && !!this.twilioAccountSid) || this.africasTalkingEnabled,
ussd: true, // USSD is stateless
};
}
}