package parser import ( "caatsm/internal/adapter/dto" "caatsm/internal/domain" "errors" "fmt" "regexp" "strings" "sync" "time" "github.com/google/uuid" "go.uber.org/zap" ) const ( SSR = "ssr" DepartureCode = "dep" DepartureTime = "dep_time" ArrivalCode = "arr" ArrivalTime = "arr_time" DestinationCode = "dest" OtherInfo = "other" ReferenceData = "reference_data" CategorySurveillance = "surve" Indicator = "indicator" Other = "other" AircraftID = "aircraft" Surveillance = "surve" Speed = "speed" Level = "level" Route = "route" EstimatedTime = "estt" AlternateAirport = "alter" PBN = "pbn" NavigationEquipment = "nav" EstimatedElapsedTime = "eet" SELCALCode = "sel" PerformanceCategory = "per" RerouteInformation = "rif" Remarks = "remark" ) var ( otherPatterns = []*regexp.Regexp{navPattern, remarkPattern, selPattern, pbnPattern, eetPattern, performancePattern, regPattern, reroutePattern} // ErrHeaderParse indicates an invalid header section. ErrHeaderParse = errors.New("invalid telegram header") // ErrBodyParse indicates a failure matching the telegram body. ErrBodyParse = errors.New("invalid telegram body") ) type BodyParser struct { body string bodyPatterns map[string]BodyConfig mu sync.Mutex } func NewBodyParser(body string) *BodyParser { return &BodyParser{ bodyPatterns: bodyPatterns, body: body, } } func (parser *BodyParser) GetBodyPatterns() map[string]BodyConfig { parser.mu.Lock() defer parser.mu.Unlock() copied := make(map[string]BodyConfig, len(parser.bodyPatterns)) for k, v := range parser.bodyPatterns { copied[k] = v } return copied } func (parser *BodyParser) SetBodyPatterns(patterns map[string]BodyConfig) { parser.mu.Lock() defer parser.mu.Unlock() parser.bodyPatterns = patterns } func (parser *BodyParser) Parse() (string, interface{}, error) { parser.mu.Lock() defer parser.mu.Unlock() parser.body = strings.TrimSpace(parser.body) category := findCategory(parser.body) if category == "" { return "", nil, fmt.Errorf("no category found in body text") } if patternConfig, exists := parser.bodyPatterns[category]; exists && patternConfig.Patterns != nil { for _, p := range patternConfig.Patterns { if data := extract(parser.body, p.Expression); data != nil { return parser.createBodyData(data) } } } return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body) } func findCategory(body string) string { if match := categoryRegex.FindStringSubmatch(body); match != nil { for i, name := range categoryRegex.SubexpNames() { if i != 0 && name == "category" { return match[i] } } } return "" } func extract(data string, exp *regexp.Regexp) map[string]string { match := exp.FindStringSubmatch(data) if len(match) > 0 { return extractData(match, exp) } return nil } func extractData(match []string, re *regexp.Regexp) map[string]string { data := make(map[string]string) for i, name := range re.SubexpNames() { if i != 0 && name != "" { data[name] = strings.TrimSpace(match[i]) } } return data } func (parser *BodyParser) createBodyData(data map[string]string) (string, interface{}, error) { switch category := data["category"]; category { case CategoryArrival: return category, &domain.ARR{ Category: data[Category], AircraftID: data[FlightNumber], SSRModeAndCode: data[SSR], DepartureAirport: data[DepartureCode], ArrivalAirport: data[ArrivalCode], ArrivalTime: data[ArrivalTime], }, nil case CategoryDeparture: return category, &domain.DEP{ Category: data[Category], AircraftID: data[FlightNumber], SSRModeAndCode: data[SSR], DepartureAirport: data[DepartureCode], DepartureTime: data[DepartureTime], Destination: data[ArrivalCode], }, nil case CategoryCancellation: return category, &domain.CNL{ Category: data[Category], AircraftID: data[FlightNumber], DepartureAirport: data[DepartureCode], DestinationAirport: data[ArrivalCode], }, nil case CategoryDelay: return category, &domain.DLA{ Category: data[Category], AircraftID: data[FlightNumber], DepartureAirport: data[DepartureCode], NewDepartureTime: data[DepartureTime], ArrivalAirport: data[ArrivalCode], ArrivalTime: data[ArrivalTime], }, nil case CategoryFlightPlan: otherData := parseOther(data[OtherInfo]) return category, &domain.FPL{ Category: data[Category], FlightNumber: data[FlightNumber], ReferenceData: data[ReferenceData], AircraftID: data[AircraftID], SSRModeAndCode: data[Surveillance], FlightRulesAndType: data[Indicator], CruisingSpeedAndLevel: data[Speed] + data[Level], DepartureAirport: data[DepartureCode], DepartureTime: data[DepartureTime], Route: data[Route], DestinationAndTotalTime: data[DestinationCode] + data[EstimatedTime], AlternateAirport: data[AlternateAirport], OtherInfo: data[OtherInfo], Register: otherData[Register], EstimatedArrivalTime: data[EstimatedTime], PBN: otherData[PBN], NavigationEquipment: otherData[NavigationEquipment], EstimatedElapsedTime: otherData[EstimatedElapsedTime], SELCALCode: otherData[SELCALCode], PerformanceCategory: otherData[PerformanceCategory], RerouteInformation: otherData[RerouteInformation], Remarks: otherData[Remarks], }, nil default: return category, nil, fmt.Errorf("invalid message type: %s", category) } } func headerToParsedTelegram(header Header) dto.ParsedTelegram { return dto.ParsedTelegram{ MessageID: header.MessageID, DateTime: header.DateTime, PriorityIndicator: header.PriorityIndicator, PrimaryAddress: header.PrimaryAddress, SecondaryAddresses: header.SecondaryAddresses, Originator: header.Originator, OriginatorDateTime: header.OriginatorDateTime, Category: header.Category, Body: header.Body, Content: header.Content, ReceivedAt: header.ReceivedAt, ParsedAt: header.ParsedAt, } } func Parse(rawText string) (*dto.ParsedTelegram, error) { header, err := ParseHeader(rawText) if err != nil { msg := dto.NewParsedTelegram() msg.Content = rawText msg.Comments = err.Error() msg.ErrorReason = err.Error() msg.Status = dto.MessageStatusHeaderError return msg, fmt.Errorf("%w: %w", ErrHeaderParse, err) } bodyParser := NewBodyParser(header.Body) category, bodyData, bodyErr := bodyParser.Parse() header.Category = category header.ParsedAt = time.Now() if bodyErr != nil { parsed := headerToParsedTelegram(header) parsed.Parsed = false parsed.Comments = bodyErr.Error() parsed.Status = dto.MessageStatusBodyError parsed.ErrorReason = bodyErr.Error() return &parsed, fmt.Errorf("%w: %w", ErrBodyParse, bodyErr) } parsed := headerToParsedTelegram(header) parsed.BodyData = bodyData parsed.Parsed = true parsed.Status = dto.MessageStatusParsed parsed.Uuid = uuid.New().String() return &parsed, nil } func cleanMessage(text string) string { cleanedText := emptyLineRemove.ReplaceAllString(text, "") cleanText := strings.ReplaceAll(cleanedText, "\n\n", "\n") if match := bodyOnly.FindStringSubmatch(cleanText); len(match) > 1 { bodyContent := match[2] if bodyContent[len(bodyContent)-1] == '\n' { return bodyContent[:len(bodyContent)-1] } return bodyContent } return "" } // ParseHeader parses only the header portion of the message and returns a lightweight struct // with header fields and body content. It is used internally by the aviation parser. type Header struct { MessageID string DateTime string PriorityIndicator string PrimaryAddress string SecondaryAddresses string Originator string OriginatorDateTime string Category string Content string Body string ReceivedAt time.Time ParsedAt time.Time } func ParseHeader(fullMessage string) (Header, error) { log := zap.S() cleaned := cleanMessage(fullMessage) lines := strings.Split(cleaned, "\n") if len(lines) < 3 { log.Warnf("invalid message format: %s", fullMessage) return Header{Content: fullMessage}, fmt.Errorf("invalid message format: %s", fullMessage) } _, messageID, dateTime, err := parseStartIndicator(lines[0]) if err != nil { return Header{Content: fullMessage}, err } priorityIndicator, primaryAddress := parsePriorityAndPrimary(lines[1]) secondaryAddresses, originator, originatorDateTime, body := parseRemainingLines(lines[2:]) return Header{ MessageID: messageID, DateTime: dateTime, PriorityIndicator: priorityIndicator, PrimaryAddress: primaryAddress, SecondaryAddresses: secondaryAddresses, Originator: originator, OriginatorDateTime: originatorDateTime, Content: fullMessage, Body: body, ReceivedAt: time.Now(), }, nil } func parseStartIndicator(line string) (string, string, string, error) { parts := strings.Fields(line) if len(parts) >= 3 && strings.HasPrefix(parts[0], StartIndicatorPrefix) { return parts[0], parts[1], parts[2], nil } zap.S().Warnf("invalid start indicator line format: %s", line) return "", "", "", fmt.Errorf("invalid start indicator line format: %s", line) } func parsePriorityAndPrimary(line string) (string, string) { parts := strings.Fields(line) if len(parts) >= 2 { return parts[0], parts[1] } zap.S().Warnf("invalid priority and primary address line format: %s", line) return "", "" } func parseRemainingLines(lines []string) (string, string, string, string) { var ( secondaryAddresses string originator string originatorDateTime string bodyAndFooter strings.Builder headerEnded bool ) for _, line := range lines { line = strings.TrimSpace(line) if headerEnded { bodyAndFooter.WriteString(line + "\n") } else { switch { case line == EndHeaderMarker: case strings.HasPrefix(line, "."): // Validate if dot-prefixed line matches originator format: .ORIGINATOR_CODE YYMMDD // Originator code should be uppercase letters, date/time should be digits originatorInfo := strings.Fields(line[1:]) if len(originatorInfo) >= 2 { // Check if first token is all uppercase letters and second is all digits firstToken := originatorInfo[0] secondToken := originatorInfo[1] if isAllUppercaseLetters(firstToken) && isAllDigits(secondToken) { originator = firstToken originatorDateTime = secondToken headerEnded = true } else { // Doesn't match originator format, treat as body content headerEnded = true bodyAndFooter.WriteString(line + "\n") } } else { // Not enough tokens for originator format, treat as body content headerEnded = true bodyAndFooter.WriteString(line + "\n") } case strings.HasPrefix(line, BeginPartMarker) || strings.HasPrefix(line, "("): headerEnded = true if strings.Index(line, "NNNN") > 0 { break } bodyAndFooter.WriteString(line + "\n") default: if o1, o2 := getOriginator(line); o1 != "" { originatorDateTime = o1 originator = o2 } else { secondaryAddresses = secondaryAddresses + " " + line } } } } return secondaryAddresses, originator, originatorDateTime, bodyAndFooter.String() } func getOriginator(line string) (string, string) { match := originator.FindStringSubmatch(line) if len(match) >= 3 { return match[1], match[2] } zap.S().Warnf("invalid originator line format: %s", line) return "", "" } // isAllUppercaseLetters checks if a string contains only uppercase letters func isAllUppercaseLetters(s string) bool { if len(s) == 0 { return false } for _, r := range s { if r < 'A' || r > 'Z' { return false } } return true } // isAllDigits checks if a string contains only digits func isAllDigits(s string) bool { if len(s) == 0 { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func parseOther(text string) map[string]string { data := make(map[string]string) for _, re := range otherPatterns { if match := re.FindStringSubmatch(text); len(match) > 0 { for i, name := range re.SubexpNames() { if i != 0 && name != "" { data[name] = strings.TrimSpace(match[i]) } } } } return data }