// This is the main package package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "os" "path" "runtime/debug" "strings" "sync" "time" "go.balki.me/anyhttp" "go.balki.me/anyhttp/idle" "go.balki.me/tglistbot/glist" ) // Version will be set from build commandline var Version string var apiToken string var usage = `Telegram List bot Environment variables: TGLB_API_TOKEN (required) : See https://core.telegram.org/bots#how-do-i-create-a-bot TGLB_ADDR (default 28923) : See https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax TGLB_DATA_PATH (default .) : Directory path where list data is stored TGLB_TIMEOUT (default 30m) : Timeout to auto shutdown if using systemd-fd, See https://pkg.go.dev/time#ParseDuration ` func main() { log.SetFlags(log.Flags() | log.Lshortfile) apiToken = os.Getenv("TGLB_API_TOKEN") if apiToken == "" { log.Print(usage) log.Panicln("TGLB_API_TOKEN is empty") } addr := func() string { addr := os.Getenv("TGLB_ADDR") if addr == "" { return "28923" } return addr }() dataPath := func() string { dataPath := os.Getenv("TGLB_DATA_PATH") if dataPath == "" { return "." } if err := os.MkdirAll(dataPath, 0755); err != nil { log.Panicf("Failed to create datapath, path: %q, err: %s\n", dataPath, err) } return dataPath }() timeout := func() time.Duration { timeoutStr := os.Getenv("TGLB_TIMEOUT") if timeoutStr == "" { return 30 * time.Minute } timeout, err := time.ParseDuration(timeoutStr) if err != nil { log.Panicf("Invalid timeout: %q\n", timeoutStr) } return timeout }() glist.DataPath = dataPath version := func() string { if Version != "" { return Version } if bi, ok := debug.ReadBuildInfo(); ok { for _, s := range bi.Settings { if s.Key == "vcs.revision" { return s.Value[:8] } } } return "unknown" }() log.Printf("List bot (%s) starting with datapath: %q, %s\n", version, dataPath, addr) var chats sync.Map if err := loadData(dataPath, &chats); err != nil { log.Panicf("failed to load data, err: %s\n", err) } botPath := fmt.Sprintf("/bot%s", apiToken) http.HandleFunc(botPath, func(w http.ResponseWriter, r *http.Request) { defer func() { if _, err := w.Write([]byte("ok")); err != nil { log.Println(err) } }() body, err := io.ReadAll(r.Body) if err != nil { log.Println(err) return } log.Println(string(body)) update := struct { Message *struct { ID int `json:"message_id"` Chat struct { ID int `json:"id"` } Text string } CallbackQuery *struct { ID string `json:"id"` Message struct { ID int `json:"message_id"` Chat struct { ID int `json:"id"` } } Data string } `json:"callback_query"` }{} if err := json.Unmarshal(body, &update); err != nil { log.Println(err) return } // Ignore if Text is empty or is a command if update.Message != nil && update.Message.Text != "" && update.Message.Text[0] != '/' { chatID := update.Message.Chat.ID if len(update.Message.Text) > 60 { replyURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%d&reply_to_message_id=%d&text=ItemTooLong-Max60", apiToken, chatID, update.Message.ID) resp, err := http.Get(replyURL) if err != nil { log.Println(err) return } logBody(resp.Body) return } g, _ := chats.LoadOrStore(chatID, glist.NewGList(chatID)) gl := g.(*glist.GList) go handleTextAdded(gl, update.Message.Text) } else if update.CallbackQuery != nil { defer func() { go answerCallbackQuery(update.CallbackQuery.ID) }() chatID := update.CallbackQuery.Message.Chat.ID g, ok := chats.Load(chatID) if !ok { log.Printf("Chat not found: %v\n", chatID) return } gl := g.(*glist.GList) go handleButtonClick(gl, update.CallbackQuery.Message.ID, update.CallbackQuery.Data) } }) addrType, server, done, err := anyhttp.Serve(addr, idle.WrapHandler(nil)) if err != nil { log.Panicln(err) } if addrType == anyhttp.SystemdFD { log.Println("server started") if err := idle.Wait(timeout); err != nil { log.Panicln(err) } log.Printf("server idle for %v, shutting down\n", timeout) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) // Don't want any stuck connections defer cancel() if err := server.Shutdown(ctx); err != nil { log.Panicln(err) } } else { <-done } } func handleTextAdded(gl *glist.GList, text string) { gl.Mutex.Lock() defer gl.Mutex.Unlock() gl.Add(text) count := gl.AllMsgCounter + 1 gl.AllMsgCounter = count time.AfterFunc(3*time.Second, func() { gl.Mutex.Lock() defer gl.Mutex.Unlock() if count == gl.AllMsgCounter { resp := sendList(gl, glist.NEWLIST) if resp == nil { return } response := struct { Ok bool `json:"ok"` Result struct { MessageID int `json:"message_id"` } `json:"result"` }{} if err := json.Unmarshal(resp, &response); err != nil { log.Println(err) return } if !response.Ok { log.Println("not ok") return } if gl.MessageID != nil { deleteMessage(gl.ChatID, *gl.MessageID) } gl.MessageID = &response.Result.MessageID } }) } func handleButtonClick(gl *glist.GList, messageID int, text string) { gl.Mutex.Lock() defer gl.Mutex.Unlock() gl.MessageID = &messageID if text == "clear" { gl.ClearChecked() } else { gl.Toggle(text) } if len(gl.Items) == 0 { deleteMessage(gl.ChatID, messageID) gl.MessageID = nil } else { sendList(gl, glist.EDITLIST) } } func sendList(gl *glist.GList, method glist.SendMethod) []byte { url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", apiToken, method) sendMsgReq, err := gl.GenSendListReq(method) if err != nil { log.Println(err) return nil } log.Println(string(sendMsgReq)) resp, err := http.Post(url, "application/json", bytes.NewReader(sendMsgReq)) if err != nil { log.Println(err) return nil } return logBody(resp.Body) } func answerCallbackQuery(callbackQueryID string) { answerURL := fmt.Sprintf("https://api.telegram.org/bot%s/answerCallbackQuery?callback_query_id=%stext=ok", apiToken, callbackQueryID) resp, err := http.Get(answerURL) if err != nil { log.Println(err) return } logBody(resp.Body) } func deleteMessage(chatID int, messageID int) { deleteURL := fmt.Sprintf("https://api.telegram.org/bot%s/deleteMessage?chat_id=%d&message_id=%d", apiToken, chatID, messageID) resp, err := http.Get(deleteURL) if err != nil { log.Println(err) return } body := logBody(resp.Body) ok := struct { Ok bool `json:"ok"` }{} if err := json.Unmarshal(body, &ok); err != nil { log.Println(err) return } //Old messages can't be deleted, so edit text instead if !ok.Ok { updateURL := fmt.Sprintf("https://api.telegram.org/bot%s/editMessageText?chat_id=%d&message_id=%d&text=%s", apiToken, chatID, messageID, "") resp, err := http.Get(updateURL) if err != nil { log.Println(err) return } logBody(resp.Body) } } func logBody(respBody io.ReadCloser) []byte { defer func() { err := respBody.Close() if err != nil { log.Println(err) } }() body, err := io.ReadAll(respBody) if err != nil { log.Println(err) return nil } log.Println(string(body)) return body } func loadData(dataPath string, chats *sync.Map) error { items, err := os.ReadDir(dataPath) if err != nil { return err } for _, de := range items { if de.IsDir() { continue } name := de.Name() if !strings.HasPrefix(name, "chkchat") { continue } var gl glist.GList data, err := os.ReadFile(path.Join(dataPath, name)) if err != nil { return fmt.Errorf("failed read file, name: %q, err:%w", name, err) } err = json.Unmarshal(data, &gl) if err != nil { return fmt.Errorf("failed to parse data, data:%q, err:%w", data, err) } chats.Store(gl.ChatID, &gl) } return nil }