import { eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { customers, tickets } from "@/lib/db/schema"; import { recordAgentReply, recordInternalNote, getEmailThreadContext } from "@/lib/tickets/service"; import { sendTelegramMessage, sendTelegramFile } from "@/lib/telegram/bot"; import { sendTicketReplyEmail } from "@/lib/mail/smtp"; interface AttachmentPayload { filename: string; mimeType: string; sizeBytes: number; storageKey: string; buffer: Buffer; } /** * Shared by the text-reply and attachment-upload admin routes: creates the * message (public reply or internal note) and, for public replies, delivers * it over the ticket's channel (email/Telegram). Not used for portal/widget * replies — those never need outbound channel delivery. */ export async function deliverAgentMessage(params: { ticketId: string; agentId: string; agentName: string; body: string; visibility: "public" | "internal"; attachment?: AttachmentPayload; }) { const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) }); if (!ticket) throw new Error("Ticket not found"); const attachmentInput = params.attachment ? { filename: params.attachment.filename, mimeType: params.attachment.mimeType, sizeBytes: params.attachment.sizeBytes, storageKey: params.attachment.storageKey, } : undefined; if (params.visibility === "internal") { return recordInternalNote({ ticketId: params.ticketId, agentId: params.agentId, agentName: params.agentName, body: params.body, attachments: attachmentInput ? [attachmentInput] : undefined, }); } // Channel delivery (email/Telegram) is best-effort: the agent's reply is // the thing that must never be lost. Previously an SMTP/Telegram failure // here threw straight out of this function, so recordAgentReply below was // never reached — the reply vanished entirely (not recorded, nothing // shown in the thread) and the API route's catch-all turned it into a // misleading "Ticket not found". A broken mail/bot account should degrade // to "reply saved internally, not delivered" — never silent data loss. // A ticket only ever has one channel, so at most one of the two branches // below runs — this single flag is the outcome of whichever one does, and // is what lets the agent tell "saved" from "the customer actually got // this" in the UI (see messages.deliveryFailed). let emailMessageId: string | undefined; let deliveryFailed = false; if (ticket.channel === "email") { const context = await getEmailThreadContext(params.ticketId); if (context) { try { emailMessageId = await sendTicketReplyEmail({ to: context.customerEmail, subject: context.subject, body: params.body, inReplyTo: context.inReplyTo, attachment: params.attachment ? { filename: params.attachment.filename, content: params.attachment.buffer } : undefined, }); } catch (err) { console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err); deliveryFailed = true; } } } else if (ticket.channel === "telegram") { const customer = await db.query.customers.findFirst({ where: eq(customers.id, ticket.customerId), }); if (customer?.telegramChatId) { try { if (params.attachment) { await sendTelegramFile( customer.telegramChatId, { buffer: params.attachment.buffer, filename: params.attachment.filename, mimeType: params.attachment.mimeType }, params.body, ); } else { await sendTelegramMessage(customer.telegramChatId, params.body); } } catch (err) { console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, err); deliveryFailed = true; } } } const result = await recordAgentReply({ ticketId: params.ticketId, agentId: params.agentId, agentName: params.agentName, body: params.body, emailMessageId, deliveryFailed, attachments: attachmentInput ? [attachmentInput] : undefined, }); return result; }