ai creates booking
This commit is contained in:
@@ -31,6 +31,8 @@ dependencies {
|
||||
implementation("tools.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("com.google.firebase:firebase-admin:9.7.0")
|
||||
implementation("com.sun.mail:jakarta.mail:2.0.1")
|
||||
implementation("org.apache.pdfbox:pdfbox:2.0.30")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-flyway-test")
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.android.trisolarisserver
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
class TrisolarisServerApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.android.trisolarisserver.component
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
import org.apache.pdfbox.pdmodel.PDPage
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class EmailStorage(
|
||||
@Value("\${storage.emails.root:/home/androidlover5842/docs/emails}")
|
||||
private val rootPath: String
|
||||
) {
|
||||
fun storePdf(propertyId: UUID?, messageId: String?, subject: String?, body: String): String {
|
||||
val dir = if (propertyId != null) {
|
||||
Paths.get(rootPath, propertyId.toString())
|
||||
} else {
|
||||
Paths.get(rootPath, "unassigned")
|
||||
}
|
||||
Files.createDirectories(dir)
|
||||
val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf"
|
||||
val path = dir.resolve(fileName).normalize()
|
||||
|
||||
val document = PDDocument()
|
||||
val page = PDPage(PDRectangle.LETTER)
|
||||
document.addPage(page)
|
||||
val header = "Subject: ${subject ?: ""}\n\n"
|
||||
writeText(document, page, header + body)
|
||||
document.save(path.toFile())
|
||||
document.close()
|
||||
return path.toString()
|
||||
}
|
||||
|
||||
private fun writeText(document: PDDocument, firstPage: PDPage, text: String) {
|
||||
var page = firstPage
|
||||
var y = 730f
|
||||
val marginX = 50f
|
||||
val lineHeight = 14f
|
||||
val maxLines = 48
|
||||
var linesOnPage = 0
|
||||
|
||||
var content = PDPageContentStream(document, page)
|
||||
content.beginText()
|
||||
content.setFont(PDType1Font.HELVETICA, 11f)
|
||||
content.newLineAtOffset(marginX, y)
|
||||
|
||||
fun newLine() {
|
||||
content.newLineAtOffset(0f, -lineHeight)
|
||||
y -= lineHeight
|
||||
linesOnPage++
|
||||
if (linesOnPage >= maxLines) {
|
||||
content.endText()
|
||||
content.close()
|
||||
page = PDPage(PDRectangle.LETTER)
|
||||
document.addPage(page)
|
||||
content = PDPageContentStream(document, page)
|
||||
content.beginText()
|
||||
content.setFont(PDType1Font.HELVETICA, 11f)
|
||||
y = 730f
|
||||
linesOnPage = 0
|
||||
content.newLineAtOffset(marginX, y)
|
||||
}
|
||||
}
|
||||
|
||||
val lines = text.split("\n")
|
||||
for (line in lines) {
|
||||
val chunks = line.chunked(90)
|
||||
for (chunk in chunks) {
|
||||
content.showText(chunk)
|
||||
newLine()
|
||||
}
|
||||
}
|
||||
|
||||
content.endText()
|
||||
content.close()
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,20 @@ class LlamaClient(
|
||||
@Value("\${ai.llama.baseUrl}")
|
||||
private val baseUrl: String
|
||||
) {
|
||||
private val systemPrompt =
|
||||
"Look only at visible text. " +
|
||||
"Return the exact text you can read verbatim. " +
|
||||
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
|
||||
"Do not guess. Do not explain."
|
||||
|
||||
fun ask(imageUrl: String, question: String): String {
|
||||
val payload = mapOf(
|
||||
"model" to "qwen",
|
||||
"messages" to listOf(
|
||||
mapOf(
|
||||
"role" to "system",
|
||||
"content" to systemPrompt
|
||||
),
|
||||
mapOf(
|
||||
"role" to "user",
|
||||
"content" to listOf(
|
||||
@@ -36,4 +46,27 @@ class LlamaClient(
|
||||
val node = objectMapper.readTree(body)
|
||||
return node.path("choices").path(0).path("message").path("content").asText()
|
||||
}
|
||||
|
||||
fun askText(content: String, question: String): String {
|
||||
val payload = mapOf(
|
||||
"model" to "qwen",
|
||||
"messages" to listOf(
|
||||
mapOf(
|
||||
"role" to "system",
|
||||
"content" to systemPrompt
|
||||
),
|
||||
mapOf(
|
||||
"role" to "user",
|
||||
"content" to "${question}\n\nEMAIL:\n${content}"
|
||||
)
|
||||
)
|
||||
)
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
val entity = HttpEntity(payload, headers)
|
||||
val response = restTemplate.postForEntity(baseUrl, entity, String::class.java)
|
||||
val body = response.body ?: return ""
|
||||
val node = objectMapper.readTree(body)
|
||||
return node.path("choices").path(0).path("message").path("content").asText()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class HttpConfig {
|
||||
fun restTemplate(builder: RestTemplateBuilder): RestTemplate {
|
||||
return builder
|
||||
.setConnectTimeout(Duration.ofSeconds(10))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.setReadTimeout(Duration.ofMinutes(5))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.android.trisolarisserver.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class JacksonConfig {
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper {
|
||||
return jacksonObjectMapper()
|
||||
}
|
||||
}
|
||||
@@ -239,9 +239,12 @@ data class GuestDocumentResponse(
|
||||
|
||||
private fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse {
|
||||
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Document id missing")
|
||||
val extracted = extractedData?.let {
|
||||
val extracted: Map<String, String>? = extractedData?.let {
|
||||
try {
|
||||
objectMapper.readValue(it, Map::class.java).mapValues { entry -> entry.value?.toString() ?: "" }
|
||||
val raw = objectMapper.readValue(it, Map::class.java)
|
||||
raw.entries.associate { entry ->
|
||||
entry.key.toString() to (entry.value?.toString() ?: "")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ class Properties(
|
||||
name = request.name,
|
||||
timezone = request.timezone ?: "Asia/Kolkata",
|
||||
currency = request.currency ?: "INR",
|
||||
active = request.active ?: true
|
||||
active = request.active ?: true,
|
||||
emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf()
|
||||
)
|
||||
val saved = propertyRepo.save(property)
|
||||
return saved.toResponse()
|
||||
@@ -205,6 +206,9 @@ class Properties(
|
||||
property.timezone = request.timezone ?: property.timezone
|
||||
property.currency = request.currency ?: property.currency
|
||||
property.active = request.active ?: property.active
|
||||
if (request.emailAliases != null) {
|
||||
property.emailAliases = request.emailAliases.toMutableSet()
|
||||
}
|
||||
|
||||
return propertyRepo.save(property).toResponse()
|
||||
}
|
||||
@@ -241,7 +245,8 @@ private fun Property.toResponse(): PropertyResponse {
|
||||
name = name,
|
||||
timezone = timezone,
|
||||
currency = currency,
|
||||
active = active
|
||||
active = active,
|
||||
emailAliases = emailAliases.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ data class PropertyCreateRequest(
|
||||
val name: String,
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val active: Boolean? = null
|
||||
val active: Boolean? = null,
|
||||
val emailAliases: Set<String>? = null
|
||||
)
|
||||
|
||||
data class PropertyUpdateRequest(
|
||||
@@ -24,7 +25,8 @@ data class PropertyUpdateRequest(
|
||||
val name: String,
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val active: Boolean? = null
|
||||
val active: Boolean? = null,
|
||||
val emailAliases: Set<String>? = null
|
||||
)
|
||||
|
||||
data class PropertyResponse(
|
||||
@@ -34,7 +36,8 @@ data class PropertyResponse(
|
||||
val name: String,
|
||||
val timezone: String,
|
||||
val currency: String,
|
||||
val active: Boolean
|
||||
val active: Boolean,
|
||||
val emailAliases: Set<String>
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
|
||||
@@ -4,4 +4,7 @@ import com.android.trisolarisserver.models.booking.Booking
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.util.UUID
|
||||
|
||||
interface BookingRepo : JpaRepository<Booking, UUID>
|
||||
interface BookingRepo : JpaRepository<Booking, UUID> {
|
||||
fun findByPropertyIdAndSourceBookingId(propertyId: UUID, sourceBookingId: String): Booking?
|
||||
fun existsByPropertyIdAndSourceBookingId(propertyId: UUID, sourceBookingId: String): Boolean
|
||||
}
|
||||
|
||||
@@ -4,4 +4,6 @@ import com.android.trisolarisserver.models.booking.Guest
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.util.UUID
|
||||
|
||||
interface GuestRepo : JpaRepository<Guest, UUID>
|
||||
interface GuestRepo : JpaRepository<Guest, UUID> {
|
||||
fun findByOrgIdAndPhoneE164(orgId: UUID, phoneE164: String): Guest?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.android.trisolarisserver.db.repo
|
||||
|
||||
import com.android.trisolarisserver.models.booking.InboundEmail
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.util.UUID
|
||||
|
||||
interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> {
|
||||
fun findByMessageId(messageId: String): InboundEmail?
|
||||
fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail?
|
||||
fun existsByMessageId(messageId: String): Boolean
|
||||
fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.android.trisolarisserver.models.booking
|
||||
|
||||
import com.android.trisolarisserver.models.property.Property
|
||||
import jakarta.persistence.*
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "inbound_email",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(columnNames = ["message_id"]),
|
||||
UniqueConstraint(columnNames = ["property_id", "ota_booking_id"])
|
||||
]
|
||||
)
|
||||
class InboundEmail(
|
||||
@Id
|
||||
@GeneratedValue
|
||||
@Column(columnDefinition = "uuid")
|
||||
val id: UUID? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "property_id")
|
||||
var property: Property? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "booking_id")
|
||||
var booking: Booking? = null,
|
||||
|
||||
@Column(name = "ota_booking_id")
|
||||
var otaBookingId: String? = null,
|
||||
|
||||
@Column(name = "message_id")
|
||||
var messageId: String? = null,
|
||||
|
||||
@Column(name = "subject")
|
||||
var subject: String? = null,
|
||||
|
||||
@Column(name = "from_address")
|
||||
var fromAddress: String? = null,
|
||||
|
||||
@Column(name = "received_at", columnDefinition = "timestamptz")
|
||||
var receivedAt: OffsetDateTime? = null,
|
||||
|
||||
@Column(name = "status", nullable = false)
|
||||
@Enumerated(EnumType.STRING)
|
||||
var status: InboundEmailStatus = InboundEmailStatus.PENDING,
|
||||
|
||||
@Column(name = "raw_pdf_path")
|
||||
var rawPdfPath: String? = null,
|
||||
|
||||
@Column(name = "extracted_data", columnDefinition = "jsonb")
|
||||
var extractedData: String? = null,
|
||||
|
||||
@Column(name = "processed_at", columnDefinition = "timestamptz")
|
||||
var processedAt: OffsetDateTime? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
|
||||
val createdAt: OffsetDateTime = OffsetDateTime.now()
|
||||
)
|
||||
|
||||
enum class InboundEmailStatus {
|
||||
PENDING,
|
||||
CREATED,
|
||||
CANCELLED,
|
||||
SKIPPED,
|
||||
ERROR
|
||||
}
|
||||
@@ -34,6 +34,14 @@ class Property(
|
||||
@Column(name = "is_active", nullable = false)
|
||||
var active: Boolean = true,
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(
|
||||
name = "property_email_alias",
|
||||
joinColumns = [JoinColumn(name = "property_id")]
|
||||
)
|
||||
@Column(name = "alias", nullable = false)
|
||||
var emailAliases: MutableSet<String> = mutableSetOf(),
|
||||
|
||||
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
|
||||
val createdAt: OffsetDateTime = OffsetDateTime.now()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.android.trisolarisserver.service
|
||||
|
||||
import com.android.trisolarisserver.component.EmailStorage
|
||||
import com.android.trisolarisserver.component.LlamaClient
|
||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
||||
import com.android.trisolarisserver.db.repo.GuestRepo
|
||||
import com.android.trisolarisserver.db.repo.InboundEmailRepo
|
||||
import com.android.trisolarisserver.models.booking.Booking
|
||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||
import com.android.trisolarisserver.models.booking.Guest
|
||||
import com.android.trisolarisserver.models.booking.InboundEmail
|
||||
import com.android.trisolarisserver.models.booking.InboundEmailStatus
|
||||
import com.android.trisolarisserver.models.property.Property
|
||||
import com.android.trisolarisserver.repo.PropertyRepo
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import jakarta.mail.Flags
|
||||
import jakarta.mail.Folder
|
||||
import jakarta.mail.Message
|
||||
import jakarta.mail.Multipart
|
||||
import jakarta.mail.Session
|
||||
import jakarta.mail.Store
|
||||
import jakarta.mail.search.FlagTerm
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Properties
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.security.MessageDigest
|
||||
|
||||
@Component
|
||||
class EmailIngestionService(
|
||||
private val propertyRepo: PropertyRepo,
|
||||
private val inboundEmailRepo: InboundEmailRepo,
|
||||
private val bookingRepo: BookingRepo,
|
||||
private val guestRepo: GuestRepo,
|
||||
private val llamaClient: LlamaClient,
|
||||
private val emailStorage: EmailStorage,
|
||||
private val objectMapper: ObjectMapper,
|
||||
@Value("\${mail.imap.host}")
|
||||
private val host: String,
|
||||
@Value("\${mail.imap.port:993}")
|
||||
private val port: Int,
|
||||
@Value("\${mail.imap.username}")
|
||||
private val username: String,
|
||||
@Value("\${mail.imap.password}")
|
||||
private val password: String,
|
||||
@Value("\${mail.imap.protocol:imaps}")
|
||||
private val protocol: String
|
||||
) {
|
||||
private val running = AtomicBoolean(false)
|
||||
@Value("\${mail.imap.enabled:false}")
|
||||
private val enabled: Boolean = false
|
||||
|
||||
@Scheduled(fixedDelayString = "\${mail.imap.pollMs:60000}")
|
||||
fun pollInbox() {
|
||||
if (!enabled) return
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
try {
|
||||
val session = Session.getInstance(Properties())
|
||||
val store: Store = session.getStore(protocol)
|
||||
store.connect(host, port, username, password)
|
||||
val inbox = store.getFolder("INBOX")
|
||||
inbox.open(Folder.READ_WRITE)
|
||||
|
||||
val unseen = inbox.search(FlagTerm(Flags(Flags.Flag.SEEN), false))
|
||||
for (message in unseen) {
|
||||
processMessage(message)
|
||||
message.setFlag(Flags.Flag.SEEN, true)
|
||||
}
|
||||
inbox.close(true)
|
||||
store.close()
|
||||
} catch (_: Exception) {
|
||||
// swallow to keep scheduler alive
|
||||
} finally {
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processMessage(message: Message) {
|
||||
val messageId = buildMessageId(message)
|
||||
if (messageId != null && inboundEmailRepo.existsByMessageId(messageId)) {
|
||||
return
|
||||
}
|
||||
|
||||
val subject = message.subject
|
||||
val from = message.from?.firstOrNull()?.toString()
|
||||
val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset)
|
||||
val body = extractText(message)
|
||||
val property = matchProperty(subject, body)
|
||||
|
||||
val inbound = InboundEmail(
|
||||
property = property,
|
||||
messageId = messageId,
|
||||
subject = subject,
|
||||
fromAddress = from,
|
||||
receivedAt = receivedAt
|
||||
)
|
||||
|
||||
inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body)
|
||||
inboundEmailRepo.save(inbound)
|
||||
|
||||
if (property == null) {
|
||||
inbound.status = InboundEmailStatus.SKIPPED
|
||||
inbound.processedAt = OffsetDateTime.now()
|
||||
inboundEmailRepo.save(inbound)
|
||||
return
|
||||
}
|
||||
|
||||
val extracted = extractBookingDetails(body)
|
||||
inbound.extractedData = objectMapper.writeValueAsString(extracted)
|
||||
|
||||
val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) }
|
||||
if (!otaBookingId.isNullOrBlank() &&
|
||||
inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId)
|
||||
) {
|
||||
inbound.status = InboundEmailStatus.SKIPPED
|
||||
inbound.processedAt = OffsetDateTime.now()
|
||||
inboundEmailRepo.save(inbound)
|
||||
return
|
||||
}
|
||||
|
||||
inbound.otaBookingId = otaBookingId
|
||||
inboundEmailRepo.save(inbound)
|
||||
|
||||
val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true
|
||||
if (isCancel) {
|
||||
if (otaBookingId.isNullOrBlank()) {
|
||||
inbound.status = InboundEmailStatus.SKIPPED
|
||||
inbound.processedAt = OffsetDateTime.now()
|
||||
inboundEmailRepo.save(inbound)
|
||||
return
|
||||
}
|
||||
val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId)
|
||||
if (booking != null) {
|
||||
booking.status = BookingStatus.CANCELLED
|
||||
bookingRepo.save(booking)
|
||||
inbound.booking = booking
|
||||
}
|
||||
inbound.status = InboundEmailStatus.CANCELLED
|
||||
inbound.processedAt = OffsetDateTime.now()
|
||||
inboundEmailRepo.save(inbound)
|
||||
return
|
||||
}
|
||||
|
||||
val sourceBookingId = otaBookingId ?: "email:$messageId"
|
||||
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
|
||||
inbound.status = InboundEmailStatus.SKIPPED
|
||||
inbound.processedAt = OffsetDateTime.now()
|
||||
inboundEmailRepo.save(inbound)
|
||||
return
|
||||
}
|
||||
|
||||
val guest = resolveGuest(property, extracted)
|
||||
val booking = createBooking(property, guest, extracted, sourceBookingId)
|
||||
inbound.booking = booking
|
||||
inbound.status = InboundEmailStatus.CREATED
|
||||
inbound.processedAt = OffsetDateTime.now()
|
||||
inboundEmailRepo.save(inbound)
|
||||
}
|
||||
|
||||
private fun extractBookingDetails(body: String): Map<String, String> {
|
||||
val results = linkedMapOf<String, String>()
|
||||
results["isCancel"] = llamaClient.askText(body, "Is this a cancellation email? Answer YES or NO only.")
|
||||
results["otaBookingId"] = llamaClient.askText(body, "OTA BOOKING ID? Reply only the ID or NONE.")
|
||||
results["checkinDate"] = llamaClient.askText(body, "CHECKIN DATE? Reply YYYY-MM-DD or NONE.")
|
||||
results["checkoutDate"] = llamaClient.askText(body, "CHECKOUT DATE? Reply YYYY-MM-DD or NONE.")
|
||||
results["nights"] = llamaClient.askText(body, "NIGHTS? Reply number or NONE.")
|
||||
results["amount"] = llamaClient.askText(body, "TOTAL AMOUNT? Reply number only or NONE.")
|
||||
results["roomRateWithoutGst"] = llamaClient.askText(body, "ROOM RATE WITHOUT GST? Reply number only or NONE.")
|
||||
results["roomRateWithGst"] = llamaClient.askText(body, "ROOM RATE WITH GST? Reply number only or NONE.")
|
||||
results["commissionIncludingGst"] = llamaClient.askText(body, "COMMISSION INCLUDING GST? Reply number only or NONE.")
|
||||
results["guestName"] = llamaClient.askText(body, "GUEST NAME? Reply name or NONE.")
|
||||
results["guestPhone"] = llamaClient.askText(body, "GUEST PHONE? Reply number or NONE.")
|
||||
results["propertyName"] = llamaClient.askText(body, "PROPERTY NAME? Reply only name or NONE.")
|
||||
results["propertyAddress"] = llamaClient.askText(body, "PROPERTY ADDRESS? Reply only address or NONE.")
|
||||
results["roomTypesRaw"] = llamaClient.askText(
|
||||
body,
|
||||
"Room wise Payment Breakup. Return ONLY JSON. Include all dates."
|
||||
)
|
||||
results["policy"] = llamaClient.askText(body, "CANCELLATION POLICY? Reply short text or NONE.")
|
||||
results["otaSource"] = llamaClient.askText(body, "OTA SOURCE? Reply only name or NONE.")
|
||||
|
||||
val source = results["otaSource"] ?: ""
|
||||
if (source.contains("mmt", ignoreCase = true) || source.contains("make my trip", ignoreCase = true) ||
|
||||
source.contains("makemytrip", ignoreCase = true)
|
||||
) {
|
||||
results["propertyGrossCharges"] =
|
||||
llamaClient.askText(body, "PROPERTY GROSS CHARGES? Reply number only or NONE.")
|
||||
results["payableToProperty"] =
|
||||
llamaClient.askText(body, "PAYABLE TO PROPERTY? Reply number only or NONE.")
|
||||
results["taxDeduction"] =
|
||||
llamaClient.askText(body, "TAX DEDUCTION (7+8)? Reply number only or NONE.")
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun resolveGuest(property: Property, extracted: Map<String, String>): Guest {
|
||||
val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim()
|
||||
if (!phone.isNullOrBlank()) {
|
||||
val existing = guestRepo.findByOrgIdAndPhoneE164(property.org.id!!, phone)
|
||||
if (existing != null) return existing
|
||||
}
|
||||
val guest = Guest(
|
||||
org = property.org,
|
||||
phoneE164 = phone,
|
||||
name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) }
|
||||
)
|
||||
return guestRepo.save(guest)
|
||||
}
|
||||
|
||||
private fun createBooking(
|
||||
property: Property,
|
||||
guest: Guest,
|
||||
extracted: Map<String, String>,
|
||||
sourceBookingId: String
|
||||
): Booking {
|
||||
val zone = ZoneId.of(property.timezone)
|
||||
val checkin = parsedDate(extracted["checkinDate"], zone)
|
||||
val checkout = parsedDate(extracted["checkoutDate"], zone)
|
||||
val booking = Booking(
|
||||
property = property,
|
||||
primaryGuest = guest,
|
||||
status = BookingStatus.OPEN,
|
||||
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
|
||||
sourceBookingId = sourceBookingId,
|
||||
expectedCheckinAt = checkin,
|
||||
expectedCheckoutAt = checkout
|
||||
)
|
||||
return bookingRepo.save(booking)
|
||||
}
|
||||
|
||||
private fun buildMessageId(message: Message): String? {
|
||||
val header = message.getHeader("Message-ID")?.firstOrNull()
|
||||
if (!header.isNullOrBlank()) return header
|
||||
val from = message.from?.firstOrNull()?.toString() ?: ""
|
||||
val subject = message.subject ?: ""
|
||||
val received = message.receivedDate?.time?.toString() ?: ""
|
||||
val raw = "$from|$subject|$received"
|
||||
if (raw.isBlank()) return null
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun parsedDate(value: String?, zone: ZoneId): OffsetDateTime? {
|
||||
if (value.isNullOrBlank() || value.contains("NONE", true)) return null
|
||||
return try {
|
||||
val localDate = java.time.LocalDate.parse(value.trim())
|
||||
localDate.atTime(12, 0).atZone(zone).toOffsetDateTime()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchProperty(subject: String?, body: String): Property? {
|
||||
val haystack = "${subject ?: ""}\n$body".lowercase()
|
||||
val properties = propertyRepo.findAll()
|
||||
val matches = properties.filter { property ->
|
||||
val aliases = mutableSetOf<String>()
|
||||
aliases.add(property.name)
|
||||
aliases.add(property.code)
|
||||
aliases.addAll(property.emailAliases)
|
||||
aliases.any { alias -> alias.isNotBlank() && haystack.contains(alias.lowercase()) }
|
||||
}
|
||||
return if (matches.size == 1) matches.first() else null
|
||||
}
|
||||
|
||||
private fun extractText(message: Message): String {
|
||||
return try {
|
||||
val content = message.content
|
||||
when (content) {
|
||||
is String -> content
|
||||
is Multipart -> extractFromMultipart(content)
|
||||
else -> content.toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractFromMultipart(multipart: Multipart): String {
|
||||
var text: String? = null
|
||||
var html: String? = null
|
||||
for (i in 0 until multipart.count) {
|
||||
val part = multipart.getBodyPart(i)
|
||||
if (part.isMimeType("text/plain")) {
|
||||
text = part.content.toString()
|
||||
} else if (part.isMimeType("text/html")) {
|
||||
html = part.content.toString()
|
||||
} else if (part.content is Multipart) {
|
||||
val nested = extractFromMultipart(part.content as Multipart)
|
||||
if (nested.isNotBlank()) text = nested
|
||||
}
|
||||
}
|
||||
return text ?: html?.replace(Regex("<[^>]*>"), " ") ?: ""
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
spring.datasource.url=jdbc:postgresql://192.168.1.53:5432/trisolaris
|
||||
ai.llama.baseUrl=https://ai.hoteltrisolaris.in/v1/chat/completions
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris
|
||||
ai.llama.baseUrl=http://localhost:8089/v1/chat/completions
|
||||
|
||||
@@ -9,4 +9,11 @@ storage.documents.root=/home/androidlover5842/docs
|
||||
storage.documents.publicBaseUrl=https://api.hoteltrisolaris.in
|
||||
storage.documents.tokenSecret=change-me
|
||||
storage.documents.tokenTtlSeconds=300
|
||||
ai.llama.baseUrl=https://ai.hoteltrisolaris.in/v1/chat/completions
|
||||
storage.emails.root=/home/androidlover5842/docs/emails
|
||||
mail.imap.host=localhost
|
||||
mail.imap.port=993
|
||||
mail.imap.protocol=imaps
|
||||
mail.imap.username=
|
||||
mail.imap.password=
|
||||
mail.imap.pollMs=60000
|
||||
mail.imap.enabled=false
|
||||
|
||||
Reference in New Issue
Block a user