diff --git a/go.mod b/go.mod index 3d664f0..a07efee 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,13 @@ module git.fromouter.space/crunchy-rocks/clessy-ng go 1.18 -require git.fromouter.space/hamcha/tg v0.1.0 +require ( + git.fromouter.space/hamcha/tg v0.1.0 + git.sr.ht/~hamcha/containers v0.0.3 + github.com/json-iterator/go v1.1.12 +) require ( - github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect ) diff --git a/go.sum b/go.sum index caf6e52..2a890c5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ git.fromouter.space/hamcha/tg v0.1.0 h1:cJwL8pElkBtaDn7Bxa14zvlnBTTK8LdcCtcbBg7hEvk= git.fromouter.space/hamcha/tg v0.1.0/go.mod h1:aIFj7n5FP+Zr/Zv6I6Kq4ZqhRxC12gXFQcC3iOakv9M= +git.sr.ht/~hamcha/containers v0.0.3 h1:obG9X8s5iOIahVe+EGpkBDYmUAO78oTi9Y9gRurt334= +git.sr.ht/~hamcha/containers v0.0.3/go.mod h1:RiZphUpy9t6EnL4Gf6uzByM9QrBoqRCEPo7kz2wzbhE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/main.go b/main.go index 200df74..817466b 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "git.fromouter.space/crunchy-rocks/clessy-ng/modules/macro" "git.fromouter.space/crunchy-rocks/clessy-ng/modules/metafora" "git.fromouter.space/crunchy-rocks/clessy-ng/modules/proverbio" + "git.fromouter.space/crunchy-rocks/clessy-ng/modules/remind" "git.fromouter.space/hamcha/tg" ) @@ -18,6 +19,7 @@ var mods = map[string]modules.Module{ "metafora": &metafora.Module{}, "proverbio": &proverbio.Module{}, "macro": ¯o.Module{}, + "remind": &remind.Module{}, } func checkErr(err error, message string, args ...interface{}) { diff --git a/modules/remind/mod.go b/modules/remind/mod.go new file mode 100644 index 0000000..28c3b60 --- /dev/null +++ b/modules/remind/mod.go @@ -0,0 +1,322 @@ +package remind + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + "unicode" + + "git.fromouter.space/crunchy-rocks/clessy-ng/utils" + "git.fromouter.space/hamcha/tg" + "git.sr.ht/~hamcha/containers" + jsoniter "github.com/json-iterator/go" +) + +type Reminder struct { + TargetID int64 + When int64 + Text string + Reference *ReminderReference +} + +type ReminderReference struct { + Chat int64 + Message int64 +} + +var remindPath string + +const reminderMaxDuration = time.Hour * 24 * 30 * 3 + +var defaultLocation *time.Location = nil + +var pending = containers.NewRWSyncMap[string, Reminder]() + +type Module struct { + client *tg.Telegram + name string +} + +func (m *Module) Initialize(api *tg.Telegram, name string) error { + m.client = api + m.name = name + + var err error + defaultLocation, err = time.LoadLocation("Europe/Rome") + if err != nil { + log.Fatalf("[remind] Something is really wrong: %s\n", err.Error()) + return err + } + + file, err := os.Open(remindPath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + + } + log.Println("[remind] WARN: Reminder file not found, no reminders loaded") + return nil + } + defer file.Close() + + var reminders map[string]Reminder + err = jsoniter.ConfigFastest.NewDecoder(file).Decode(&reminders) + if err != nil { + log.Println("[remind] WARN: Could not load pending reminders (malformed or unreadable file): " + err.Error()) + return err + } + for id, reminder := range reminders { + pending.SetKey(id, reminder) + go m.schedule(id) + } + log.Printf("[remind] Loaded %d pending reminders from %s\n", len(reminders), remindPath) + + return nil +} + +func (m *Module) OnUpdate(update tg.APIUpdate) { + // Not a message? Ignore + if update.Message == nil { + return + } + message := *update.Message + + if utils.IsCommand(message, m.name, "ricordami") { + // Supported formats: + // Xs/m/h/d => in X seconds/minutes/hours/days + // HH:MM => at HH:MM (24 hour format) + // HH:MM:SS => at HH:MM:SS (24 hour format) + // dd/mm/yyyy => same hour, specific date + // dd/mm/yyyy-HH:MM => specific hour, specific dat + // dd/mm/yyyy-HH:MM:SS => specific hour, specific date + + parts := strings.SplitN(*message.Text, " ", 3) + + // Little hack to allow text-less reminders with replies + if len(parts) == 2 && message.ReplyTo != nil { + parts = append(parts, "") + } + + if len(parts) < 3 { + m.client.SendTextMessage(tg.ClientTextMessageData{ + ChatID: message.Chat.ChatID, + Text: "Sintassi\n/ricordami [quando] Messaggio\n\nFormati supportati per [quando]:\n 10s 10m 10h 10d (secondi/minuti/ore/giorni)\n 13:20 15:55:01 (ora dello stesso giorno, formato 24h)\n 11/02/2099 11/02/2099-11:20:01 (giorno diverso, stessa ora [1] o specifica [2])", + ReplyID: &message.MessageID, + }) + return + } + + format := parts[1] + remindText := parts[2] + + loc := defaultLocation + /*TODO REDO + if uloc, ok := tzlocs[update.User.UserID]; ok { + loc = uloc + } + */ + timestamp, err := parseDuration(format, loc) + if err != nil { + m.client.SendTextMessage(tg.ClientTextMessageData{ + ChatID: message.Chat.ChatID, + Text: err.Error(), + ReplyID: &message.MessageID, + }) + return + } + + id := strconv.FormatInt(message.Chat.ChatID, 36) + "-" + strconv.FormatInt(message.MessageID, 36) + reminder := Reminder{ + TargetID: message.User.UserID, + When: timestamp.Unix(), + Text: remindText, + } + if message.ReplyTo != nil { + reminder.Reference = &ReminderReference{ + Chat: message.Chat.ChatID, + Message: message.ReplyTo.MessageID, + } + } + pending.SetKey(id, reminder) + save() + go m.schedule(id) + + whenday := "più tardi" + _, todaym, todayd := time.Now().Date() + _, targetm, targetd := timestamp.Date() + if todaym != targetm || todayd != targetd { + whenday = "il " + timestamp.In(loc).Format("2/1") + } + whentime := "alle " + timestamp.In(loc).Format("15:04:05") + m.client.SendTextMessage(tg.ClientTextMessageData{ + ChatID: message.Chat.ChatID, + Text: "Ok, vedrò di avvisarti " + whenday + " " + whentime, + ReplyID: &message.MessageID, + }) + return + } + + if utils.IsCommand(message, m.name, "reminders") { + // Should only work in private chats + if message.Chat.Type != tg.ChatTypePrivate { + + m.client.SendTextMessage(tg.ClientTextMessageData{ + ChatID: message.Chat.ChatID, + Text: "Per favore chiedimi in privato dei reminder", + ReplyID: &message.MessageID, + }) + return + } + + useritems := []Reminder{} + for _, reminder := range pending.Copy() { + if reminder.TargetID == message.User.UserID { + useritems = append(useritems, reminder) + } + } + + if len(useritems) == 0 { + m.client.SendTextMessage(tg.ClientTextMessageData{ + ChatID: message.Chat.ChatID, + Text: "Non ci sono reminder in coda per te", + ReplyID: &message.MessageID, + }) + } else { + m.client.SendTextMessage(tg.ClientTextMessageData{ + ChatID: message.Chat.ChatID, + Text: fmt.Sprintf("Ci sono %d reminder in coda per te", len(useritems)), + ReplyID: &message.MessageID, + }) + } + } +} + +func (m *Module) schedule(id string) { + // Get reminder + r := pending.GetKey(id) + remaining := r.When - time.Now().Unix() + if remaining > 0 { + // Wait remaining time + time.Sleep(time.Second * time.Duration(remaining)) + } + // Remind! + m.client.SendTextMessage(tg.ClientTextMessageData{ + ChatID: r.TargetID, + Text: "Heyla! Mi avevi chiesto di ricordarti questo:\n" + r.Text, + ReplyID: nil, + }) + if r.Reference != nil { + m.client.ForwardMessage(tg.ClientForwardMessageData{ + ChatID: r.TargetID, + FromChatID: r.Reference.Chat, + MessageID: r.Reference.Message, + }) + } + // Delete reminder from pending list and save list to disk + pending.DeleteKey(id) + save() +} + +func save() { + file, err := os.Create(remindPath) + if err != nil { + log.Println("[remind] WARN: Could not open pending reminders file: " + err.Error()) + return + } + err = json.NewEncoder(file).Encode(pending) + if err != nil { + log.Println("[remind] WARN: Could not save pending reminders into file: " + err.Error()) + } +} + +func isSscanfValid(n int, err error) bool { + return err == nil +} + +func scanMixedDelay(str string) (bool, time.Time, error) { + remaining := str + now := time.Now() + num := 0 + sep := ' ' + for len(remaining) > 1 { + _, err := fmt.Sscanf(remaining, "%d%c", &num, &sep) + if err != nil { + return false, now, err + } + dur := time.Duration(num) + switch unicode.ToLower(sep) { + case 's': + dur *= time.Second + case 'm': + dur *= time.Minute + case 'h': + dur *= time.Hour + case 'd': + dur *= time.Hour * 24 + default: + return true, now, fmt.Errorf("La durata ha una unità che non conosco, usa una di queste: s (secondi) m (minuti) h (ore) d (giorni)") + } + return true, now.Add(dur), nil + } + /* TO FIX + now = now.Add(dur) + remaining = remaining[scanned:] + } + return true, now, nil + */ + return false, now, nil +} + +func parseDuration(date string, loc *time.Location) (time.Time, error) { + now := time.Now().In(loc) + hour := now.Hour() + min := now.Minute() + sec := now.Second() + day := now.Day() + month := now.Month() + year := now.Year() + dayunspecified := false + isDurationFmt, duration, err := scanMixedDelay(date) + switch { + case isSscanfValid(fmt.Sscanf(date, "%d/%d/%d-%d:%d:%d", &day, &month, &year, &hour, &min, &sec)): + case isSscanfValid(fmt.Sscanf(date, "%d/%d/%d-%d:%d", &day, &month, &year, &hour, &min)): + sec = 0 + case isSscanfValid(fmt.Sscanf(date, "%d/%d/%d", &day, &month, &year)): + hour = now.Hour() + min = now.Minute() + sec = now.Second() + case isSscanfValid(fmt.Sscanf(date, "%d:%d:%d", &hour, &min, &sec)): + day = now.Day() + month = now.Month() + year = now.Year() + dayunspecified = true + case isSscanfValid(fmt.Sscanf(date, "%d:%d", &hour, &min)): + day = now.Day() + month = now.Month() + year = now.Year() + sec = 0 + dayunspecified = true + case isDurationFmt: + return duration, err + default: + return now, fmt.Errorf("Non capisco quando dovrei ricordartelo!") + } + targetDate := time.Date(year, month, day, hour, min, sec, 0, loc) + if targetDate.Before(now) { + // If day was not specified assume tomorrow + if dayunspecified { + targetDate = targetDate.Add(time.Hour * 24) + } else { + return now, fmt.Errorf("Non posso ricordarti cose nel passato!") + } + } + if targetDate.After(now.Add(reminderMaxDuration)) { + return now, fmt.Errorf("Non credo riuscirei a ricordarmi qualcosa per così tanto") + } + return targetDate, nil +}