1 Commits
v1.0.2 ... v0.1

Author SHA1 Message Date
58ddf202bb tmp 2022-12-27 22:15:47 -05:00
7 changed files with 68 additions and 216 deletions

1
.gitignore vendored
View File

@ -1 +0,0 @@
tglistbot*

View File

@ -1,12 +0,0 @@
VERSION = $(shell git describe --tags --abbrev=0)
build_release:
CGO_ENABLED=0 go build -buildmode=pie -ldflags "-X main.Version=$(VERSION)" -o tglistbot-$(VERSION)
release_check:
go list -m go.balki.me/tglistbot@$(VERSION)
clean:
rm -i tglistbot* || true

View File

@ -1,28 +0,0 @@
## 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,4 +1,3 @@
// Package glist handles the list processing
package glist
import (
@ -35,17 +34,16 @@ func NewGList(chatID int, items ...string) *GList {
return &g
}
var persistReqC chan<- *GList
var PersistReqC chan<- *GList
func startPersistenceGoR() {
reqs := make(chan *GList, 50)
persistReqC = reqs
PersistReqC = reqs
go func() {
lists := map[*GList]struct{}{}
for g := range reqs {
lists := map[*GList]struct{}{
g: struct{}{},
}
time.Sleep(5 * time.Second) // Collect all persist requests for 5 seconds
lists[g] = struct{}{}
time.Sleep(5 * time.Second)
for len(reqs) > 0 {
g := <-reqs
lists[g] = struct{}{}
@ -53,6 +51,7 @@ func startPersistenceGoR() {
for g := range lists {
g.persist()
}
lists = make(map[*GList]struct{}, len(lists))
}
}()
}
@ -84,18 +83,18 @@ outer:
}
g.Items = append(g.Items, Entry{text, false})
}
persistReqC <- g
PersistReqC <- g
}
func (g *GList) Toggle(text string) {
func (g *GList) Toggle(text string) error {
for i, item := range g.Items {
if item.Text == text {
g.Items[i].Checked = !g.Items[i].Checked
persistReqC <- g
return
PersistReqC <- g
return nil
}
}
log.Printf("item not found in toggle, chat: %d, item: %s\n", g.ChatID, text)
return fmt.Errorf("not found:%s", text)
}
func (g *GList) ClearChecked() {
@ -106,27 +105,19 @@ func (g *GList) ClearChecked() {
}
}
g.Items = remaining
persistReqC <- g
PersistReqC <- g
}
type SendMethod string
var (
NEWLIST SendMethod = "sendMessage"
EDITLIST SendMethod = "editMessageText"
)
type button struct {
Text string `json:"text"`
CallbackData string `json:"callback_data"`
}
type newListReq struct {
ChatID int `json:"chat_id"`
MessageID *int `json:"message_id,omitempty"`
Text string `json:"text"`
DisableNotification *bool `json:"disable_notification,omitempty"`
ReplyMarkup struct {
ChatID int `json:"chat_id"`
MessageID *int `json:"message_id,omitempty"`
Text string `json:"text"`
ReplyMarkup struct {
InlineKeyboard [][]button `json:"inline_keyboard"`
} `json:"reply_markup"`
}
@ -155,12 +146,8 @@ func makeButtons(items []Entry) [][]button {
return buttons
}
func (g *GList) GenSendListReq(method SendMethod) ([]byte, error) {
func (g *GList) GenSendListReq() ([]byte, error) {
req := newListReq{ChatID: g.ChatID, MessageID: g.MessageID, Text: "List:"}
if method == NEWLIST {
disableNotification := true
req.DisableNotification = &disableNotification
}
itemButtons := makeButtons(g.Items)
controlButtons := []button{{"clear checked", "clear"}}
req.ReplyMarkup.InlineKeyboard = append(itemButtons, controlButtons)

View File

@ -2,32 +2,34 @@ package glist
import (
"fmt"
"sync"
"testing"
)
func TestGList(t *testing.T) {
g := NewGList(4342, "foo")
data, err := g.GenSendListReq(NEWLIST)
var ti int
var m sync.Mutex
g := GList{ti, m, 4342, nil, []Entry{{"foo", true}}}
data, err := g.GenSendListReq()
if err != nil {
t.Fatal(err)
}
expected := `{"chat_id":4342,"text":"List:","disable_notification":true,"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) {
t.Fatalf("expected: %s\n got:%s\n", expected, string(data))
}
}
func TestSplit(t *testing.T) {
g := &GList{}
g := GList{}
//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("bar\nfoo\nblah")
g.Add("foo")
g.Add("lskfj")
g.Toggle("foo")
expected := `&{0 {0 0} 0 <nil> [{foo true} {bar false} {blah false} {lskfj false}]}`
expected := `{0 {0 0} 0 <nil> [{foo false} {bar false} {blah false} {lskfj false}]}`
actual := fmt.Sprintf("%v", g)
if expected != actual {
t.Fatalf("expected: %s\n got:%s\n", expected, actual)

4
go.mod
View File

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

176
main.go
View File

@ -4,12 +4,9 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path"
@ -19,92 +16,55 @@ import (
"sync"
"time"
"go.balki.me/tglistbot/glist"
"gitea.balki.me/telegram-msgchkbox/glist"
)
// Version will be set from build commandline
var Version string
var apiToken string
var usage string = `Telegram List bot
Environment variables:
TGLB_API_TOKEN (required): See https://core.telegram.org/bots#how-do-i-create-a-bot
TGLB_PORT (default 28923): Set numerical port or unix//run/path.sock for unix socket
TGLB_DATA_PATH (default .): Directory path where list data is stored
`
func main() {
apiToken = os.Getenv("TGLB_API_TOKEN")
apiToken = os.Getenv("CHKBOT_API_TOKEN")
if apiToken == "" {
log.Print(usage)
log.Panicln("TGLB_API_TOKEN is empty")
log.Panicln("CHKBOT_API_TOKEN is empty")
}
port, unixSocketPath := func() (int, string) {
portStr := os.Getenv("TGLB_PORT")
defaultPort := 28923
if strings.HasPrefix(portStr, "unix/") {
return defaultPort, strings.TrimPrefix(portStr, "unix/")
port := func() int {
portStr := os.Getenv("CHKBOT_PORT")
port, err := strconv.Atoi(portStr)
if err == nil {
return port
}
if port, err := strconv.Atoi(portStr); err == nil {
return port, ""
}
return defaultPort, ""
return 28923
}()
dataPath := func() string {
dataPath := os.Getenv("TGLB_DATA_PATH")
dataPath := os.Getenv("CHKBOT_DATA_PATH")
if dataPath == "" {
return "."
dataPath = "."
}
if err := os.MkdirAll(dataPath, 0755); err != nil {
log.Panicf("Failed to create datapath, path: %q, err: %s\n", dataPath, err)
err := os.MkdirAll(dataPath, 0755)
if err != nil {
log.Panicf("Failed to create datapath, path: %s, err: %s", dataPath, err)
}
return dataPath
}()
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"
}()
if bi, ok := debug.ReadBuildInfo(); ok {
log.Println(bi.String())
}
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", version, dataPath, listeningOn)
log.Printf("Grocery List bot starting with datapath:%s, port:%d\n", dataPath, port)
var chats sync.Map
if err := loadData(dataPath, &chats); err != nil {
log.Panicf("failed to load data, err: %s\n", err)
log.Panicf("failed to load data, err: %s", 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)
}
}()
w.Write([]byte("ok"))
body, err := io.ReadAll(r.Body)
if err != nil {
log.Println(err)
@ -137,8 +97,7 @@ func main() {
return
}
// Ignore if Text is empty or is a command
if update.Message != nil && update.Message.Text != "" && update.Message.Text[0] != '/' {
if update.Message != nil && update.Message.Text != "" {
chatID := update.Message.Chat.ID
g, _ := chats.LoadOrStore(chatID, glist.NewGList(chatID))
@ -152,7 +111,7 @@ func main() {
chatID := update.CallbackQuery.Message.Chat.ID
g, ok := chats.Load(chatID)
if !ok {
log.Printf("Chat not found: %v\n", chatID)
log.Println("Chat not found: %s", chatID)
return
}
gl := g.(*glist.GList)
@ -160,24 +119,7 @@ func main() {
}
})
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))
log.Panic(http.ListenAndServe(fmt.Sprintf(":%v", port), nil))
}
func handleTextAdded(gl *glist.GList, text string) {
@ -190,29 +132,7 @@ func handleTextAdded(gl *glist.GList, text string) {
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
sendList(gl, "sendMessage")
}
})
}
@ -220,6 +140,11 @@ func handleTextAdded(gl *glist.GList, text string) {
func handleButtonClick(gl *glist.GList, messageID int, text string) {
gl.Mutex.Lock()
defer gl.Mutex.Unlock()
if gl.MessageID != nil {
if messageID != *gl.MessageID {
go deleteMessage(gl.ChatID, *gl.MessageID)
}
}
gl.MessageID = &messageID
if text == "clear" {
gl.ClearChecked()
@ -230,25 +155,24 @@ func handleButtonClick(gl *glist.GList, messageID int, text string) {
if len(gl.Items) == 0 {
deleteMessage(gl.ChatID, messageID)
gl.MessageID = nil
} else {
sendList(gl, glist.EDITLIST)
sendList(gl, "editMessageText")
}
}
func sendList(gl *glist.GList, method glist.SendMethod) []byte {
func sendList(gl *glist.GList, method string) {
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", apiToken, method)
sendMsgReq, err := gl.GenSendListReq(method)
sendMsgReq, err := gl.GenSendListReq()
if err != nil {
log.Println(err)
return nil
return
}
resp, err := http.Post(url, "application/json", bytes.NewReader(sendMsgReq))
if err != nil {
log.Println(err)
return nil
return
}
return logBody(resp.Body)
logBody(resp.Body)
}
func answerCallbackQuery(callbackQueryID string) {
@ -268,29 +192,10 @@ func deleteMessage(chatID int, messageID int) {
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, "<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) []byte {
func logBody(respBody io.ReadCloser) {
defer func() {
err := respBody.Close()
if err != nil {
@ -300,10 +205,9 @@ func logBody(respBody io.ReadCloser) []byte {
body, err := io.ReadAll(respBody)
if err != nil {
log.Println(err)
return nil
return
}
log.Println(string(body))
return body
}
func loadData(dataPath string, chats *sync.Map) error {
@ -322,11 +226,11 @@ func loadData(dataPath string, chats *sync.Map) error {
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)
return fmt.Errorf("failed read file, name: %s, 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)
return fmt.Errorf("failed to parse data, data:%s, err:%w", data, err)
}
chats.Store(gl.ChatID, &gl)
}