Compare commits

...

12 commits

8 changed files with 311 additions and 45 deletions

25
.dockerignore Normal file
View file

@ -0,0 +1,25 @@
### Go template
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Also ignore the Dockerfile
Dockerfile

View file

@ -0,0 +1,30 @@
name: Build latest Docker image
on:
push:
branches:
- master
jobs:
docker:
permissions:
contents: read
packages: write
runs-on:
- ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/${{ github.actor }}/cfr_train_info_telegram_bot:latest

5
.idea/vcs.xml generated
View file

@ -9,6 +9,11 @@
</inspection_tool> </inspection_tool>
</profile> </profile>
</component> </component>
<component name="GitSharedSettings">
<option name="FORCE_PUSH_PROHIBITED_PATTERNS">
<list />
</option>
</component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
</component> </component>

36
Dockerfile Normal file
View file

@ -0,0 +1,36 @@
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS build
LABEL authors="kbruen"
LABEL org.opencontainers.image.source=https://github.com/dancojocaru2000/CfrTrainInfoTelegramBot
RUN apk add tzdata
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
RUN apk add zig@testing
COPY --from=xx / /
ARG TARGETPLATFORM
ENV CGO_ENABLED=1
WORKDIR /app
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY main.go ./
COPY pkg ./pkg/
ARG TARGETOS
ARG TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_CFLAGS="-D_LARGEFILE64_SOURCE" CC="zig cc -target $(xx-info march)-$(xx-info os)-$(xx-info libc)" CXX="zig c++ -target $(xx-info march)-$(xx-info os)-$(xx-info libc)" go build -o server && xx-verify server
FROM scratch
COPY --from=build /etc/ssl/certs /etc/ssl/certs
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
WORKDIR /app
# COPY static ./static/
COPY --from=build /app/server ./
ENV DEBUG=false
ENTRYPOINT [ "/app/server" ]

19
main.go
View file

@ -44,7 +44,7 @@ You may use ` + cancelCommand + ` to cancel any ongoing command.`
You may also send the date as a message in the following formats: dd.mm.yyyy, m/d/yyyy, yyyy-mm-dd, UNIX timestamp. You may also send the date as a message in the following formats: dd.mm.yyyy, m/d/yyyy, yyyy-mm-dd, UNIX timestamp.
Keep in mind that, for night trains, this date might be yesterday.` Keep in mind that, for night trains, this date might be yesterday.`
invalidDateMessage = "Invalid date. Please try again or us " + cancelCommand + " to cancel." invalidDateMessage = "Invalid date. Please try again or use " + cancelCommand + " to cancel."
) )
func main() { func main() {
@ -59,7 +59,13 @@ func main() {
log.Fatal("ERROR: No bot token supplied; supply with CFR_BOT.TOKEN") log.Fatal("ERROR: No bot token supplied; supply with CFR_BOT.TOKEN")
} }
db, err := gorm.Open(sqlite.Open("bot_db.sqlite"), &gorm.Config{}) dbPath := os.Getenv("CFR_BOT.DB_PATH")
dbPath = strings.TrimSpace(dbPath)
if len(dbPath) == 0 {
dbPath = "bot_db.sqlite"
}
log.Printf("INFO : DB Path: %s\n", dbPath)
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -257,7 +263,7 @@ func handler(ctx context.Context, b *tgBot.Bot, update *models.Update, subs *sub
}, },
} }
} else { } else {
// TODO: Update message to contain unsubscribe button log.Printf("DEBUG: Subscribed: chatID %d, trainNumber %s, date %s, groupIndex %d", update.CallbackQuery.Message.Chat.ID, trainNumber, date.Format("2006-01-02"), groupIndex)
response = &handlers.HandlerResponse{ response = &handlers.HandlerResponse{
CallbackAnswer: &tgBot.AnswerCallbackQueryParams{ CallbackAnswer: &tgBot.AnswerCallbackQueryParams{
Text: fmt.Sprintf("Subscribed successfully!"), Text: fmt.Sprintf("Subscribed successfully!"),
@ -287,7 +293,7 @@ func handler(ctx context.Context, b *tgBot.Bot, update *models.Update, subs *sub
}, },
} }
} else { } else {
// TODO: Update message to contain unsubscribe button log.Printf("DEBUG: Unsubscribed: chatID %d, trainNumber %s, date %s, groupIndex %d", update.CallbackQuery.Message.Chat.ID, trainNumber, date.Format("2006-01-02"), groupIndex)
response = &handlers.HandlerResponse{ response = &handlers.HandlerResponse{
CallbackAnswer: &tgBot.AnswerCallbackQueryParams{ CallbackAnswer: &tgBot.AnswerCallbackQueryParams{
Text: fmt.Sprintf("Unsubscribed successfully!"), Text: fmt.Sprintf("Unsubscribed successfully!"),
@ -301,6 +307,9 @@ func handler(ctx context.Context, b *tgBot.Bot, update *models.Update, subs *sub
}, },
} }
} }
default:
log.Printf("WARN : Unknown callback query method: %s", splitted[0])
} }
} }
} }
@ -334,7 +343,7 @@ func handleFindTrainStages(ctx context.Context, b *tgBot.Bot, update *models.Upd
groupIndex := -1 groupIndex := -1
if len(commandParams) > 1 { if len(commandParams) > 1 {
date, _ = time.Parse(time.RFC3339, commandParams[1]) date, _ = utils.ParseDate(commandParams[1])
} }
if len(commandParams) > 2 { if len(commandParams) > 2 {
groupIndex, _ = strconv.Atoi(commandParams[2]) groupIndex, _ = strconv.Atoi(commandParams[2])

View file

@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"dcdev.ro/CfrTrainInfoTelegramBot/pkg/utils"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@ -46,6 +47,11 @@ func HandleTrainNumberCommand(ctx context.Context, trainNumber string, date time
Message: &bot.SendMessageParams{ Message: &bot.SendMessageParams{
Text: fmt.Sprintf("The train %s was not found.", trainNumber), Text: fmt.Sprintf("The train %s was not found.", trainNumber),
}, },
ShouldUnsubscribe: func() bool {
now := time.Now().In(utils.Location)
midnightYesterday := time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, utils.Location)
return date.Before(midnightYesterday)
}(),
}, false }, false
case errors.Is(err, api.ServerError): case errors.Is(err, api.ServerError):
log.Printf("ERROR: In handle train number: %s", err.Error()) log.Printf("ERROR: In handle train number: %s", err.Error())
@ -53,6 +59,11 @@ func HandleTrainNumberCommand(ctx context.Context, trainNumber string, date time
Message: &bot.SendMessageParams{ Message: &bot.SendMessageParams{
Text: fmt.Sprintf("Unknown server error when searching for train %s.", trainNumber), Text: fmt.Sprintf("Unknown server error when searching for train %s.", trainNumber),
}, },
ShouldUnsubscribe: func() bool {
now := time.Now().In(utils.Location)
midnightYesterday := time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, utils.Location)
return date.Before(midnightYesterday)
}(),
}, false }, false
default: default:
log.Printf("ERROR: In handle train number: %s", err.Error()) log.Printf("ERROR: In handle train number: %s", err.Error())
@ -63,6 +74,35 @@ func HandleTrainNumberCommand(ctx context.Context, trainNumber string, date time
groupIndex = 0 groupIndex = 0
} }
shouldUnsubscribe := func() bool {
if groupIndex == -1 {
return false
}
if len(trainData.Groups) <= groupIndex {
groupIndex = 0
}
now := time.Now().In(utils.Location)
midnightYesterday := time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, utils.Location)
lastStation := trainData.Groups[groupIndex].
Stations[len(trainData.Groups[groupIndex].Stations)-1]
if now.After(lastStation.Arrival.
ScheduleTime.Add(time.Hour * 6)) {
return true
}
if trainData.Groups[groupIndex].
Status != nil && trainData.Groups[groupIndex].
Status.Station == lastStation.Name &&
trainData.Groups[groupIndex].Status.
State == "arrival" {
return true
}
if date.Before(midnightYesterday) {
return true
}
return false
}()
message := bot.SendMessageParams{} message := bot.SendMessageParams{}
if groupIndex == -1 { if groupIndex == -1 {
message.Text = fmt.Sprintf("Train %s %s contains multiple groups. Please choose one.", trainData.Rank, trainData.Number) message.Text = fmt.Sprintf("Train %s %s contains multiple groups. Please choose one.", trainData.Rank, trainData.Number)
@ -97,6 +137,68 @@ func HandleTrainNumberCommand(ctx context.Context, trainNumber string, date time
messageText.WriteString(fmt.Sprintf("Date: %s\n", trainData.Date)) messageText.WriteString(fmt.Sprintf("Date: %s\n", trainData.Date))
messageText.WriteString(fmt.Sprintf("Operator: %s\n", trainData.Operator)) messageText.WriteString(fmt.Sprintf("Operator: %s\n", trainData.Operator))
nextStopIdx := -1
for i, station := range group.Stations {
if station.Arrival != nil && time.Now().Before(station.Arrival.ScheduleTime.Add(func() time.Duration {
if station.Arrival.Status != nil {
return time.Minute * time.Duration(station.Arrival.Status.Delay)
} else {
return time.Nanosecond * 0
}
}())) {
nextStopIdx = i
break
}
if station.Departure != nil && time.Now().Before(station.Departure.ScheduleTime.Add(func() time.Duration {
if station.Departure.Status != nil {
return time.Minute * time.Duration(station.Departure.Status.Delay)
} else {
return time.Nanosecond * 0
}
}())) {
nextStopIdx = i
break
}
}
if nextStopIdx != -1 {
nextStop := &group.Stations[nextStopIdx]
arrTime := func() *time.Time {
if nextStop.Arrival == nil {
return nil
}
if nextStop.Arrival.Status != nil {
result := nextStop.Arrival.ScheduleTime.Add(time.Minute * time.Duration(nextStop.Arrival.Status.Delay))
return &result
}
return &nextStop.Arrival.ScheduleTime
}()
if arrTime != nil && time.Now().Before(*arrTime) {
arrStr := "less than 1m"
arrDiff := arrTime.Sub(time.Now())
if arrDiff/time.Hour >= 1 {
arrStr = fmt.Sprintf("%dh%dm", arrDiff/time.Hour, (arrDiff%time.Hour)/time.Minute)
} else if arrDiff/time.Minute >= 1 {
arrStr = fmt.Sprintf("%dm", arrDiff/time.Minute)
}
messageText.WriteString(fmt.Sprintf("Next stop: %s, arriving in %s at %s\n", nextStop.Name, arrStr, arrTime.In(utils.Location).Format("15:04")))
} else {
depStr := "less than 1m"
depTime := nextStop.Departure.ScheduleTime.Add(func() time.Duration {
if nextStop.Departure.Status != nil {
return time.Minute * time.Duration(nextStop.Departure.Status.Delay)
} else {
return time.Nanosecond * 0
}
}())
depDiff := depTime.Sub(time.Now())
if depDiff/time.Hour >= 1 {
depStr = fmt.Sprintf("%dh%dm", depDiff/time.Hour, (depDiff%time.Hour)/time.Minute)
} else if depDiff/time.Minute >= 1 {
depStr = fmt.Sprintf("%dm", depDiff/time.Minute)
}
messageText.WriteString(fmt.Sprintf("Currently stopped at: %s, departing in %s at %s\n", nextStop.Name, depStr, depTime.In(utils.Location).Format("15:04")))
}
}
if group.Status != nil { if group.Status != nil {
messageText.WriteString("Status: ") messageText.WriteString("Status: ")
if group.Status.Delay == 0 { if group.Status.Delay == 0 {
@ -136,7 +238,9 @@ func HandleTrainNumberCommand(ctx context.Context, trainNumber string, date time
}, },
} }
buttonKind := TrainInfoResponseButtonIncludeSub buttonKind := TrainInfoResponseButtonIncludeSub
if isSubscribed { if shouldUnsubscribe {
buttonKind = TrainInfoResponseButtonExcludeSub
} else if isSubscribed {
buttonKind = TrainInfoResponseButtonIncludeUnsub buttonKind = TrainInfoResponseButtonIncludeUnsub
} }
message.ReplyMarkup = GetTrainNumberCommandResponseButtons(trainData.Number, group.Stations[0].Departure.ScheduleTime, groupIndex, buttonKind) message.ReplyMarkup = GetTrainNumberCommandResponseButtons(trainData.Number, group.Stations[0].Departure.ScheduleTime, groupIndex, buttonKind)
@ -153,7 +257,8 @@ func HandleTrainNumberCommand(ctx context.Context, trainNumber string, date time
} }
return &HandlerResponse{ return &HandlerResponse{
Message: &message, Message: &message,
ShouldUnsubscribe: shouldUnsubscribe,
}, true }, true
} }

View file

@ -8,6 +8,7 @@ type HandlerResponse struct {
CallbackAnswer *bot.AnswerCallbackQueryParams CallbackAnswer *bot.AnswerCallbackQueryParams
MessageEdits []*bot.EditMessageTextParams MessageEdits []*bot.EditMessageTextParams
MessageMarkupEdits []*bot.EditMessageReplyMarkupParams MessageMarkupEdits []*bot.EditMessageReplyMarkupParams
ShouldUnsubscribe bool
Injected struct { Injected struct {
ChatId int64 ChatId int64
MessageId int MessageId int

View file

@ -98,15 +98,14 @@ func (sub *Subscriptions) DeleteSubscription(chatId int64, messageId int) (*SubD
break break
} }
} }
var result *SubData var result SubData
if deleteIndex != -1 { if deleteIndex != -1 {
result = &SubData{} result = datas[deleteIndex]
*result = datas[deleteIndex]
datas[deleteIndex] = datas[len(datas)-1] datas[deleteIndex] = datas[len(datas)-1]
datas = datas[:len(datas)-1] datas = datas[:len(datas)-1]
_, err := database.WriteDB(func(db *gorm.DB) (*gorm.DB, error) { _, err := database.WriteDB(func(db *gorm.DB) (*gorm.DB, error) {
db.Delete(result) db.Delete(&result)
return db, db.Error return db, db.Error
}) })
if err != nil { if err != nil {
@ -120,11 +119,11 @@ func (sub *Subscriptions) DeleteSubscription(chatId int64, messageId int) (*SubD
} else { } else {
sub.data[chatId] = datas sub.data[chatId] = datas
} }
return result, nil return &result, nil
} }
func (sub *Subscriptions) CheckSubscriptions(ctx context.Context) { func (sub *Subscriptions) CheckSubscriptions(ctx context.Context) {
ticker := time.NewTicker(time.Second * 90) ticker := time.NewTicker(time.Second * 45)
sub.executeChecks(ctx) sub.executeChecks(ctx)
for { for {
@ -142,54 +141,110 @@ type workerData struct {
data SubData data SubData
} }
type unsubscribe struct {
chatId int64
messageId int
}
type workerResponseData struct {
unsubscribe *unsubscribe
}
func (sub *Subscriptions) executeChecks(ctx context.Context) { func (sub *Subscriptions) executeChecks(ctx context.Context) {
sub.mutex.RLock() sub.mutex.RLock()
defer sub.mutex.RUnlock()
// Only allow 8 concurrent requests // Only allow 8 concurrent requests
// TODO: Make configurable instead of hardcoded // TODO: Make configurable instead of hardcoded
workerCount := 8 workerCount := 8
workerChan := make(chan workerData, workerCount) workerChan := make(chan workerData, workerCount)
wg := &sync.WaitGroup{} responseChan := make(chan *workerResponseData, workerCount)
defer close(responseChan)
for i := 0; i < workerCount; i++ { for i := 0; i < workerCount; i++ {
wg.Add(1) go checkWorker(ctx, workerChan, responseChan)
go checkWorker(ctx, workerChan, wg)
} }
go func() {
for _, datas := range sub.data {
for i := range datas {
workerChan <- workerData{
tgBot: sub.tgBot,
data: datas[i],
}
}
}
close(workerChan)
}()
responses := make([]*workerResponseData, 0, len(sub.data))
for _, datas := range sub.data { for _, datas := range sub.data {
for i := range datas { for range datas {
workerChan <- workerData{ if resp := <-responseChan; resp != nil && resp.unsubscribe != nil {
tgBot: sub.tgBot, responses = append(responses, resp)
data: datas[i],
} }
} }
} }
close(workerChan)
wg.Wait()
}
func checkWorker(ctx context.Context, workerChan <-chan workerData, wg *sync.WaitGroup) { sub.mutex.RUnlock()
defer wg.Done()
for wData := range workerChan {
data := wData.data
log.Printf("DEBUG: Timer tick, update for chat %d, train %s, date %s, group %d", data.ChatId, data.TrainNumber, data.Date.Format("2006-01-02"), data.GroupIndex)
resp, ok := handlers.HandleTrainNumberCommand(ctx, data.TrainNumber, data.Date, data.GroupIndex, true) for i := range responses {
if responses[i].unsubscribe != nil {
if !ok || resp == nil || resp.Message == nil { // Ignore error since this is optional optimisation
// Silently discard update errors deletedSub, err := sub.DeleteSubscription(responses[i].unsubscribe.chatId, responses[i].unsubscribe.messageId)
log.Printf("DEBUG: Error when updating chat %d, train %s, date %s, group %d", data.ChatId, data.TrainNumber, data.Date.Format("2006-01-02"), data.GroupIndex) if err == nil && deletedSub != nil {
return _, _ = sub.tgBot.EditMessageReplyMarkup(ctx, &bot.EditMessageReplyMarkupParams{
ChatID: responses[i].unsubscribe.chatId,
MessageID: responses[i].unsubscribe.messageId,
ReplyMarkup: handlers.GetTrainNumberCommandResponseButtons(deletedSub.TrainNumber, deletedSub.Date, deletedSub.GroupIndex, handlers.TrainInfoResponseButtonExcludeSub),
})
}
} }
}
_, _ = wData.tgBot.EditMessageText(ctx, &bot.EditMessageTextParams{ }
ChatID: data.ChatId,
MessageID: data.MessageId, func checkWorker(ctx context.Context, workerChan <-chan workerData, responseChan chan<- *workerResponseData) {
Text: resp.Message.Text, for wData := range workerChan {
ParseMode: resp.Message.ParseMode, func() {
Entities: resp.Message.Entities, var response *workerResponseData
DisableWebPagePreview: resp.Message.DisableWebPagePreview, defer func() {
ReplyMarkup: resp.Message.ReplyMarkup, responseChan <- response
}) }()
data := wData.data
log.Printf("DEBUG: Timer tick, update for chat %d, train %s, date %s, group %d", data.ChatId, data.TrainNumber, data.Date.Format("2006-01-02"), data.GroupIndex)
resp, ok := handlers.HandleTrainNumberCommand(ctx, data.TrainNumber, data.Date, data.GroupIndex, true)
if !ok || resp == nil || resp.Message == nil {
// Silently discard update errors
log.Printf("DEBUG: Error when updating chat %d, train %s, date %s, group %d", data.ChatId, data.TrainNumber, data.Date.Format("2006-01-02"), data.GroupIndex)
if resp != nil && resp.ShouldUnsubscribe {
response = &workerResponseData{
unsubscribe: &unsubscribe{
chatId: data.ChatId,
messageId: data.MessageId,
},
}
}
return
}
_, _ = wData.tgBot.EditMessageText(ctx, &bot.EditMessageTextParams{
ChatID: data.ChatId,
MessageID: data.MessageId,
Text: resp.Message.Text,
ParseMode: resp.Message.ParseMode,
Entities: resp.Message.Entities,
DisableWebPagePreview: resp.Message.DisableWebPagePreview,
ReplyMarkup: resp.Message.ReplyMarkup,
})
response = &workerResponseData{}
if resp.ShouldUnsubscribe {
response.unsubscribe = &unsubscribe{
chatId: data.ChatId,
messageId: data.MessageId,
}
}
}()
} }
} }