Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd/bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions internal/cmd_handler/addnote.go
Original file line number Diff line number Diff line change
@@ -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
}
}
52 changes: 52 additions & 0 deletions internal/cmd_handler/addnote_callback.go
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions internal/cmd_handler/addnote_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
83 changes: 83 additions & 0 deletions internal/note/service.go
Original file line number Diff line number Diff line change
@@ -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] = &note
}

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
}
116 changes: 116 additions & 0 deletions pkg/noteparser/parser.go
Original file line number Diff line number Diff line change
@@ -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
}
47 changes: 47 additions & 0 deletions pkg/noteparser/parser_test.go
Original file line number Diff line number Diff line change
@@ -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)
}