diff --git a/README.md b/README.md index 1651cde..d3569ac 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ You can configure some settings using a `config.json` file in the working direct `port` (`8080`): Set the port to which KISSS should listen +`baseUrl` (optional, required for Telegram): Set the base URL on which KISSS runs + `dnt` (`true`): Set whether or not KISSS should respect Do-Not-Track headers some browsers send `dbPath` (`data/kis3.db`): Set the path for the SQLite database (relative to the working directory - in the Docker container it's `/app`). @@ -50,7 +52,7 @@ The configuration file can look like this: } ``` -If you specify an environment variable (`PORT`, `DNT`, `DB_PATH`, `STATS_USERNAME`, `STATS_PASSWORD`), that will override the settings from the configuration file. +If you specify an environment variable (`PORT`, `BASE_URL`, `DNT`, `DB_PATH`, `STATS_USERNAME`, `STATS_PASSWORD`), that will override the settings from the configuration file. ### Email @@ -68,7 +70,9 @@ To enable email integration for sending reports, you need to add some configurat The Telegram integration allows sending reports via Telegram and also requesting stats via Telegram. For that the following configuration value must be set: -`tgBotToken`: Token for the Telegram bot, which you can request via the [Bot Father](https://t.me/BotFather). +`tgBotToken`: Token for the Telegram bot, which you can request via the [Bot Father](https://t.me/BotFather) + +`tgHookSecret` (optional): Secret, so nobody (except KISSS and Telegram) knows the URL to which Telegram should send updates about new messages ## Add to website diff --git a/config.go b/config.go index 254a0ee..21672c9 100644 --- a/config.go +++ b/config.go @@ -10,6 +10,7 @@ import ( type config struct { Port string `json:"port"` + BaseUrl string `json:"baseUrl"` Dnt bool `json:"dnt"` DbPath string `json:"dbPath"` StatsUsername string `json:"statsUsername"` @@ -19,17 +20,15 @@ type config struct { SmtpPassword string `json:"smtpPassword"` SmtpHost string `json:"smtpHost"` TGBotToken string `json:"tgBotToken"` + TGHookSecret string `json:"tgHookSecret"` Reports []report `json:"reports"` } var ( appConfig = &config{ - Port: "8080", - Dnt: true, - DbPath: "data/kis3.db", - StatsUsername: "", - StatsPassword: "", - TGBotToken: "", + Port: "8080", + Dnt: true, + DbPath: "data/kis3.db", } ) @@ -58,6 +57,10 @@ func overwriteEnvVarValues(appConfig *config) { if set { appConfig.Port = port } + baseUrl, set := os.LookupEnv("BASE_URL") + if set { + appConfig.BaseUrl = baseUrl + } dntString, set := os.LookupEnv("DNT") dntBool, e := strconv.ParseBool(dntString) if set && e == nil { diff --git a/go.mod b/go.mod index db6bf02..de1ee77 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.14 require ( github.com/go-sql-driver/mysql v1.5.0 // indirect - github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/gobuffalo/packr/v2 v2.8.0 github.com/google/uuid v1.1.1 // indirect github.com/gorilla/handlers v1.4.2 @@ -18,7 +17,6 @@ require ( github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/rubenv/sql-migrate v0.0.0-20200401080106-baf4e4b6812c github.com/sirupsen/logrus v1.5.0 // indirect - github.com/technoweenie/multipartstreamer v1.0.1 // indirect github.com/whiteshtef/clockwork v0.0.0-20200221012748-027e62affd84 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 // indirect golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a // indirect diff --git a/go.sum b/go.sum index c8a1d69..54dfb15 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,6 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= -github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8= github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= @@ -180,8 +178,6 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= -github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= diff --git a/main.go b/main.go index 6f92e2e..d7c3881 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,6 @@ import ( "os/signal" "syscall" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" "github.com/gobuffalo/packr/v2" "github.com/gorilla/mux" ) @@ -16,7 +15,7 @@ import ( type kis3 struct { router *mux.Router staticBox *packr.Box - tgBot *tgbotapi.BotAPI + telegram *telegram } var ( @@ -32,12 +31,11 @@ func main() { if e != nil { log.Fatal("Database setup failed:", e) } + initTelegram() initRouter() - initTelegramBot() // Start go startListeningToWeb() go startReports() - go startStatsTelegram() // Graceful stop var gracefulStop = make(chan os.Signal, 1) signal.Notify(gracefulStop, os.Interrupt, syscall.SIGTERM) @@ -50,20 +48,9 @@ func initRouter() { app.router = mux.NewRouter() initStatsRouter() initTrackingRouter() -} - -func initTelegramBot() { - if appConfig.TGBotToken == "" { - fmt.Println("Telegram not configured.") - return + if app.telegram != nil { + initTelegramRouter() } - bot, e := tgbotapi.NewBotAPI(appConfig.TGBotToken) - if e != nil { - fmt.Println("Failed to setup Telegram:", e) - return - } - fmt.Println("Authorized Telegram bot on account", bot.Self.UserName) - app.tgBot = bot } func startListeningToWeb() { diff --git a/reports.go b/reports.go index 8612521..296aa79 100644 --- a/reports.go +++ b/reports.go @@ -2,7 +2,6 @@ package main import ( "fmt" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" "github.com/jordan-wright/email" "github.com/whiteshtef/clockwork" "io/ioutil" @@ -17,7 +16,7 @@ type report struct { Query string `json:"query"` Type string `json:"type"` To string `json:"to"` - TGUserId int64 `json:"tgUserId"` + TGUserId int `json:"tgUserId"` } func startReports() { @@ -82,12 +81,11 @@ func sendMail(r *report, content []byte) { } func sendTelegram(r *report, content []byte) { - if r.TGUserId == 0 || app.tgBot == nil { + if r.TGUserId == 0 || app.telegram == nil { fmt.Println("No valid report configuration") return } - msg := tgbotapi.NewMessage(r.TGUserId, r.Name+"\n\n"+string(content)) - _, e := app.tgBot.Send(msg) + e := app.telegram.sendMessage(r.TGUserId, r.Name+"\n\n"+string(content)) if e != nil { fmt.Println("Sending report failed:", e) return diff --git a/stats.go b/stats.go index 8571c4a..71fd830 100644 --- a/stats.go +++ b/stats.go @@ -9,9 +9,7 @@ import ( "strconv" "strings" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" "kis3.dev/kis3/helpers" - "github.com/whiteshtef/clockwork" ) func initStatsRouter() { @@ -134,54 +132,31 @@ func sendChartResponse(result []*RequestResultRow, w http.ResponseWriter) { _ = t.Execute(w, data) } -func startStatsTelegram() { - if app.tgBot == nil { - return - } - u := tgbotapi.NewUpdate(0) - scheduler := clockwork.NewScheduler() - scheduler.Schedule().Every(5).Seconds().Do(func() { - checkForTelegramUpdates(&u) - }) - scheduler.Run() -} - -func checkForTelegramUpdates(u *tgbotapi.UpdateConfig) { - updates, e := app.tgBot.GetUpdates(*u) - if e != nil { - return - } - for _, update := range updates { - if update.Message != nil && update.Message.Command() == "stats" { - response := "" - fakeUrl, e := url.Parse("/stats?" + update.Message.CommandArguments()) - if e != nil { - response = "Request failed" +func respondToTelegramUpdate(u *telegramUpdate) { + if app.telegram != nil && strings.HasPrefix(u.Message.Text, "/stats") { + response := "" + fakeUrl, e := url.Parse("/stats?" + strings.TrimSpace(strings.TrimPrefix(u.Message.Text, "/stats"))) + if e != nil { + response = "Request failed" + } else { + if appConfig.statsAuth() && (fakeUrl.Query().Get("username") != appConfig.StatsUsername || fakeUrl.Query().Get("password") != appConfig.StatsPassword) { + response = "Not authorized. Add username=yourusername&password=yourpassword to the query." } else { - if appConfig.statsAuth() && (fakeUrl.Query().Get("username") != appConfig.StatsUsername || fakeUrl.Query().Get("password") != appConfig.StatsPassword) { - response = "Not authorized. Add username=yourusername&password=yourpassword to the query." + result, e := doRequest(fakeUrl.Query()) + if e != nil { + response = "Request failed" } else { - result, e := doRequest(fakeUrl.Query()) - if e != nil { - response = "Request failed" - } else { - rowStrings := make([]string, len(result)) - for i, row := range result { - rowStrings[i] = (*row).First + ": " + strconv.Itoa((*row).Second) - } - response = strings.Join(rowStrings, "\n") + rowStrings := make([]string, len(result)) + for i, row := range result { + rowStrings[i] = (*row).First + ": " + strconv.Itoa((*row).Second) } + response = strings.Join(rowStrings, "\n") } } - msg := tgbotapi.NewMessage(update.Message.Chat.ID, response) - msg.ReplyToMessageID = update.Message.MessageID - _, e = app.tgBot.Send(msg) - if e != nil { - fmt.Println("Failed to send message:", e) - } } - if update.UpdateID >= u.Offset { - u.Offset = update.UpdateID + 1 + e = app.telegram.replyToMessage(u.Message.Chat.Id, response, u.Message.Id) + if e != nil { + fmt.Println("Failed to send message:", e) } } } diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..5bbf148 --- /dev/null +++ b/telegram.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "strconv" +) + +type telegram struct { + botToken string +} + +type telegramUpdate struct { + Message struct { + Chat struct { + Id int `json:"id"` + } `json:"chat"` + Id int `json:"message_id"` + Text string `json:"text"` + } `json:"message"` +} + +func initTelegram() { + if appConfig.TGBotToken == "" { + fmt.Println("Telegram not configured.") + return + } + tg := &telegram{ + botToken: appConfig.TGBotToken, + } + username, err := tg.getBotUsername() + if err != nil { + fmt.Println("Failed to setup Telegram:", err) + return + } + err = tg.setTelegramHook() + if err != nil { + fmt.Println("Failed to setup Telegram webhook:", err) + return + } + fmt.Println("Authorized Telegram bot on account", username) + app.telegram = tg +} + +func initTelegramRouter() { + app.router.HandleFunc(path.Join("/telegramHook", appConfig.TGHookSecret), TelegramHookHandler) +} + +func TelegramHookHandler(w http.ResponseWriter, r *http.Request) { + tgUpdate := &telegramUpdate{} + err := json.NewDecoder(r.Body).Decode(tgUpdate) + if err != nil { + http.Error(w, "Failed to decode body", http.StatusBadRequest) + return + } + go respondToTelegramUpdate(tgUpdate) + return +} + +var telegramBaseUrl = "https://api.telegram.org/bot" + +func (t *telegram) getBotUsername() (string, error) { + tgUrl, err := url.Parse(telegramBaseUrl + t.botToken + "/getMe") + if err != nil { + return "", errors.New("failed to create Telegram request") + } + req, _ := http.NewRequest(http.MethodPost, tgUrl.String(), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != 200 { + return "", errors.New("failed to get Telegram bot info") + } + tgBotInfo := &struct { + Ok bool `json:"ok"` + Result struct { + Id int `json:"id"` + Username string `json:"username"` + } `json:"result"` + }{} + err = json.NewDecoder(resp.Body).Decode(tgBotInfo) + _ = resp.Body.Close() + if err != nil || !tgBotInfo.Ok { + return "", errors.New("failed to parse Telegram bot info") + } + // If getMe returns no username, but only an ID for whatever reason + if len(tgBotInfo.Result.Username) == 0 { + tgBotInfo.Result.Username = strconv.Itoa(tgBotInfo.Result.Id) + } + return tgBotInfo.Result.Username, nil +} + +func (t *telegram) setTelegramHook() error { + if len(appConfig.BaseUrl) < 1 { + return errors.New("base URL not configured") + } + hookUrl, e := url.Parse(appConfig.BaseUrl) + if e != nil { + return errors.New("failed to parse base URL") + } + hookUrl.Path = path.Join(hookUrl.Path, path.Join("telegramHook", appConfig.TGHookSecret)) + params := url.Values{} + params.Add("url", hookUrl.String()) + params.Add("allowed_updates", "[\"message\"]") + tgUrl, err := url.Parse(telegramBaseUrl + t.botToken + "/setWebhook") + if err != nil { + return errors.New("failed to create Telegram request") + } + tgUrl.RawQuery = params.Encode() + req, _ := http.NewRequest(http.MethodPost, tgUrl.String(), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != 200 { + return errors.New("failed to set Telegram webhook") + } + fmt.Println("Telegram webhook URL:", hookUrl.String()) + return nil +} + +func (t *telegram) sendMessage(chat int, message string) error { + return t.replyToMessage(chat, message, 0) +} + +func (t *telegram) replyToMessage(chat int, message string, replyTo int) error { + params := url.Values{} + params.Add("chat_id", strconv.Itoa(chat)) + if replyTo != 0 { + params.Add("reply_to_message_id", strconv.Itoa(replyTo)) + } + params.Add("text", message) + tgUrl, err := url.Parse(telegramBaseUrl + t.botToken + "/sendMessage") + if err != nil { + return errors.New("failed to create Telegram request") + } + tgUrl.RawQuery = params.Encode() + req, _ := http.NewRequest(http.MethodPost, tgUrl.String(), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != 200 { + return errors.New("failed to send Telegram message") + } + return nil +}