13 Commits
v0.1 ... v1.0

5 changed files with 172 additions and 57 deletions

28
README.md Normal file
View File

@ -0,0 +1,28 @@
## Grocery list bot
Bot shows the list as buttons that can be clicked to mark the item as done
## Quick start
### Single user
Send your list of items to [@grocery_guy_bot](https://t.me/grocery_guy_bot)
### Multiple users (Shared list)
1. Create a group with all the members
2. Add [@grocery_guy_bot](https://t.me/grocery_guy_bot) to the group
3. Make the bot admin to read all the messages
4. Send your list of items.
Note: Everything sent to the group is considered a list item, so the group cannot be used to for any other text messages
## TODO
1. Write doc, screenshots, video, gif
2. Done button for finish one list and start another
3. Show list contents when done
4. Show date time
5. Multiple lists for same chat
6. Sort button to move checked items to top
7. systemd socket activation
8. Add tests

View File

@ -1,3 +1,4 @@
// Package glist handles the list processing
package glist package glist
import ( import (
@ -34,16 +35,17 @@ func NewGList(chatID int, items ...string) *GList {
return &g return &g
} }
var PersistReqC chan<- *GList var persistReqC chan<- *GList
func startPersistenceGoR() { func startPersistenceGoR() {
reqs := make(chan *GList, 50) reqs := make(chan *GList, 50)
PersistReqC = reqs persistReqC = reqs
go func() { go func() {
lists := map[*GList]struct{}{}
for g := range reqs { for g := range reqs {
lists[g] = struct{}{} lists := map[*GList]struct{}{
time.Sleep(5 * time.Second) g: struct{}{},
}
time.Sleep(5 * time.Second) // Collect all persist requests for 5 seconds
for len(reqs) > 0 { for len(reqs) > 0 {
g := <-reqs g := <-reqs
lists[g] = struct{}{} lists[g] = struct{}{}
@ -51,7 +53,6 @@ func startPersistenceGoR() {
for g := range lists { for g := range lists {
g.persist() g.persist()
} }
lists = make(map[*GList]struct{}, len(lists))
} }
}() }()
} }
@ -83,18 +84,18 @@ outer:
} }
g.Items = append(g.Items, Entry{text, false}) g.Items = append(g.Items, Entry{text, false})
} }
PersistReqC <- g persistReqC <- g
} }
func (g *GList) Toggle(text string) error { func (g *GList) Toggle(text string) {
for i, item := range g.Items { for i, item := range g.Items {
if item.Text == text { if item.Text == text {
g.Items[i].Checked = !g.Items[i].Checked g.Items[i].Checked = !g.Items[i].Checked
PersistReqC <- g persistReqC <- g
return nil return
} }
} }
return fmt.Errorf("not found:%s", text) log.Printf("item not found in toggle, chat: %d, item: %s\n", g.ChatID, text)
} }
func (g *GList) ClearChecked() { func (g *GList) ClearChecked() {
@ -105,7 +106,7 @@ func (g *GList) ClearChecked() {
} }
} }
g.Items = remaining g.Items = remaining
PersistReqC <- g persistReqC <- g
} }
type button struct { type button struct {

View File

@ -2,34 +2,32 @@ package glist
import ( import (
"fmt" "fmt"
"sync"
"testing" "testing"
) )
func TestGList(t *testing.T) { func TestGList(t *testing.T) {
var ti int g := NewGList(4342, "foo")
var m sync.Mutex
g := GList{ti, m, 4342, nil, []Entry{{"foo", true}}}
data, err := g.GenSendListReq() data, err := g.GenSendListReq()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
expected := `{"chat_id":4342,"text":"List:","reply_markup":{"inline_keyboard":[[{"text":"foo","callback_data":"foo"}],[{"text":"clear checked","callback_data":"clear"}]]}}` expected := `{"chat_id":4342,"text":"List:","reply_markup":{"inline_keyboard":[[{"text":"foo","callback_data":"foo"}],[{"text":"clear checked","callback_data":"clear"}]]}}`
if expected != string(data) { if expected != string(data) {
t.Fatalf("expected: %s\n got:%s\n", expected, string(data)) t.Fatalf("expected: %s\n got:%s\n", expected, string(data))
} }
} }
func TestSplit(t *testing.T) { func TestSplit(t *testing.T) {
g := GList{} g := &GList{}
//This resets the channel, so test does not try to persist //This resets the channel, so test does not try to persist
PersistReqC = make(chan *GList, 50) persistReqC = make(chan *GList, 50)
g.Add("foo") g.Add("foo")
g.Add("bar\nfoo\nblah") g.Add("bar\nfoo\nblah")
g.Add("foo") g.Add("foo")
g.Add("lskfj") g.Add("lskfj")
expected := `{0 {0 0} 0 <nil> [{foo false} {bar false} {blah false} {lskfj false}]}` g.Toggle("foo")
expected := `&{0 {0 0} 0 <nil> [{foo true} {bar false} {blah false} {lskfj false}]}`
actual := fmt.Sprintf("%v", g) actual := fmt.Sprintf("%v", g)
if expected != actual { if expected != actual {
t.Fatalf("expected: %s\n got:%s\n", expected, actual) t.Fatalf("expected: %s\n got:%s\n", expected, actual)

4
go.mod
View File

@ -1,3 +1,3 @@
module gitea.balki.me/telegram-msgchkbox module go.balki.me/tglistbot
go 1.19 go 1.20

156
main.go
View File

@ -4,62 +4,94 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"path" "path"
"runtime/debug"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"gitea.balki.me/telegram-msgchkbox/glist" "go.balki.me/tglistbot/glist"
) )
var apiToken string var apiToken string
func main() { func main() {
apiToken = os.Getenv("CHKBOT_API_TOKEN") apiToken = os.Getenv("TGLB_API_TOKEN")
if apiToken == "" { if apiToken == "" {
log.Panicln("CHKBOT_API_TOKEN is empty") log.Panicln("TG_API_TOKEN is empty")
} }
port := func() int { port, unixSocketPath := func() (int, string) {
portStr := os.Getenv("CHKBOT_PORT") portStr := os.Getenv("TGLB_PORT")
port, err := strconv.Atoi(portStr)
if err == nil { defaultPort := 28923
return port
if strings.HasPrefix(portStr, "unix/") {
return defaultPort, strings.TrimPrefix(portStr, "unix/")
} }
return 28923
if port, err := strconv.Atoi(portStr); err == nil {
return port, ""
}
return defaultPort, ""
}() }()
dataPath := func() string { dataPath := func() string {
dataPath := os.Getenv("CHKBOT_DATA_PATH") dataPath := os.Getenv("TGLB_DATA_PATH")
if dataPath == "" { if dataPath == "" {
dataPath = "." return "."
} }
err := os.MkdirAll(dataPath, 0755)
if err != nil { if err := os.MkdirAll(dataPath, 0755); err != nil {
log.Panicf("Failed to create datapath, path: %s, err: %s", dataPath, err) log.Panicf("Failed to create datapath, path: %q, err: %s\n", dataPath, err)
} }
return dataPath return dataPath
}() }()
glist.DataPath = dataPath glist.DataPath = dataPath
log.Printf("Grocery List bot starting with datapath:%s, port:%d\n", dataPath, port) commit := func() string {
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
if s.Key == "vcs.revision" {
return s.Value[:8]
}
}
}
return "unknown"
}()
listeningOn := func() string {
if unixSocketPath != "" {
return fmt.Sprintf("socket: %q", unixSocketPath)
}
return fmt.Sprintf("port: %v", port)
}()
log.Printf("List bot (%s) starting with datapath: %q, %s\n", commit, dataPath, listeningOn)
var chats sync.Map var chats sync.Map
if err := loadData(dataPath, &chats); err != nil { if err := loadData(dataPath, &chats); err != nil {
log.Panicf("failed to load data, err: %s", err) log.Panicf("failed to load data, err: %s\n", err)
} }
botPath := fmt.Sprintf("/bot%s", apiToken) botPath := fmt.Sprintf("/bot%s", apiToken)
http.HandleFunc(botPath, func(w http.ResponseWriter, r *http.Request) { http.HandleFunc(botPath, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) defer func() {
if _, err := w.Write([]byte("ok")); err != nil {
log.Println(err)
}
}()
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
@ -92,7 +124,8 @@ func main() {
return return
} }
if update.Message != nil && update.Message.Text != "" { // 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 chatID := update.Message.Chat.ID
g, _ := chats.LoadOrStore(chatID, glist.NewGList(chatID)) g, _ := chats.LoadOrStore(chatID, glist.NewGList(chatID))
@ -106,7 +139,7 @@ func main() {
chatID := update.CallbackQuery.Message.Chat.ID chatID := update.CallbackQuery.Message.Chat.ID
g, ok := chats.Load(chatID) g, ok := chats.Load(chatID)
if !ok { if !ok {
log.Println("Chat not found: %s", chatID) log.Printf("Chat not found: %v\n", chatID)
return return
} }
gl := g.(*glist.GList) gl := g.(*glist.GList)
@ -114,7 +147,24 @@ func main() {
} }
}) })
log.Panic(http.ListenAndServe(fmt.Sprintf(":%v", port), nil)) if unixSocketPath != "" {
// Remove old one
if err := os.Remove(unixSocketPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Panicf("Failed to remove unix socket : %q err: %v\n", unixSocketPath, err)
}
l, err := net.Listen("unix", unixSocketPath)
if err != nil {
log.Panicf("Unable to listen to unix socket : %q err: %v\n", unixSocketPath, err)
}
if err = os.Chmod(unixSocketPath, 0666); err != nil {
log.Panicf("Failed to set permission of unix socket %q err: %v\n", unixSocketPath, err)
}
log.Panicln(http.Serve(l, nil))
}
log.Panicln(http.ListenAndServe(fmt.Sprintf(":%v", port), nil))
} }
func handleTextAdded(gl *glist.GList, text string) { func handleTextAdded(gl *glist.GList, text string) {
@ -127,7 +177,29 @@ func handleTextAdded(gl *glist.GList, text string) {
gl.Mutex.Lock() gl.Mutex.Lock()
defer gl.Mutex.Unlock() defer gl.Mutex.Unlock()
if count == gl.AllMsgCounter { if count == gl.AllMsgCounter {
sendList(gl, "sendMessage") resp := sendList(gl, "sendMessage")
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
} }
}) })
} }
@ -135,11 +207,6 @@ func handleTextAdded(gl *glist.GList, text string) {
func handleButtonClick(gl *glist.GList, messageID int, text string) { func handleButtonClick(gl *glist.GList, messageID int, text string) {
gl.Mutex.Lock() gl.Mutex.Lock()
defer gl.Mutex.Unlock() defer gl.Mutex.Unlock()
if gl.MessageID != nil {
if messageID != *gl.MessageID {
go deleteMessage(gl.ChatID, *gl.MessageID)
}
}
gl.MessageID = &messageID gl.MessageID = &messageID
if text == "clear" { if text == "clear" {
gl.ClearChecked() gl.ClearChecked()
@ -150,24 +217,25 @@ func handleButtonClick(gl *glist.GList, messageID int, text string) {
if len(gl.Items) == 0 { if len(gl.Items) == 0 {
deleteMessage(gl.ChatID, messageID) deleteMessage(gl.ChatID, messageID)
gl.MessageID = nil
} else { } else {
sendList(gl, "editMessageText") sendList(gl, "editMessageText")
} }
} }
func sendList(gl *glist.GList, method string) { func sendList(gl *glist.GList, method string) []byte {
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", apiToken, method) url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", apiToken, method)
sendMsgReq, err := gl.GenSendListReq() sendMsgReq, err := gl.GenSendListReq()
if err != nil { if err != nil {
log.Println(err) log.Println(err)
return return nil
} }
resp, err := http.Post(url, "application/json", bytes.NewReader(sendMsgReq)) resp, err := http.Post(url, "application/json", bytes.NewReader(sendMsgReq))
if err != nil { if err != nil {
log.Println(err) log.Println(err)
return return nil
} }
logBody(resp.Body) return logBody(resp.Body)
} }
func answerCallbackQuery(callbackQueryID string) { func answerCallbackQuery(callbackQueryID string) {
@ -187,10 +255,29 @@ func deleteMessage(chatID int, messageID int) {
log.Println(err) log.Println(err)
return 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, "<list updated>")
resp, err := http.Get(updateURL)
if err != nil {
log.Println(err)
return
}
logBody(resp.Body) logBody(resp.Body)
} }
func logBody(respBody io.ReadCloser) { }
func logBody(respBody io.ReadCloser) []byte {
defer func() { defer func() {
err := respBody.Close() err := respBody.Close()
if err != nil { if err != nil {
@ -200,9 +287,10 @@ func logBody(respBody io.ReadCloser) {
body, err := io.ReadAll(respBody) body, err := io.ReadAll(respBody)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
return return nil
} }
log.Println(string(body)) log.Println(string(body))
return body
} }
func loadData(dataPath string, chats *sync.Map) error { func loadData(dataPath string, chats *sync.Map) error {
@ -221,11 +309,11 @@ func loadData(dataPath string, chats *sync.Map) error {
var gl glist.GList var gl glist.GList
data, err := os.ReadFile(path.Join(dataPath, name)) data, err := os.ReadFile(path.Join(dataPath, name))
if err != nil { if err != nil {
return fmt.Errorf("failed read file, name: %s, err:%w", name, err) return fmt.Errorf("failed read file, name: %q, err:%w", name, err)
} }
err = json.Unmarshal(data, &gl) err = json.Unmarshal(data, &gl)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse data, data:%s, err:%w", data, err) return fmt.Errorf("failed to parse data, data:%q, err:%w", data, err)
} }
chats.Store(gl.ChatID, &gl) chats.Store(gl.ChatID, &gl)
} }