🎉first commit

This commit is contained in:
Aykhan Shahsuvarov 2024-08-22 15:17:03 +04:00
commit 1574f3014c
7 changed files with 665 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
binaries/

17
Dockerfile Normal file
View File

@ -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"]

81
README.md Normal file
View File

@ -0,0 +1,81 @@
<h1 align="center">Azal-Bot: Receive Notifications for Flights on https://azal.az</h1>
## 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"
```

32
build.sh Executable file
View File

@ -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

10
go.mod Normal file
View File

@ -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

10
go.sum Normal file
View File

@ -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=

514
main.go Normal file
View File

@ -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,
)
}