Spaces:
Running
Running
Download apps/api/src/network/sms.service.ts from Benaah/MicProtocol: direct link, hf CLI and curl.
- Browser
- Download file 23.6 kB
-
https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/network/sms.service.ts
- Command line
-
hf download hf://spaces/Benaah/MicProtocol/apps/api/src/network/sms.service.ts
-
curl -L -o sms.service.ts https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/network/sms.service.ts
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) | |
| */ | |
| () | |
| 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 | |
| }; | |
| } | |
| } | |