import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Queue, Worker } from 'bullmq'; import IORedis from 'ioredis'; import { randomUUID } from 'node:crypto'; import { createHash } from 'node:crypto'; import { setTimeout as sleep } from 'node:timers/promises'; import { BillingService } from '../billing/billing.service'; import { isIpAllowed } from '../common/ip-allowlist'; import { moneyToNumber } from '../common/money'; import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import { OpenApiService } from '../open-api/open-api.service'; import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers'; import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; import { SendSubmissionService } from './send-submission.service'; import { SendCompletionService, type SendCompletionFacade } from './send-completion.service'; @Injectable() export class SendChainService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(SendChainService.name); private redis?: IORedis; private sendQueue?: Queue; private gatewayQueue?: Queue; private worker?: Worker; private receiptTimeoutInitialTimer?: ReturnType; private receiptTimeoutIntervalTimer?: ReturnType; private receiptTimeoutScanRunning = false; private scheduledDispatchInitialTimer?: ReturnType; private scheduledDispatchIntervalTimer?: ReturnType; private scheduledDispatchScanRunning = false; private inboundLongMessageInitialTimer?: ReturnType; private inboundLongMessageIntervalTimer?: ReturnType; private upstreamReceiptInboxInitialTimer?: ReturnType; private upstreamReceiptInboxIntervalTimer?: ReturnType; private readonly submission: SendSubmissionService; private readonly completion: SendCompletionService; constructor( private readonly prisma: PrismaService, private readonly billing: BillingService, private readonly riskReview: RiskReviewService, private readonly phoneFrequency: PhoneFrequencyService, @Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService, @Optional() phoneRouting?: PhoneRoutingLookupService, ) { const resolvedPhoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma); this.submission = new SendSubmissionService( prisma, billing, riskReview, phoneFrequency, resolvedPhoneRouting, this as unknown as SendSubmissionService, { releaseMessageReservation: (message, remark) => this.releaseMessageReservation(message, remark), recordCmppFailureReceipt: (message, errorCode, reason) => this.recordCmppFailureReceipt(message, errorCode, reason), }, ); this.completion = new SendCompletionService( prisma, billing, openApi, this as unknown as SendCompletionFacade, ); } onModuleInit() { if (process.env.API_ENABLE_SEND_WORKER === 'true') { this.startWorker(); } if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') { this.receiptTimeoutInitialTimer = setTimeout(() => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS); this.receiptTimeoutInitialTimer.unref?.(); this.receiptTimeoutIntervalTimer = setInterval( () => void this.runReceiptTimeoutScan(), positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS), ); this.receiptTimeoutIntervalTimer.unref?.(); } if (process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED !== 'false') { this.scheduledDispatchInitialTimer = setTimeout( () => void this.runScheduledDispatchScan(), SCHEDULED_DISPATCH_INITIAL_DELAY_MS, ); this.scheduledDispatchInitialTimer.unref?.(); this.scheduledDispatchIntervalTimer = setInterval( () => void this.runScheduledDispatchScan(), positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS), ); this.scheduledDispatchIntervalTimer.unref?.(); } if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') { this.inboundLongMessageInitialTimer = setTimeout( () => void this.expireInboundLongMessages().catch((error) => { this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); }), INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, ); this.inboundLongMessageInitialTimer.unref?.(); this.inboundLongMessageIntervalTimer = setInterval( () => void this.expireInboundLongMessages().catch((error) => { this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); }), positiveInteger( process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, ), ); this.inboundLongMessageIntervalTimer.unref?.(); } if (process.env.UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED !== 'false') { this.upstreamReceiptInboxInitialTimer = setTimeout( () => void this.runUpstreamReceiptInboxScan(), UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, ); this.upstreamReceiptInboxInitialTimer.unref?.(); this.upstreamReceiptInboxIntervalTimer = setInterval( () => void this.runUpstreamReceiptInboxScan(), positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, ), ); this.upstreamReceiptInboxIntervalTimer.unref?.(); } } async onModuleDestroy() { if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer); if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer); if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer); if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer); if (this.inboundLongMessageInitialTimer) clearTimeout(this.inboundLongMessageInitialTimer); if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer); if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer); if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer); await this.worker?.close(); await this.sendQueue?.close(); await this.gatewayQueue?.close(); this.redis?.disconnect(); } async createBatchTask(data: CreateBatchTaskDto) { return this.submission.createBatchTask(data); } async createHttpBatchTask(data: CreateHttpBatchTaskDto) { return this.submission.createHttpBatchTask(data); } async listBatchTasks(tenantId?: string, status?: string, sourceType = 'client') { const tasks = await this.prisma.smsBatchTask.findMany({ where: { tenantId, status, sourceType }, include: { tenant: true, application: true, template: true, apiRequests: true, }, orderBy: { createdAt: 'desc' }, }); const taskIds = tasks.map((task) => task.id); const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ by: ['batchTaskId', 'carrier', 'province', 'status'], where: { batchTaskId: { in: taskIds } }, _count: { _all: true }, _sum: { billingUnits: true }, }) : []; return tasks.map((task) => ({ ...task, messageStats: messageStats.filter((item) => item.batchTaskId === task.id), })); } async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { return this.submission.getBatchTask(taskId, tenantId, sourceType); } async listClientTaskMessages(taskId: string, tenantId: string) { await this.getBatchTask(taskId, tenantId, 'client'); return this.listMessages({ tenantId, taskId }); } async listBatchTasksPage(query: { tenantId?: string; status?: string; sourceType?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number; }) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsBatchTaskWhereInput = { tenantId: query.tenantId, status: query.status, sourceType: query.sourceType ?? 'client', taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined, application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined, createdAt: query.createdAtFrom || query.createdAtTo ? { gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, } : undefined, }; const [tasks, total] = await Promise.all([ this.prisma.smsBatchTask.findMany({ where, include: { tenant: { select: { id: true, name: true } }, application: { select: { id: true, name: true } }, template: { select: { id: true, name: true, content: true } }, apiRequests: true, }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.smsBatchTask.count({ where }), ]); const taskIds = tasks.map((task) => task.id); const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ by: ['batchTaskId', 'carrier', 'province', 'status'], where: { batchTaskId: { in: taskIds } }, _count: { _all: true }, _sum: { billingUnits: true }, }) : []; return { items: tasks.map((task) => ({ ...task, messageStats: messageStats.filter((item) => item.batchTaskId === task.id), })), total, page, pageSize, }; } async listAdminBatchTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) { const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, sourceType: 'client' }, select: { id: true }, }); if (!task) { throw new NotFoundException('SMS batch task not found'); } const normalizedPage = Math.max(1, Math.floor(Number(page) || 1)); const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(Number(pageSize) || 20))); const where: Prisma.SmsMessageRecordWhereInput = { batchTaskId: taskId, ...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}), }; const [items, total] = await Promise.all([ this.prisma.smsMessageRecord.findMany({ where, select: { id: true, phoneNumber: true, province: true, carrier: true, status: true, }, orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }], skip: (normalizedPage - 1) * normalizedPageSize, take: normalizedPageSize, }), this.prisma.smsMessageRecord.count({ where }), ]); return { items, total, page: normalizedPage, pageSize: normalizedPageSize }; } listMessages(query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string; } = {}) { return this.prisma.smsMessageRecord.findMany({ where: { tenantId: query.tenantId, applicationId: query.applicationId, channelId: query.channelId, batchTaskId: query.taskId, phoneNumber: query.phoneNumber, status: query.status, }, include: { tenant: true, application: true, channel: true, submitRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } }, receiptRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } }, }, orderBy: { queuedAt: 'desc' }, }); } listSubmitRecords(taskId?: string) { return this.prisma.smsSubmitRecord.findMany({ where: { batchTaskId: taskId }, orderBy: { createdAt: 'desc' }, }); } listReceiptRecords(taskId?: string) { return this.prisma.smsReceiptRecord.findMany({ where: { batchTaskId: taskId }, orderBy: { createdAt: 'desc' }, }); } listUplinkMessages(tenantId?: string, channelId?: string) { return this.prisma.smsUplinkMessage.findMany({ where: { tenantId, channelId }, include: { application: true, channel: true, messageRecord: { include: { application: true } } }, orderBy: { receivedAt: 'desc' }, }); } async previewImport(data: ImportPreviewDto) { return this.submission.previewImport(data); } async confirmImport(data: ConfirmImportDto) { return this.submission.confirmImport(data); } async enqueueBatchTask(taskId: string) { return this.submission.enqueueBatchTask(taskId); } async cancelBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, tenantId, sourceType } }); if (!task) { throw new NotFoundException('SMS batch task not found'); } if (task.status !== 'scheduled') { throw new BadRequestException('Only scheduled SMS batch tasks can be canceled before dispatch'); } await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: taskId, status: 'scheduled' }, data: { status: 'canceled', errorMessage: '定时任务已取消' }, }); return this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'canceled', canceledAt: new Date() }, }); } async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { return this.submission.handleReviewDecision(reviewTaskId, decision, reason); } async terminateBatchTask(taskId: string) { const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } }); if (!task) { throw new NotFoundException('SMS batch task not found'); } if (['finished', 'completed', 'failed', 'canceled', 'rejected'].includes(task.status)) { throw new BadRequestException('SMS batch task is already final'); } await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: taskId, status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } }, data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' }, }); await this.refreshTaskProgress(taskId); return this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'canceled', canceledAt: new Date(), rejectReason: '运营终止任务' }, }); } async dispatchDueScheduledTasks(now = new Date()) { return this.submission.dispatchDueScheduledTasks(now); } private async runScheduledDispatchScan() { return this.submission.runScheduledDispatchScan(); } startWorker() { return this.submission.startWorker(); } async processSendJob(job: SendJob) { return this.submission.processSendJob(job); } async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { return this.completion.handleSubmitSegmentResult(data); } private async resolveSubmitRecordForGatewaySegmentResult( messageRecordId: string, data: GatewaySubmitSegmentResultDto, ) { return this.completion.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data); } async handleSubmitResult(data: GatewaySubmitResultDto) { return this.completion.handleSubmitResult(data); } private async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) { return this.completion.resolveSubmitRecordForGatewayResult(messageRecordId, data); } async intakeReceipt(data: GatewayReceiptEventDto) { return this.completion.intakeReceipt(data); } async processPendingUpstreamReceiptInbox(limit = 100) { return this.completion.processPendingUpstreamReceiptInbox(limit); } private async processUpstreamReceiptInboxRecord(id: string) { return this.completion.processUpstreamReceiptInboxRecord(id); } private async runUpstreamReceiptInboxScan() { return this.completion.runUpstreamReceiptInboxScan(); } async handleReceipt( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, ) { return this.completion.handleReceipt(data, incomingIdentity); } async handleUplink(data: GatewayUplinkEventDto) { return this.completion.handleUplink(data); } async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) { return this.completion.listPendingDownstreamDeliveries(data); } async markDownstreamDeliveryDelivered(id: string) { return this.completion.markDownstreamDeliveryDelivered(id); } async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) { return this.completion.markDownstreamDeliverySent(data); } async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) { return this.completion.acknowledgeDownstreamDelivery(data); } async markDownstreamDeliveryFailed( id: string, errorMessage?: string, failureType: GatewayDownstreamFailureType = 'send_failed', attempt?: GatewayDownstreamSentDto, ) { return this.completion.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt); } async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) { return this.completion.recordGatewaySubmitDeadLetter(data); } async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) { return this.completion.recordGatewayDownstreamRecoveryStatus(data); } async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) { return this.completion.requeueGatewaySubmitDeadLetter(id, data); } async recoverStaleGatewaySubmitRequeues(now = new Date()) { return this.completion.recoverStaleGatewaySubmitRequeues(now); } async requeueDownstreamDelivery(id: string) { return this.completion.requeueDownstreamDelivery(id); } async recoverStaleDownstreamManualRequeues(now = new Date()) { return this.completion.recoverStaleDownstreamManualRequeues(now); } async batchRequeueDownstreamDeliveries(ids: string[]) { return this.completion.batchRequeueDownstreamDeliveries(ids); } async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) { return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId); } private async queueAndTryDownstreamDelivery(data: { tenantId: string; applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; deliveryType: 'receipt' | 'uplink'; payload: Record; }) { return this.completion.queueAndTryDownstreamDelivery(data); } private async resolveUplinkMatch( data: GatewayUplinkEventDto, channel: { id: string; srcId?: string | null }, ): Promise<{ tenantId?: string; applicationId?: string; messageRecordId?: string; matchStatus: string; matchReason: string; candidates: UplinkMatchCandidateInput[]; }> { return this.completion.resolveUplinkMatch(data, channel); } async authenticateInboundApplication(data: GatewayInboundAuthDto) { return this.submission.authenticateInboundApplication(data); } async submitInboundMessage(data: GatewayInboundSubmitDto) { return this.submission.submitInboundMessage(data); } private async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) { return this.submission.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers); } private async submitCompleteInboundMessage( data: GatewayInboundSubmitDto, phoneNumbers: string[], application: Awaited>, requestedGroupMessageId?: string, ) { return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId); } private async collectInboundLongMessageFragment( data: GatewayInboundSubmitDto, application: NonNullable>>, phoneNumbers: string[], ) { return this.submission.collectInboundLongMessageFragment(data, application, phoneNumbers); } async expireInboundLongMessages(now = new Date()) { return this.submission.expireInboundLongMessages(now); } private async submitInboundSingleMessage( data: GatewayInboundSubmitDto & { phoneNumber: string }, messageId: string, submitGroupMessageId: string, synchronousRejection?: { code: string; reason: string }, receiptRejection?: { code: string; reason: string }, ) { return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection); } /** * CMPP 单号码提交必须在生成最终入队决定前原子占用号码频次。 * 频次规则是直接拒绝,因此优先级高于其他规则产生的待人工审核结果。 */ private async evaluateRiskWithPhoneFrequency(input: { tenantId: string; applicationId: string; templateId?: string; content: string; variables?: Record; phoneNumber: string; sourceType: 'cmpp'; }) { return this.submission.evaluateRiskWithPhoneFrequency(input); } async markUnknownTimeout(data: TimeoutUnknownDto) { return this.completion.markUnknownTimeout(data); } async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) { return this.completion.markExpiredDownstreamDeliveries(olderThanHours); } private async runReceiptTimeoutScan() { return this.completion.runReceiptTimeoutScan(); } private async submitMessageToGateway( message: { id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; submitId?: string | null; messageId: string; phoneNumber: string; content: string; billingUnits: number; queuePriority?: string | null; clientSrcId?: string | null; applicationExtension?: string | null; template?: { signature?: { id?: string | null; name?: string | null } | null } | null; signature?: { id?: string | null; name?: string | null } | null; }, routed: RoutedChannel, attempt: number, retryOfSubmitRecordId?: string, ) { return this.submission.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId); } private async retryMessageIfAllowed( message: { id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; submitId?: string | null; messageId: string; phoneNumber: string; content: string; billingUnits: number; queuedAt?: Date; clientSrcId?: string | null; applicationExtension?: string | null; carrier?: string | null; province?: string | null; }, reason: string, sourceSubmitRecordId?: string, ) { return this.completion.retryMessageIfAllowed(message, reason, sourceSubmitRecordId); } private async selectChannelForMessage( message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, ): Promise { return this.submission.selectChannelForMessage(message, options); } private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) { return this.submission.findApplicationRoute(tenantId, applicationId, carrier); } private async identifyCarrier(phoneNumber: string) { return this.submission.identifyCarrier(phoneNumber); } private async identifyProvince(phoneNumber: string) { return this.submission.identifyProvince(phoneNumber); } private async resolveUnitPrice(tenantId: string, applicationId?: string) { return this.submission.resolveUnitPrice(tenantId, applicationId); } private async resolveQueuePriority(tenantId: string, applicationId?: string): Promise { return this.submission.resolveQueuePriority(tenantId, applicationId); } private async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) { return this.submission.resolveApplicationAccessNumber(tenantId, applicationId); } private findInboundApplication(account: string) { return this.submission.findInboundApplication(account); } private async resolveInboundTemplateCandidate(applicationId: string, content: string) { return this.submission.resolveInboundTemplateCandidate(applicationId, content); } private resolveInboundSignatureCandidate(applicationId: string, content: string) { return this.submission.resolveInboundSignatureCandidate(applicationId, content); } private async resolveTemplateMessageClassification( tenantId: string, applicationId: string | undefined, templateId: string | undefined, content: string, ) { return this.submission.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content); } private async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { return this.submission.resolveDrainageInfoMatch(signatureId, content); } private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { return this.submission.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId); } private async recordCmppFailureReceipt( message: { id: string; tenantId?: string | null; batchTaskId?: string | null; applicationId?: string | null; messageId: string; phoneNumber: string; cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null; }, errorCode: string, reason: string, ) { return this.completion.recordCmppFailureReceipt(message, errorCode, reason); } private async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { return this.submission.classifyRejectedPhones(tenantId, applicationId, phones); } private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { return this.submission.validateSendResources(tenantId, applicationId, templateId); } private async reserveDailySendQuota(applicationId: string, requestedCount: number) { return this.submission.reserveDailySendQuota(applicationId, requestedCount); } private async tryReserveDailySendQuota(applicationId: string, requestedCount: number) { return this.submission.tryReserveDailySendQuota(applicationId, requestedCount); } private async chargeAcceptedMessage(message: { tenantId: string; applicationId?: string | null; batchTaskId: string; messageId: string; phoneNumber: string; content: string; billingUnits: number; unitPrice: number | bigint; amountCents: number | bigint; }) { return this.completion.chargeAcceptedMessage(message); } private async releaseMessageReservation( message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, remark: string, ) { return this.completion.releaseMessageReservation(message, remark); } private async refundMessage( message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, remark: string, ) { return this.completion.refundMessage(message, remark); } private async ensureSignatureReportedForChannel( message: { id: string; templateId?: string | null; template?: { signature?: { id?: string | null; name?: string | null } | null } | null; signature?: { id?: string | null; name?: string | null } | null; }, channelId: string, ) { return this.submission.ensureSignatureReportedForChannel(message, channelId); } private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { return this.submission.resolveMessageSignatureId(message); } private async waitForChannelRateLimit(channelId: string, tps: number) { return this.submission.waitForChannelRateLimit(channelId, tps); } private async refreshTaskProgress(batchTaskId: string) { return this.submission.refreshTaskProgress(batchTaskId); } private smsMessageSegmentAuditDelegate() { return this.completion.smsMessageSegmentAuditDelegate(); } private async recordSubmitSegments( message: { id: string; tenantId?: string | null; batchTaskId?: string | null; channelId?: string | null; submitId?: string | null; billingUnits?: number | null; }, data: GatewaySubmitResultDto, submittedAt: Date, ) { return this.completion.recordSubmitSegments(message, data, submittedAt); } private async recordReceiptSegment( message: { id: string; tenantId?: string | null; batchTaskId?: string | null; channelId?: string | null; submitId?: string | null; billingUnits?: number | null; }, data: GatewayReceiptEventDto, deliveredAt: Date, submitRecordId?: string, ) { return this.completion.recordReceiptSegment(message, data, deliveredAt, submitRecordId); } private async aggregateReceiptSegments( message: { id: string; billingUnits?: number | null; }, data: GatewayReceiptEventDto, deliveredAt: Date, submitRecordId?: string, submitId?: string, ) { return this.completion.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId); } private async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { return this.completion.findMessageByGatewayEvent(messageId, gatewayMessageId); } private async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { return this.completion.requireMessageByGatewayEvent(messageId, gatewayMessageId); } private async resolveReceiptMessage( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, ) { return this.completion.resolveReceiptMessage(data, incomingIdentity); } private getSendQueue(): Queue { return this.submission.getSendQueue(); } private getGatewayQueue(): Queue { return this.submission.getGatewayQueue(); } private getRedis() { return this.submission.getRedis(); } private async postGatewayControl(path: string, payload: unknown) { return this.completion.postGatewayControl(path, payload); } private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { return this.submission.publishGatewaySubmitCommand(command, idempotencyKey); } }