commit 1574f3014ca60d00801b3474de4b7567577d54d9 Author: Aykhan Shahsuvarov Date: Thu Aug 22 15:17:03 2024 +0400 🎉first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3155ad3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +binaries/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4feef27 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.22.6-alpine AS builder + +WORKDIR /azalbot + +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./main.go + +RUN go build -ldflags "-s -w" -o azal-bot + +FROM gcr.io/distroless/static-debian12:latest + +WORKDIR /azalbot + +COPY --from=builder /azalbot/azal-bot /azalbot/azal-bot + +ENTRYPOINT ["./azal-bot"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b12f7e8 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +

Azal-Bot: Receive Notifications for Flights on https://azal.az

+ +## Installation + +### With Docker (Recommended) +Pull the Docker image from Docker Hub: +```sh +docker pull aykhans/dodo:latest +``` + +### With Binary File +You can download the binaries from the [releases](https://github.com/aykhans/azal-bot/releases) section. + +### Build from Source +To build Azal-Bot from source, you need to have [Go 1.22+](https://golang.org/dl/) installed. Follow these steps: + +1. **Clone the repository:** + + ```sh + git clone https://github.com/aykhans/azal-bot.git + ``` + +2. **Navigate to the project directory:** + + ```sh + cd azal-bot + ``` + +3. **Build the project:** + + ```sh + go build -ldflags "-s -w" -o azal-bot + ``` + +This will generate an executable named `azal-bot` in the project directory. + +## Usage + +### Basic Usage +Search for flights from 2024-09-24T00:00:00 to 2024-09-27T23:59:59 every 60 seconds, then print the results to the CLI: +```sh +azal-bot \ + --first-date 2024-09-24 \ + --last-date 2024-09-27 \ + --from NAJ \ + --to BAK +``` +With Docker: +```sh +docker run --rm -d \ + aykhans/azal-bot \ + --first-date 2024-09-24 \ + --last-date 2024-09-27 \ + --from NAJ \ + --to BAK +``` + +### With All Flags +Search for flights from 2024-09-24T15:00:00 to 2024-09-27T21:32:10 every 120 seconds, print the results to the CLI, and send a notification via Telegram if any flights are found: +```sh +azal-bot \ + --first-date 2024-09-24T15:00:00 \ + --last-date 2024-09-27T21:32:10 \ + --repeat-interval 120 \ # seconds + --from NAJ \ + --to BAK \ + --telegram-bot-key "key" \ + --telegram-chat-id "id" +``` +With Docker: +```sh +docker run --rm -d \ + aykhans/azal-bot \ + --first-date 2024-09-24T15:00:00 \ + --last-date 2024-09-27T21:32:10 \ + --repeat-interval 120 \ # seconds + --from NAJ \ + --to BAK \ + --telegram-bot-key "key" \ + --telegram-chat-id "id" +``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..c047189 --- /dev/null +++ b/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +platforms=( + "darwin,amd64" + "darwin,arm64" + "freebsd,386" + "freebsd,amd64" + "freebsd,arm" + "linux,386" + "linux,amd64" + "linux,arm" + "linux,arm64" + "netbsd,386" + "netbsd,amd64" + "netbsd,arm" + "openbsd,386" + "openbsd,amd64" + "openbsd,arm" + "openbsd,arm64" + "windows,386" + "windows,amd64" + "windows,arm64" +) + +for platform in "${platforms[@]}"; do + IFS=',' read -r build_os build_arch <<< "$platform" + ext="" + if [ "$build_os" == "windows" ]; then + ext=".exe" + fi + GOOS="$build_os" GOARCH="$build_arch" go build -ldflags "-s -w" -o "./binaries/azal-bot-$build_os-$build_arch$ext" +done \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..250b028 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/aykhans/azal-bot + +go 1.22.6 + +require ( + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 +) + +require github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..912390a --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5aafac2 --- /dev/null +++ b/main.go @@ -0,0 +1,514 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "io" + "log" + "net/http" + "os" + "reflect" + "time" +) + +const ( + RequestURL = "https://azal.az/book/api/flights/search/by-deeplink" + Version = "0.1.0" +) + +var ( + ErrorNoFlightsAvailable = fmt.Errorf("no flights available") +) + +var Colors = struct { + reset string + Red string + Green string + Yellow string + Orange string + Blue string + Magenta string + Cyan string + Gray string + White string +}{ + reset: "\033[0m", + Red: "\033[31m", + Green: "\033[32m", + Yellow: "\033[33m", + Orange: "\033[38;5;208m", + Blue: "\033[34m", + Magenta: "\033[35m", + Cyan: "\033[36m", + Gray: "\033[37m", + White: "\033[97m", +} + +func Colored(color string, a ...any) string { + return color + fmt.Sprint(a...) + Colors.reset +} + +type AvialableFlights map[string][]time.Time + +type UserInput struct { + FirstDate time.Time + LastDate time.Time + From string + To string + TelegramBotKey string + TelegramChatID string + RepetInterval time.Duration +} + +type BotConfig struct { + FirstDate time.Time + LastDate time.Time + From string + To string + days []string + RepetInterval time.Duration +} + +type ResponseTime struct { + time.Time +} + +func (responseTime *ResponseTime) UnmarshalJSON(b []byte) error { + s := string(b) + s = s[1 : len(s)-1] + + t, err := time.Parse("2006-01-02T15:04:05", s) + if err != nil { + return err + } + responseTime.Time = t + return nil +} + +type SuccessResponse struct { + Search struct { + OptionSets []struct { + Options []struct { + ID string `json:"id"` + Available bool `json:"available"` + Route struct { + ID string `json:"id"` + DepartureDate ResponseTime `json:"departureDate"` + } `json:"route"` + } `json:"options"` + } `json:"optionSets"` + } `json:"search"` +} + +type ErrorResponse struct { + Error struct { + Code string `json:"code"` + Text string `json:"text"` + } `json:"error"` +} + +type HeaderConfig struct { + Host string `req_header:"Host"` + UserAgent string `req_header:"User-Agent"` + Accept string `req_header:"Accept"` + AcceptLanguage string `req_header:"Accept-Language"` + AcceptEncoding string `req_header:"Accept-Encoding"` + XApplication string `req_header:"x-application"` + XLocale string `req_header:"x-locale"` + Connection string `req_header:"Connection"` + Referer string `req_header:"Referer"` + SecFetchDest string `req_header:"Sec-Fetch-Dest"` + SecFetchMode string `req_header:"Sec-Fetch-Mode"` + SecFetchSite string `req_header:"Sec-Fetch-Site"` + TE string `req_header:"TE"` +} + +func (headerConf *HeaderConfig) setDefaults() { + if headerConf.Host == "" { + headerConf.Host = "book.azal.az" + } + if headerConf.UserAgent == "" { + headerConf.UserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0" + } + if headerConf.Accept == "" { + headerConf.Accept = "application/json, text/plain, */*" + } + if headerConf.AcceptLanguage == "" { + headerConf.AcceptLanguage = "en-US,en;q=0.5" + } + if headerConf.AcceptEncoding == "" { + headerConf.AcceptEncoding = "gzip, deflate, br" + } + if headerConf.XApplication == "" { + headerConf.XApplication = "ibe" + } + if headerConf.XLocale == "" { + headerConf.XLocale = "az" + } + if headerConf.Connection == "" { + headerConf.Connection = "keep-alive" + } + if headerConf.SecFetchDest == "" { + headerConf.SecFetchDest = "empty" + } + if headerConf.SecFetchMode == "" { + headerConf.SecFetchMode = "cors" + } + if headerConf.SecFetchSite == "" { + headerConf.SecFetchSite = "same-origin" + } + if headerConf.TE == "" { + headerConf.TE = "trailers" + } +} + +func (headerConf *HeaderConfig) setToRequest(req *http.Request) { + t := reflect.TypeOf(*headerConf) + v := reflect.ValueOf(headerConf).Elem() + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + tag := field.Tag.Get("req_header") + value := v.Field(i).String() + req.Header.Set(tag, value) + } +} + +type QueryConfig struct { + Lang string `req_query:"lang"` + From string `req_query:"from"` + To string `req_query:"to"` + DepartureDate string `req_query:"departure_date"` + TripType string `req_query:"tripType"` + AdultCount string `req_query:"adult_count"` + ChildCount string `req_query:"child_count"` + InfantCount string `req_query:"infant_count"` + IsStudent string `req_query:"is_student"` + Timestamp string `req_query:"timestamp"` + IsCitizen string `req_query:"is_citizen"` + Currency string `req_query:"currency"` + Theme string `req_query:"theme"` +} + +func (queryConf *QueryConfig) setDefaults() { + if queryConf.Lang == "" { + queryConf.Lang = "az" + } + if queryConf.TripType == "" { + queryConf.TripType = "OW" + } + if queryConf.AdultCount == "" { + queryConf.AdultCount = "1" + } + if queryConf.ChildCount == "" { + queryConf.ChildCount = "0" + } + if queryConf.InfantCount == "" { + queryConf.InfantCount = "0" + } + if queryConf.IsStudent == "" { + queryConf.IsStudent = "0" + } + if queryConf.Timestamp == "" { + queryConf.Timestamp = fmt.Sprintf("%d", time.Now().UnixNano()/int64(time.Millisecond)) + } + if queryConf.IsCitizen == "" { + queryConf.IsCitizen = "1" + } + if queryConf.Currency == "" { + queryConf.Currency = "AZN" + } + if queryConf.Theme == "" { + queryConf.Theme = "dark" + } +} + +func (queryConf *QueryConfig) setToRequest(req *http.Request) { + q := req.URL.Query() + t := reflect.TypeOf(*queryConf) + v := reflect.ValueOf(queryConf).Elem() + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + tag := field.Tag.Get("req_query") + value := v.Field(i).String() + q.Add(tag, value) + } + + req.URL.RawQuery = q.Encode() +} + +func handleErrorResponse(errorResponse *ErrorResponse) error { + switch errorResponse.Error.Code { + case "no.flights.available": + return ErrorNoFlightsAvailable + default: + return fmt.Errorf("unknown error: %s", errorResponse.Error.Code) + } +} + +func sendRequest(queryConf *QueryConfig, headerConf *HeaderConfig) (*SuccessResponse, error) { + req, err := http.NewRequest("GET", RequestURL, nil) + if err != nil { + return nil, err + } + + headerConf.setToRequest(req) + queryConf.setToRequest(req) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data map[string]interface{} + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status code: %d", resp.StatusCode) + } + if err := json.Unmarshal(respBody, &data); err != nil { + return nil, err + } + errorResponseData := &ErrorResponse{} + if err := json.Unmarshal(respBody, &errorResponseData); err != nil { + return nil, err + } + if errorResponseData.Error.Code != "" { + return nil, handleErrorResponse(errorResponseData) + } + successResponseData := &SuccessResponse{} + if err := json.Unmarshal(respBody, &successResponseData); err != nil { + return nil, err + } + return successResponseData, nil +} + +func getUserInput() *UserInput { + var ( + firstDate, + lastDate, + from, + to, + telegramBotKey, + telegramChatID string + repetInterval uint32 + userInput = &UserInput{} + ) + + var rootCmd = &cobra.Command{ + Use: "Azal Bot", + Short: "A CLI tool to find the flights", + Version: Version, + Run: func(cmd *cobra.Command, args []string) { + first, err := time.Parse("2006-01-02T15:04:05", firstDate) + if err != nil { + first, err = time.Parse("2006-01-02", firstDate) + if err != nil { + fmt.Printf("Error: parsing FirstDate: %v\n", err) + cmd.Help() + os.Exit(1) + } + } + last, err := time.Parse("2006-01-02T15:04:05", lastDate) + if err != nil { + last, err = time.Parse("2006-01-02", lastDate) + if err != nil { + fmt.Printf("Error: parsing LastDate: %v\n", err) + cmd.Help() + os.Exit(1) + } + last = last.AddDate(0, 0, 1) + last = last.Add(-time.Second) + } + if first.After(last) || first.Equal(last) { + fmt.Println("Error: first date should be before last date and they should not be equal") + cmd.Help() + os.Exit(1) + } + if repetInterval < 1 { + fmt.Println("Error: repetInterval should be greater than 0") + cmd.Help() + os.Exit(1) + } + if len(from) > 5 || len(from) < 2 { + fmt.Println("Error: from should be between 2 and 5 characters") + cmd.Help() + os.Exit(1) + } + if len(to) > 5 || len(to) < 2 { + fmt.Println("Error: to should be between 2 and 5 characters") + cmd.Help() + os.Exit(1) + } + if telegramBotKey != "" { + if telegramChatID == "" { + fmt.Println("Error: telegramChatID is required if telegramBotKey is provided") + cmd.Help() + os.Exit(1) + } + } + if telegramChatID != "" { + if telegramBotKey == "" { + fmt.Println("Error: telegramBotKey is required if telegramChatID is provided") + cmd.Help() + os.Exit(1) + } + } + + userInput.FirstDate = first + userInput.LastDate = last + userInput.From = from + userInput.To = to + userInput.TelegramBotKey = telegramBotKey + userInput.TelegramChatID = telegramChatID + userInput.RepetInterval = time.Duration(repetInterval) * time.Second + }, + } + + rootCmd.Flags().StringVarP(&firstDate, "first-date", "i", "", "First date in format '2006-01-02T15:04:05'") + rootCmd.Flags().StringVarP(&lastDate, "last-date", "l", "", "Last date in format '2006-01-02T15:04:05'") + rootCmd.Flags().StringVarP(&from, "from", "f", "", "From where you want to fly (e.g. NAJ)") + rootCmd.Flags().StringVarP(&to, "to", "t", "", "To where you want to fly (e.g. BAK)") + rootCmd.Flags().StringVar(&telegramBotKey, "telegram-bot-key", "", "Telegram bot key") + rootCmd.Flags().StringVar(&telegramChatID, "telegram-chat-id", "", "Telegram chat id") + rootCmd.Flags().Uint32VarP(&repetInterval, "repet-interval", "r", 60, "Repetition interval in seconds") + + rootCmd.MarkFlagRequired("first-date") + rootCmd.MarkFlagRequired("last-date") + rootCmd.MarkFlagRequired("from") + rootCmd.MarkFlagRequired("to") + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } + + rootCmd.Flags().Visit(func(flag *pflag.Flag) { + switch flag.Name { + case "version": + os.Exit(0) + case "help": + os.Exit(0) + } + }) + + return userInput +} + +func sendTelegramMessage(avialableFlights AvialableFlights, botKey string, chatID string) error { + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botKey) + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + message := "Azal Bot\n\n" + for day, flights := range avialableFlights { + message += fmt.Sprintf("%s\n-----------\n", day) + for _, flight := range flights { + message += fmt.Sprintf("%s\n", flight.Format("15:04:05")) + } + message += "\n" + } + message = message[:len(message)-1] + q := req.URL.Query() + q.Add("chat_id", chatID) + q.Add("text", message) + q.Add("parse_mode", "HTML") + req.URL.RawQuery = q.Encode() + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("telegram send message status code: %d", resp.StatusCode) + } + return nil +} + +func startBot(botConfig *BotConfig, ifAvailable func(avialableFlights AvialableFlights) error) { + queryConf := QueryConfig{ + From: botConfig.From, + To: botConfig.To, + } + queryConf.setDefaults() + headerConf := HeaderConfig{} + headerConf.setDefaults() + + for { + avialableFlights := make(AvialableFlights) + for _, day := range botConfig.days { + queryConf.DepartureDate = day + data, err := sendRequest(&queryConf, &headerConf) + if err != nil { + if err == ErrorNoFlightsAvailable { + log.Println(Colored(Colors.Yellow, "No flights available for ", day)) + continue + } + log.Println(Colored(Colors.Red, err.Error())) + continue + } + + for _, option := range data.Search.OptionSets[0].Options { + departureDate := option.Route.DepartureDate + if (departureDate.After(botConfig.FirstDate) || departureDate.Equal(botConfig.FirstDate)) && + (departureDate.Before(botConfig.LastDate) || departureDate.Equal(botConfig.LastDate)) { + + avialableFlights[day] = append(avialableFlights[day], departureDate.Time) + log.Println(Colored(Colors.Green, "Flight available for ", departureDate)) + } else { + log.Println(Colored(Colors.Yellow, "No flights available for ", departureDate)) + } + } + } + if err := ifAvailable(avialableFlights); err != nil { + log.Println(Colored(Colors.Red, "Error: ", err.Error())) + } + time.Sleep(botConfig.RepetInterval) + } +} + +func main() { + userInput := getUserInput() + botConfig := &BotConfig{ + FirstDate: userInput.FirstDate, + LastDate: userInput.LastDate, + From: userInput.From, + To: userInput.To, + RepetInterval: userInput.RepetInterval, + } + for current := userInput.FirstDate; !current.After(userInput.LastDate); current = current.AddDate(0, 0, 1) { + botConfig.days = append(botConfig.days, current.Format("2006-01-02")) + } + + ifAvailableFunc := func(avialableFlights AvialableFlights) error { return nil } + if userInput.TelegramBotKey != "" { + ifAvailableFunc = func(avialableFlights AvialableFlights) error { + if len(avialableFlights) > 0 { + return sendTelegramMessage( + avialableFlights, + userInput.TelegramBotKey, + userInput.TelegramChatID, + ) + } + return nil + } + } + + startBot( + botConfig, + ifAvailableFunc, + ) +}