18 Commits
v0.1 ... v1.0.3

Author SHA1 Message Date
dd62f237b0 Replace unix socket logic with go.balki.me/anyhttp
Now also support systemd socket activation
2023-04-20 17:59:40 -04:00
107d90deb8 fix typo in Makefile 2023-04-05 11:33:02 -04:00
6f111a42e9 Add usage 2023-04-05 11:28:16 -04:00
2aa043b5fc Add main.Version 2023-04-05 11:17:07 -04:00
347823b0bd Disable notificaiton for new list 2023-04-05 07:32:11 -04:00
0b1d455bc5 cleanup old unix socket and set write permissions 2023-03-29 18:37:30 -04:00
a479dab96c rename project name 2023-03-29 17:22:11 -04:00
fb8fa61546 Support listening on unix socket 2023-03-29 15:19:28 -04:00
4e54219efb print newline when panicking 2023-03-21 14:24:53 -04:00
bb9a12ef13 Edit message when delete fails 2023-03-21 13:38:20 -04:00
201f9df3d3 Cleanup for readability 2023-03-08 10:20:37 -05:00
e726a4fe01 Save MessageID from send response 2022-12-28 13:59:06 -05:00
a30aae9bf4 Fix missing error checks and test 2022-12-28 01:36:04 -05:00
f80aae9e03 Ignore commands 2022-12-28 00:15:03 -05:00
b37f1b4e8f Add quick start doc 2022-12-28 00:14:47 -05:00
894e940d33 add README 2022-12-27 23:30:02 -05:00
37cad43fbf delete old list correctly 2022-12-27 22:47:26 -05:00
ab75014a19 Show git commit version on startup 2022-12-27 22:24:27 -05:00
8 changed files with 187 additions and 69 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tglistbot*

12
Makefile Normal file
View File

@ -0,0 +1,12 @@
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

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
import (
@ -34,16 +35,17 @@ 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[g] = struct{}{}
time.Sleep(5 * time.Second)
lists := map[*GList]struct{}{
g: struct{}{},
}
time.Sleep(5 * time.Second) // Collect all persist requests for 5 seconds
for len(reqs) > 0 {
g := <-reqs
lists[g] = struct{}{}
@ -51,7 +53,6 @@ func startPersistenceGoR() {
for g := range lists {
g.persist()
}
lists = make(map[*GList]struct{}, len(lists))
}
}()
}
@ -83,18 +84,18 @@ outer:
}
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 {
if item.Text == text {
g.Items[i].Checked = !g.Items[i].Checked
PersistReqC <- g
return nil
persistReqC <- g
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() {
@ -105,9 +106,16 @@ 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"`
@ -117,6 +125,7 @@ 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 {
InlineKeyboard [][]button `json:"inline_keyboard"`
} `json:"reply_markup"`
@ -146,8 +155,12 @@ func makeButtons(items []Entry) [][]button {
return buttons
}
func (g *GList) GenSendListReq() ([]byte, error) {
func (g *GList) GenSendListReq(method SendMethod) ([]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,34 +2,32 @@ package glist
import (
"fmt"
"sync"
"testing"
)
func TestGList(t *testing.T) {
var ti int
var m sync.Mutex
g := GList{ti, m, 4342, nil, []Entry{{"foo", true}}}
data, err := g.GenSendListReq()
g := NewGList(4342, "foo")
data, err := g.GenSendListReq(NEWLIST)
if err != nil {
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:","disable_notification":true,"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")
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)
if expected != actual {
t.Fatalf("expected: %s\n got:%s\n", expected, actual)

6
go.mod
View File

@ -1,3 +1,5 @@
module gitea.balki.me/telegram-msgchkbox
module go.balki.me/tglistbot
go 1.19
go 1.20
require go.balki.me/anyhttp v0.1.0

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
go.balki.me/anyhttp v0.1.0 h1:ULzLWS1pRWMEduHHJxXCbvxoTmxNaWSNANV9gQ0Pigw=
go.balki.me/anyhttp v0.1.0/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s=

140
main.go
View File

@ -11,60 +11,83 @@ import (
"os"
"path"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"gitea.balki.me/telegram-msgchkbox/glist"
"go.balki.me/anyhttp"
"go.balki.me/tglistbot/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("CHKBOT_API_TOKEN")
apiToken = os.Getenv("TGLB_API_TOKEN")
if apiToken == "" {
log.Panicln("CHKBOT_API_TOKEN is empty")
log.Print(usage)
log.Panicln("TGLB_API_TOKEN is empty")
}
port := func() int {
portStr := os.Getenv("CHKBOT_PORT")
port, err := strconv.Atoi(portStr)
if err == nil {
return port
addr := func() string {
addr := os.Getenv("TGLB_PORT")
if addr == "" {
return "28923"
}
return 28923
return addr
}()
dataPath := func() string {
dataPath := os.Getenv("CHKBOT_DATA_PATH")
dataPath := os.Getenv("TGLB_DATA_PATH")
if dataPath == "" {
dataPath = "."
return "."
}
err := os.MkdirAll(dataPath, 0755)
if err != nil {
log.Panicf("Failed to create datapath, path: %s, err: %s", dataPath, err)
if err := os.MkdirAll(dataPath, 0755); err != nil {
log.Panicf("Failed to create datapath, path: %q, err: %s\n", dataPath, err)
}
return dataPath
}()
glist.DataPath = dataPath
if bi, ok := debug.ReadBuildInfo(); ok {
log.Println(bi.String())
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("Grocery List bot starting with datapath:%s, port:%d\n", dataPath, port)
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", err)
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) {
w.Write([]byte("ok"))
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)
@ -97,7 +120,8 @@ func main() {
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
g, _ := chats.LoadOrStore(chatID, glist.NewGList(chatID))
@ -111,7 +135,7 @@ func main() {
chatID := update.CallbackQuery.Message.Chat.ID
g, ok := chats.Load(chatID)
if !ok {
log.Println("Chat not found: %s", chatID)
log.Printf("Chat not found: %v\n", chatID)
return
}
gl := g.(*glist.GList)
@ -119,7 +143,7 @@ func main() {
}
})
log.Panic(http.ListenAndServe(fmt.Sprintf(":%v", port), nil))
log.Panicln(anyhttp.ListenAndServe(addr, nil))
}
func handleTextAdded(gl *glist.GList, text string) {
@ -132,7 +156,29 @@ func handleTextAdded(gl *glist.GList, text string) {
gl.Mutex.Lock()
defer gl.Mutex.Unlock()
if count == gl.AllMsgCounter {
sendList(gl, "sendMessage")
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
}
})
}
@ -140,11 +186,6 @@ 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()
@ -155,24 +196,25 @@ func handleButtonClick(gl *glist.GList, messageID int, text string) {
if len(gl.Items) == 0 {
deleteMessage(gl.ChatID, messageID)
gl.MessageID = nil
} else {
sendList(gl, "editMessageText")
sendList(gl, glist.EDITLIST)
}
}
func sendList(gl *glist.GList, method string) {
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()
sendMsgReq, err := gl.GenSendListReq(method)
if err != nil {
log.Println(err)
return
return nil
}
resp, err := http.Post(url, "application/json", bytes.NewReader(sendMsgReq))
if err != nil {
log.Println(err)
return
return nil
}
logBody(resp.Body)
return logBody(resp.Body)
}
func answerCallbackQuery(callbackQueryID string) {
@ -192,10 +234,29 @@ 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)
}
}
func logBody(respBody io.ReadCloser) {
func logBody(respBody io.ReadCloser) []byte {
defer func() {
err := respBody.Close()
if err != nil {
@ -205,9 +266,10 @@ func logBody(respBody io.ReadCloser) {
body, err := io.ReadAll(respBody)
if err != nil {
log.Println(err)
return
return nil
}
log.Println(string(body))
return body
}
func loadData(dataPath string, chats *sync.Map) error {
@ -226,11 +288,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: %s, err:%w", name, err)
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:%s, err:%w", data, err)
return fmt.Errorf("failed to parse data, data:%q, err:%w", data, err)
}
chats.Store(gl.ChatID, &gl)
}