diff --git a/cmd/bot/main.go b/cmd/bot/main.go index dfd7b4b..4046041 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -7,9 +7,10 @@ import ( th "github.com/mymmrac/telego/telegohandler" cmdh "github.com/gitrus/digikeeper-bot/internal/cmd_handler" + note "github.com/gitrus/digikeeper-bot/internal/note" + session "github.com/gitrus/digikeeper-bot/pkg/sessionmanager" cmdrouter "github.com/gitrus/digikeeper-bot/pkg/telego_commandrouter" tm "github.com/gitrus/digikeeper-bot/pkg/telego_middleware" - session "github.com/gitrus/digikeeper-bot/pkg/sessionmanager" ) func main() { @@ -41,10 +42,16 @@ func main() { useStateMiddleware := tm.NewUserSessionMiddleware[*session.SimpleUserSession](usm) bh.Use(useStateMiddleware.Middleware()) + noteSvc := note.NewInMemoryService() + cmdHandlerGroup := cmdrouter.NewCommandHandlerGroup() cmdHandlerGroup.RegisterCommand("start", cmdh.HandleStart, "Show start-bot message") cmdHandlerGroup.RegisterCommand("cancel", cmdh.HandleCancel(usm), "Interrupt any current operation/s") cmdHandlerGroup.RegisterCommand("add", cmdh.HandleAdd(usm), "Add new note to the list") + cmdHandlerGroup.RegisterCommand("addnote", cmdh.HandleAddNote(noteSvc), "Add note with tags and date") + + callbacks := bh.Group(th.AnyCallbackQuery()) + callbacks.Handle(cmdh.HandleAddNoteCallback(noteSvc), th.CallbackDataPrefix("addtag:"), th.CallbackDataEqual("save")) cmdHandlerGroup.BindCommandsToHandler(bh) diff --git a/internal/cmd_handler/addnote.go b/internal/cmd_handler/addnote.go new file mode 100644 index 0000000..c53dda4 --- /dev/null +++ b/internal/cmd_handler/addnote.go @@ -0,0 +1,46 @@ +package cmdhandler + +import ( + "log/slog" + "strings" + "time" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + tu "github.com/mymmrac/telego/telegoutil" + + "github.com/gitrus/digikeeper-bot/internal/note" + "github.com/gitrus/digikeeper-bot/pkg/noteparser" +) + +func HandleAddNote(svc note.Service) th.Handler { + return func(ctx *th.Context, update telego.Update) error { + slog.InfoContext(ctx.Context(), "Receive /addnote") + + chatID := tu.ID(update.Message.Chat.ID) + + raw := strings.TrimSpace(strings.TrimPrefix(update.Message.Text, "/addnote")) + createdAt := time.Unix(int64(update.Message.Date), 0) + parsedNote, _ := noteparser.Parse(createdAt, raw) + + svc.SetPending(update.Message.From.ID, parsedNote) + + msg := "Parsed note:\n" + parsedNote.Payload.Text + if len(parsedNote.Tags) > 0 { + msg += "\nAdd tags?" + } + + keyboard := tu.InlineKeyboard() + for _, tag := range parsedNote.Tags { + keyboard.Row(tu.InlineKeyboardButton("+" + tag).WithCallbackData("addtag:" + tag)) + } + keyboard.Row(tu.InlineKeyboardButton("Save").WithCallbackData("save")) + + _, err := ctx.Bot().SendMessage(ctx, tu.Message(chatID, msg).WithReplyMarkup(keyboard)) + if err != nil { + slog.ErrorContext(ctx.Context(), "Failed to send message", "error", err) + return err + } + return nil + } +} diff --git a/internal/cmd_handler/addnote_callback.go b/internal/cmd_handler/addnote_callback.go new file mode 100644 index 0000000..27fe8fc --- /dev/null +++ b/internal/cmd_handler/addnote_callback.go @@ -0,0 +1,52 @@ +package cmdhandler + +import ( + "log/slog" + "strings" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + tu "github.com/mymmrac/telego/telegoutil" + + "github.com/gitrus/digikeeper-bot/internal/note" +) + +func HandleAddNoteCallback(svc note.Service) th.Handler { + return func(ctx *th.Context, update telego.Update) error { + if update.CallbackQuery == nil { + return nil + } + + data := update.CallbackQuery.Data + userID := update.CallbackQuery.From.ID + chatID := tu.ID(update.CallbackQuery.Message.Chat.ID) + msgID := update.CallbackQuery.Message.MessageID + + if strings.HasPrefix(data, "addtag:") { + tag := strings.TrimPrefix(data, "addtag:") + svc.AddTagToPending(userID, tag) + text := update.CallbackQuery.Message.Text + "\nTag added: " + tag + _, err := ctx.Bot().EditMessageText(ctx, tu.EditMessageText(chatID, msgID, text)) + if err != nil { + slog.ErrorContext(ctx.Context(), "Failed to edit message", "error", err) + } + _ = ctx.Bot().AnswerCallbackQuery(ctx, tu.CallbackQuery(update.CallbackQuery.ID)) + return err + } + + if data == "save" { + if err := svc.SavePending(ctx.Context(), userID); err != nil { + slog.ErrorContext(ctx.Context(), "Failed to save note", "error", err) + return err + } + _, err := ctx.Bot().EditMessageText(ctx, tu.EditMessageText(chatID, msgID, "Note saved")) + if err != nil { + slog.ErrorContext(ctx.Context(), "Failed to edit message", "error", err) + return err + } + _ = ctx.Bot().AnswerCallbackQuery(ctx, tu.CallbackQuery(update.CallbackQuery.ID)) + return nil + } + return nil + } +} diff --git a/internal/cmd_handler/addnote_test.go b/internal/cmd_handler/addnote_test.go new file mode 100644 index 0000000..3bff36f --- /dev/null +++ b/internal/cmd_handler/addnote_test.go @@ -0,0 +1,16 @@ +package cmdhandler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gitrus/digikeeper-bot/internal/cmd_handler" + "github.com/gitrus/digikeeper-bot/internal/note" +) + +func TestHandleAddNote_ReturnsHandler(t *testing.T) { + svc := note.NewInMemoryService() + h := cmdhandler.HandleAddNote(svc) + assert.NotNil(t, h) +} diff --git a/internal/note/service.go b/internal/note/service.go new file mode 100644 index 0000000..6decba0 --- /dev/null +++ b/internal/note/service.go @@ -0,0 +1,83 @@ +package note + +import ( + "context" + "sync" + "time" +) + +type Payload struct { + EventAt time.Time + Text string +} + +type Note struct { + CreatedAt time.Time + Tags []string + Payload Payload +} + +type Service interface { + Save(ctx context.Context, userID int64, note Note) error + SetPending(userID int64, note Note) + AddTagToPending(userID int64, tag string) + GetPending(userID int64) (*Note, bool) + SavePending(ctx context.Context, userID int64) error +} + +type InMemoryService struct { + mu sync.Mutex + notes map[int64][]Note + pending map[int64]*Note +} + +func NewInMemoryService() *InMemoryService { + return &InMemoryService{notes: make(map[int64][]Note), pending: make(map[int64]*Note)} +} + +func (s *InMemoryService) Save(ctx context.Context, userID int64, note Note) error { + s.mu.Lock() + defer s.mu.Unlock() + s.notes[userID] = append(s.notes[userID], note) + return nil +} + +func (s *InMemoryService) SetPending(userID int64, note Note) { + s.mu.Lock() + defer s.mu.Unlock() + s.pending[userID] = ¬e +} + +func (s *InMemoryService) AddTagToPending(userID int64, tag string) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.pending[userID] + if !ok { + return + } + for _, t := range n.Tags { + if t == tag { + return + } + } + n.Tags = append(n.Tags, tag) +} + +func (s *InMemoryService) GetPending(userID int64) (*Note, bool) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.pending[userID] + return n, ok +} + +func (s *InMemoryService) SavePending(ctx context.Context, userID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.pending[userID] + if !ok { + return nil + } + s.notes[userID] = append(s.notes[userID], *n) + delete(s.pending, userID) + return nil +} diff --git a/pkg/noteparser/parser.go b/pkg/noteparser/parser.go new file mode 100644 index 0000000..e870795 --- /dev/null +++ b/pkg/noteparser/parser.go @@ -0,0 +1,116 @@ +package noteparser + +import ( + "regexp" + "strings" + "time" + + "github.com/gitrus/digikeeper-bot/internal/note" +) + +var ( + tagRegex = regexp.MustCompile(`#([A-Za-zА-Яа-я_]+)`) +) + +const ( + DateTime = "2006-01-02 15:04:05" + DateOnly = "2006-01-02" + TimeOnly = "15:04:05" + TimeHM = "15:04" +) + +const DateHM = "2006-01-02 15:04" + +var dateLayouts = []string{time.RFC3339, DateTime, DateHM, DateOnly, TimeOnly, TimeHM} + +type segmentParser func(n *note.Note, in string) (string, error) + +func compose(parsers ...segmentParser) segmentParser { + return func(n *note.Note, in string) (string, error) { + var err error + for _, p := range parsers { + in, err = p(n, in) + if err != nil { + return "", err + } + } + return in, nil + } +} + +func parseTags(n *note.Note, in string) (string, error) { + matches := tagRegex.FindAllStringSubmatch(in, -1) + for _, m := range matches { + if len(m) > 1 { + n.Tags = append(n.Tags, m[1]) + } + } + cleaned := tagRegex.ReplaceAllString(in, "") + return cleaned, nil +} + +func parseDate(n *note.Note, in string) (string, error) { + fields := strings.Fields(in) + remaining := make([]string, 0, len(fields)) + var ( + fullDT *time.Time + dateOnly *time.Time + timeOnly *time.Time + ) + for _, f := range fields { + parsed := false + for _, layout := range dateLayouts { + if t, err := time.Parse(layout, f); err == nil { + parsed = true + switch layout { + case DateTime, DateHM: + temp := t + fullDT = &temp + case DateOnly: + temp := t + dateOnly = &temp + case TimeOnly, TimeHM: + temp := t + timeOnly = &temp + } + break + } + } + if !parsed { + remaining = append(remaining, f) + } + } + + if fullDT != nil { + n.Payload.EventAt = *fullDT + } else { + event := n.Payload.EventAt + if dateOnly != nil { + d := *dateOnly + event = time.Date(d.Year(), d.Month(), d.Day(), event.Hour(), event.Minute(), event.Second(), 0, time.Local) + } + if timeOnly != nil { + tm := *timeOnly + event = time.Date(event.Year(), event.Month(), event.Day(), tm.Hour(), tm.Minute(), tm.Second(), 0, time.Local) + } + n.Payload.EventAt = event + } + return strings.Join(remaining, " "), nil +} + +func Parse(createdAt time.Time, input string) (note.Note, error) { + n := note.Note{ + CreatedAt: createdAt, + Payload: note.Payload{ + EventAt: createdAt, + }, + } + + p := compose(parseTags, parseDate) + remaining, err := p(&n, input) + if err != nil { + return note.Note{}, err + } + n.Payload.Text = strings.TrimSpace(remaining) + return n, nil +} diff --git a/pkg/noteparser/parser_test.go b/pkg/noteparser/parser_test.go new file mode 100644 index 0000000..4164beb --- /dev/null +++ b/pkg/noteparser/parser_test.go @@ -0,0 +1,47 @@ +package noteparser_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/gitrus/digikeeper-bot/internal/note" + "github.com/gitrus/digikeeper-bot/pkg/noteparser" +) + +func TestParseDateTimeAndTags(t *testing.T) { + createdAt := time.Date(2025, time.January, 1, 10, 0, 0, 0, time.Local) + input := "Meeting with team 2025-06-01 09:30 #work" + + n, err := noteparser.Parse(createdAt, input) + assert.NoError(t, err) + assert.Equal(t, createdAt, n.CreatedAt) + assert.Equal(t, []string{"work"}, n.Tags) + expected := time.Date(2025, 6, 1, 9, 30, 0, 0, time.Local) + assert.Equal(t, expected, n.Payload.EventAt) + assert.Equal(t, "Meeting with team", n.Payload.Text) +} + +func TestParseTimeOnly(t *testing.T) { + createdAt := time.Date(2025, time.May, 5, 8, 0, 0, 0, time.Local) + input := "Call mom 15:04 #family" + + n, err := noteparser.Parse(createdAt, input) + assert.NoError(t, err) + assert.Equal(t, []string{"family"}, n.Tags) + expected := time.Date(2025, 5, 5, 15, 4, 0, 0, time.Local) + assert.Equal(t, expected, n.Payload.EventAt) + assert.Equal(t, "Call mom", n.Payload.Text) +} + +func TestParseCyrillicTag(t *testing.T) { + createdAt := time.Date(2025, time.July, 7, 12, 0, 0, 0, time.Local) + input := "Обед #обед" + + n, err := noteparser.Parse(createdAt, input) + assert.NoError(t, err) + assert.Equal(t, []string{"обед"}, n.Tags) + assert.Equal(t, createdAt, n.Payload.EventAt) + assert.Equal(t, "Обед", n.Payload.Text) +}