Files
2026-07-13 12:30:20 +08:00

2902 lines
85 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Bot's main functionality. We accept messages from the user,
// we ask user where to save the messages. We save messages
// to plain markdown files locally.
package server
import (
"bytes"
"errors"
"fmt"
"io"
"math/rand"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"golang.org/x/exp/slog"
"github.com/zakirullin/files.md/server/config"
"github.com/zakirullin/files.md/server/fs"
"github.com/zakirullin/files.md/server/habits"
"github.com/zakirullin/files.md/server/i18n"
"github.com/zakirullin/files.md/server/journal"
"github.com/zakirullin/files.md/server/pkg/slice"
"github.com/zakirullin/files.md/server/pkg/tg"
"github.com/zakirullin/files.md/server/pkg/txt"
"github.com/zakirullin/files.md/server/plugins"
"github.com/zakirullin/files.md/server/stats"
"github.com/zakirullin/files.md/server/sync"
"github.com/zakirullin/files.md/server/userconfig"
)
var (
errUnknownCommand = errors.New("unknown command")
errInvalidRequestFromInline = errors.New("invalid request from inline query")
errInvalidInlineQuery = errors.New("invalid inline query")
BotPlugins = []BotPlugin{plugins.NewWorldClockPlugin()}
)
const (
btnsPerRow = 3
quickBtnsPerRow = 4
maxBtns = 50
maxBtnsInChecklist = 10 // For _read_ and _watch_ checklists, so we're less likely to be overwhelmed :)
maxGroupedBtnsInMoveTo = 6
maxInlineResults = 20
maxMsgLength = 4096 // In UTF-8 characters (runes), skin-tone emojis count as 2
maxMsgsToSendAtOnce = 5 // For lengthy messages
maxHeaderLength = 100
maxHeaderLengthForMobile = 33 // Fits regular mobile screen
inlineResultsCacheTime = 15 // Seconds
// On mobile phones buttons shrink to the message width, and sometimes it's too narrow, so we make the message wider
wideSpacer = "<code> </code>"
)
// Update represents incoming user updates.
type Update interface {
MsgText() string
UserID() int64
Cmd() *tg.Cmd
MsgEntities() []tgbotapi.MessageEntity
CaptionEntities() []tgbotapi.MessageEntity
CallbackQueryID() (string, bool)
InlineQueryID() (string, bool)
InlineQuery() (string, bool)
InlineQueryOffset() int
IsSentViaBot() bool
ReplyToMsgID() (int, bool)
PhotoOrImageID() (string, bool)
Caption() string
MsgID() (int, bool)
Time() (int, bool)
ChannelID() (int64, bool)
ChannelName() (string, bool)
}
// Chat provides a simple interface to chat API like Telegram.
type Chat interface {
Send(userID int64, text string, kb *tg.Keyboard, markup string) (int, error)
SendImages(userID int64, images []string) ([]int, error)
SendReaction(userID int64, msgID int, reaction string) error
Edit(userID int64, msgID int, text string, kb *tg.Keyboard, markup string) error
Del(userID int64, msgID int) error
AnswerCallbackQuery(queryID string, text string) error
AnswerInlineQuery(queryID string, results []interface{}, cacheTime int, offset string) error
DownloadFile(fileID string, outFile io.Writer) (string, error)
}
// Database stores per user data like "last sent message id"
type Database interface {
LastKeyboardMsgID() (int, bool)
SetLastKeyboardMsgID(ID int)
DelLastKeyboardMsgID()
InputExpectation() *tg.Cmd
SetInputExpectation(cmd tg.Cmd)
DelInputExpectation()
HashOrPathByMsgID(msgID int) (string, bool)
SetHashOrPathByMsgID(msgID int, value string)
RecentCommand() (string, bool)
SetRecentCommand(cmd string)
RecentCommandParams() ([]string, bool)
SetRecentCommandParams(params []string)
AddImgMsgID(msgID int)
ImgMsgID() ([]int, bool)
DelImgMsgID()
}
type BotPlugin interface {
CanHandle(string) bool
Handle(string) (output string, error error)
}
var now = time.Now
// Telegram only allows 64 bytes in callback_data,
// So we have to be really short :)
const (
CmdShowStart = "start"
CmdDoNothing = "nothing"
CmdShowLater = "later"
CmdShowHome = "home"
CmdShowFiles = "files"
CmdShowDirs = "dirs"
CmdShowPostpone = "postpone"
CmdShowMoveExisting = "move"
CmdShowMoveTo = "s_move"
CmdShowRename = "rename"
CmdShowRenameFile = "rename_file"
CmdShowChecklists = "checklists"
CmdShowStats = "stats"
CmdOpenInApp = "app"
CmdShowHelp = "help"
CmdComplete = "c"
CmdPostpone = "post"
CmdShowLongItemFromChecklist = "item"
CmdShowLongItem = "item_t"
CmdShowFile = "file"
CmdShowChecklist = "checklist"
CmdShowChecklistItem = "check_show"
CmdCompleteListItem = "check_comp"
CmdShowMoveToDirOrFile = "to_file"
CmdShowMoveToChecklist = "to_checklist"
CmdRename = "ren"
CmdMoveToExistingDir = "mv"
CmdMoveToChecklist = "add_item"
CmdCompleteChecklistItem = "check_item"
CmdRequestNewDir = "new_dir"
CmdMoveToNewDir = "mv_to_new_dir"
CmdMoveToExistingFile = "mf"
CmdMoveToExistingNote = "mvn"
CmdMoveToNewFile = "mn"
CmdMoveToDirChecklist = "mv_to_chk"
CmdMoveToRead = "mv_to_read"
CmdMoveToWatch = "mv_to_watch"
CmdMoveToShop = "mv_to_shop"
CmdMoveToNewChecklist = "mv_to_new_chk"
CmdMoveToJournal = "mv_to_journal"
CmdMoveToLater = "mv_later"
CmdShowScheduleForDay = "sc_day"
CmdSchedule = "sc"
CmdScheduleForTmrw = "sc_tmrw"
CmdPomodoro = "pomodoro"
CmdShowScheduleForDayRecurring = "sc_day_r"
CmdLater = "later"
CmdShowSettings = "settings"
CmdShowQuickBtnsSettings = "c_quick_btns"
CmdShowMoveToBtnsSettings = "c_move_btns"
CmdAddToQuickBtns = "add_quick"
CmdDelFromQuickBtns = "del_quick"
CmdAddToMoveToBtns = "add_move"
CmdDelFromMoveToBtns = "del_move"
CmdShowTimezone = "timezone"
CmdSetTimezone = "set_timezone"
CmdShowReadChecklist = "read"
CmdShowWatchChecklist = "watch"
CmdShowShopChecklist = "shop"
CmdShowSchedule = "schedule"
CmdDownload = "download"
CmdTasksOnlyMode = "tasks_only"
CmdNotesOnlyMode = "notes_only"
CmdJournalOnlyMode = "journal_only"
CmdFullMode = "full"
CmdChatMode = "chat"
CmdInlineQuerySearchEveryWhere = "search"
CmdWebAppHabits = "habits"
CmdRandomNote = "random_note"
CmdAddToJournalShortcut = "j"
CmdAddToJournalAndContinueShortcut = "ja"
CmdAddToRecentFileShortcut = "+"
CmdCompleteHabit = "ch"
CmdShare = "share"
)
var Shortcuts = map[string][]string{
CmdAddToJournalShortcut: {"/ж", "jj", "жж"},
CmdAddToJournalAndContinueShortcut: {"жд", "jd", "ja"},
CmdAddToRecentFileShortcut: {"++"},
}
// Bot has all the things that we need to handle a message or command from a user.
// We use tg chat to talk with the user.
// We use fs to save artefacts to the disk (.md files).
// We use db to save temporal things like recent command.
// We use cfg to configure bot behaviour (config.json).
type Bot struct {
userID int64
tg Chat
fs *fs.FS
db Database
cfg *userconfig.Config
}
func NewBot(userID int64, tg Chat, fs *fs.FS, db Database, cfg *userconfig.Config) *Bot {
return &Bot{userID, tg, fs, db, cfg}
}
// Reply to incoming text message, command or inline query
func (b *Bot) Reply(u Update) error {
// Handle inline queries.
if _, ok := u.InlineQueryID(); ok {
return b.answerSearch(u)
}
// Handle messages from channels.
_, isChannel := u.ChannelID()
if isChannel {
channelName, _ := u.ChannelName()
if len(strings.TrimSpace(channelName)) == 0 {
channelName = "UnknownChannel"
}
return b.addToFile(fs.DirUserRoot, fs.Filename(channelName), u.MsgText())
}
// Handle plugins.
for _, plugin := range BotPlugins {
if plugin.CanHandle(u.MsgText()) {
output, err := plugin.Handle(u.MsgText())
if err != nil {
return fmt.Errorf("answer: plugin error: %w", err)
}
_, _ = b.tg.Send(b.userID, output, nil, tg.MarkupHTML)
b.delAllKeyboards()
err = b.ShowHome(nil)
if err != nil {
return fmt.Errorf("answer after plugin: %w", err)
}
return nil
}
}
// Handle inline query file requests
if u.IsSentViaBot() {
return b.answerFileRequest(u.MsgText())
}
// Handle commands
cmd, err := b.extractCmd(u)
if err != nil {
return fmt.Errorf("answer: %w", err)
}
if cmd != nil {
if _, ok := u.CallbackQueryID(); !ok {
b.delAllKeyboards()
}
handler, ok := b.handlers()[cmd.Name]
if !ok {
// It should be handled at cmd extraction step
return fmt.Errorf("no such command %s: %w", cmd.Name, errUnknownCommand)
}
slog.Debug("Command is called", "command", cmd.Name, "params", cmd.Params)
err = handler(cmd.Params)
if err != nil {
return err
}
if callbackQueryID, ok := u.CallbackQueryID(); ok {
// We can tolerate an error here, that won't affect UX
if cmd.Name == CmdCompleteHabit || cmd.Name == CmdComplete {
_ = b.tg.AnswerCallbackQuery(callbackQueryID, completedMsg())
} else if cmd.Name == CmdShare {
_ = b.tg.AnswerCallbackQuery(callbackQueryID, "Shared 💚!")
} else {
_ = b.tg.AnswerCallbackQuery(callbackQueryID, "")
}
}
return nil
}
// Handle images.
if _, hasImage := u.PhotoOrImageID(); hasImage {
err = b.saveFromImage(u)
} else {
err = b.saveFromTextMsg(u)
}
if errors.Is(err, fs.ErrQuotaExceeded) {
b.tg.Send(b.userID, "Storage quota exceeded. Please delete some files.", nil, tg.MarkupHTML)
return nil
}
return err
}
// Commands and their handlers.
// Every handler accepts []string params
func (b *Bot) handlers() map[string]func([]string) error {
handlers := map[string]func([]string) error{
// Direct user commands
CmdShowHome: b.ShowHome,
CmdShowStart: b.showStart,
CmdShowLater: b.showLaterTasks,
CmdShowFiles: b.showFiles,
CmdShowDirs: b.showDirs,
CmdShowChecklists: b.showChecklists,
CmdShowPostpone: b.showPostpone,
CmdShowMoveTo: b.showMoveTo,
CmdShowRename: b.showRename,
CmdShowStats: b.showStats,
CmdShowReadChecklist: b.showRead,
CmdRandomNote: b.randomNote,
CmdShowWatchChecklist: b.showWatch,
CmdShowShopChecklist: b.showShop,
CmdShowSchedule: b.showSchedule,
CmdShowMoveExisting: b.showMoveExisting,
CmdShowSettings: b.showSettings,
CmdShowTimezone: b.showTimezone,
CmdSetTimezone: b.setTimezone,
CmdOpenInApp: b.openInApp,
CmdShowHelp: b.showHelp,
CmdDownload: b.download,
// Button's commands (callbacks)
CmdShowRenameFile: b.showRenameFile,
CmdShowLongItemFromChecklist: b.showLongItemFromChecklist,
CmdShowLongItem: b.showLongItem,
CmdShowFile: b.showFile,
CmdShowChecklist: b.showChecklist,
CmdCompleteListItem: b.completeListItem,
CmdShowChecklistItem: b.showChecklistItem,
CmdShowScheduleForDay: b.showToADay,
CmdShowMoveToDirOrFile: b.showMoveToFileOrDir,
CmdShowMoveToChecklist: b.showToChecklist,
CmdMoveToExistingDir: b.moveToDir,
CmdMoveToChecklist: b.moveToChecklist,
CmdCompleteChecklistItem: b.completeChecklistItem,
CmdRequestNewDir: b.requestNewDirName,
CmdMoveToNewDir: b.moveToNewDir,
CmdMoveToExistingFile: b.moveToExistingFile,
CmdMoveToExistingNote: b.moveToExistingNote,
CmdMoveToNewFile: b.moveToNewFile,
CmdMoveToDirChecklist: b.moveToDirChecklist,
CmdMoveToRead: b.moveToRead,
CmdMoveToWatch: b.moveToWatch,
CmdMoveToShop: b.moveToShop,
CmdMoveToNewChecklist: b.moveToNewChecklist,
CmdMoveToJournal: b.moveToJournal,
CmdMoveToLater: b.moveToLater,
CmdSchedule: b.schedule,
CmdScheduleForTmrw: b.scheduleForTmrw,
CmdComplete: b.complete,
CmdPostpone: b.postpone,
CmdPomodoro: b.togglePomodoro,
CmdShowScheduleForDayRecurring: b.showToADayRecurring,
CmdShowQuickBtnsSettings: b.showQuickBtnsSettings,
CmdShowMoveToBtnsSettings: b.showMoveToBtnsSettings,
CmdAddToQuickBtns: b.addToQuickBtns,
CmdDelFromQuickBtns: b.delFromQuickBtns,
CmdAddToMoveToBtns: b.addToMoveToBtns,
CmdDelFromMoveToBtns: b.delFromMoveToBtns,
CmdAddToJournalShortcut: b.addToJournalFromShortcut,
CmdAddToJournalAndContinueShortcut: b.addToJournalAndContinue,
CmdAddToRecentFileShortcut: b.addToRecentFileOrNoteFromShortcut,
CmdRename: b.rename,
CmdTasksOnlyMode: b.setTasksOnlyMode,
CmdNotesOnlyMode: b.setNotesOnlyMode,
CmdJournalOnlyMode: b.setJournalOnlyMode,
CmdFullMode: b.setFullMode,
CmdChatMode: b.setChatOnlyMode,
CmdCompleteHabit: b.completeHabit,
CmdShare: b.shareNote,
// Used for button-like separators
CmdDoNothing: func(s []string) error { return nil },
}
for cmd, shortcuts := range Shortcuts {
for _, shortcut := range shortcuts {
handlers[shortcut] = handlers[cmd]
}
}
return handlers
}
func (b *Bot) extractCmd(u Update) (*tg.Cmd, error) {
cmd := u.Cmd()
if cmd != nil {
// Check if the command is known
_, ok := b.handlers()[cmd.Name]
if !ok {
_, _ = b.tg.Send(b.userID, i18n.Tr("I know nothing about this command 😕"), nil, tg.MarkupHTML)
return nil, fmt.Errorf("unknown command: %s", cmd.Name)
}
b.db.DelInputExpectation()
return cmd, nil
}
// Input expectation is mostly used for renaming things
cmd = b.db.InputExpectation()
if cmd != nil {
slog.Debug("Got command from input expectation", "command", cmd.Name)
b.db.DelInputExpectation()
for i, param := range cmd.Params {
if param == "%s" {
cmd.Params[i] = u.MsgText()
}
}
return cmd, nil
}
for canonicalCMD, shortcuts := range Shortcuts {
for _, shortcut := range shortcuts {
escapedShortcut := regexp.QuoteMeta(shortcut)
reText := regexp.MustCompile(fmt.Sprintf(`(?i)^%s\s+|\s+%s$`, escapedShortcut, escapedShortcut))
// The only difference from reText is that caption can contain only shortcut, with no other text
reCaption := regexp.MustCompile(fmt.Sprintf(`(?i)^%s\s+|\s+%s$|^\s*%s\s*$`, escapedShortcut, escapedShortcut, escapedShortcut))
doesntMatchText := !reText.MatchString(u.MsgText())
doesntMatchCaption := !reCaption.MatchString(u.Caption())
if doesntMatchText && doesntMatchCaption {
continue
}
text := ""
_, hasImage := u.PhotoOrImageID()
if hasImage {
var errImage error
text, errImage = b.saveImage(u)
if errImage != nil {
return nil, fmt.Errorf("save image: %w", errImage)
}
text = string(reCaption.ReplaceAll([]byte(text), []byte("")))
} else {
text = extractMarkdown(u)
text = string(reText.ReplaceAll([]byte(text), []byte("")))
}
text = txt.Ucfirst(strings.TrimSpace(text))
shortCmd := tg.NewCmd(canonicalCMD, []string{text})
return &shortCmd, nil
}
}
return nil, nil
}
func (b *Bot) saveFromTextMsg(u Update) error {
msg := extractMarkdown(u)
if len(msg) == 0 {
return fmt.Errorf("save: empty message")
}
// Collapse a few consecutive messages into one, see bot_forwards.go
msgTime, updateHasTime := u.Time()
if updateHasTime {
_, shouldCollapse := collapseToMsg(b.userID, msgTime)
if shouldCollapse {
// We just write at the end of our append-only chat file,
// that would concat the current message with the previous one.
err := b.createOrAdd(fs.DirUserRoot, fs.ChatFilename, msg)
if err != nil {
return fmt.Errorf("save collapsed: %w", err)
}
return nil
}
}
// Adding to an existing file or chat item
if replyMsgID, ok := u.ReplyToMsgID(); ok {
return b.addToReplied(replyMsgID, msg)
}
msgHash, err := b.appendToChat(msg, b.cfg.Timezone())
if err != nil {
return fmt.Errorf("save to chat: %w", err)
}
if b.cfg.ChatOnlyMode() {
msgID, _ := u.MsgID()
_ = b.tg.SendReaction(b.userID, msgID, "👌")
return nil
}
if updateHasTime {
setFirstMsgHash(b.userID, msgHash, msgTime)
setFirstMsgTime(b.userID, msgTime)
}
if b.cfg.JournalOnlyMode() {
return b.moveToJournal([]string{msgHash})
}
return b.showMoveTo([]string{msgHash})
}
// TODO test collapsing from both regular messages and images
func (b *Bot) saveFromImage(u Update) error {
content, err := b.saveImage(u)
if err != nil {
return fmt.Errorf("save from image: %w", err)
}
// Collapse a few consecutive messages into one, see bot_forwards.go
msgTime, updateHasTime := u.Time()
if updateHasTime {
_, shouldCollapse := collapseToMsg(b.userID, msgTime)
if shouldCollapse {
err := b.createOrAdd(fs.DirUserRoot, fs.ChatFilename, content)
if err != nil {
return fmt.Errorf("save collapsed: %w", err)
}
return nil
}
}
// Adding to an existing file or chat item
if replyMsgID, ok := u.ReplyToMsgID(); ok {
return b.addToReplied(replyMsgID, content)
}
msgHash, err := b.appendToChat(content, b.cfg.Timezone())
if err != nil {
return fmt.Errorf("save from image: %w", err)
}
if b.cfg.ChatOnlyMode() {
msgID, _ := u.MsgID()
// We can tolerate missing reaction.
_ = b.tg.SendReaction(b.userID, msgID, "👌")
return nil
}
// Track forwards.
if updateHasTime {
setFirstMsgHash(b.userID, msgHash, msgTime)
setFirstMsgTime(b.userID, msgTime)
}
if b.cfg.JournalOnlyMode() {
return b.moveToJournal([]string{msgHash})
}
return b.showMoveTo([]string{msgHash})
}
// saveImage saves an image to the filesystem and returns a markdown link to it
func (b *Bot) saveImage(u Update) (string, error) {
imageID, _ := u.PhotoOrImageID()
var buf bytes.Buffer
extension, err := b.tg.DownloadFile(imageID, &buf)
if err != nil {
return "", fmt.Errorf("can't download file: %w", err)
}
imgFilename := fmt.Sprintf("tg_%s%s", imageID, extension)
err = b.fs.Write(fs.DirMedia, imgFilename, buf.String())
if err != nil {
return "", fmt.Errorf("can't save image: %w", err)
}
// TODO remove center
imgPath := fmt.Sprintf("%s/%s", fs.DirMedia, imgFilename)
content := fmt.Sprintf("![](%s)", imgPath)
// If there's caption, place it under the image
if u.Caption() != "" {
caption := txt.TelegramEntitiesToMarkdown(u.Caption(), u.CaptionEntities())
caption = strings.TrimSpace(txt.NormNewLines(caption))
content = fmt.Sprintf("%s\n%s", content, txt.Ucfirst(caption))
}
return content, nil
}
// addToReplied appends newContent to whatever the bot rendered for
// replyToMsgID. Chat-item targets are stored with a "#" prefix
// ("#<msgHash>"); file targets are stored as a plain relative path.
func (b *Bot) addToReplied(replyToMsgID int, newContent string) error {
value, ok := b.db.HashOrPathByMsgID(replyToMsgID)
if !ok {
return fmt.Errorf("add to replied: no target for msgID %d", replyToMsgID)
}
if strings.HasPrefix(value, "#") {
msgHash := strings.TrimPrefix(value, "#")
chatMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err != nil {
return fmt.Errorf("add to replied chat: can't read chat: %w", err)
}
updated, err := appendToChatMsg(chatMD, msgHash, newContent)
if err != nil {
return fmt.Errorf("add to replied chat: %w", err)
}
if err := b.fs.Write(fs.DirUserRoot, fs.ChatFilename, updated); err != nil {
return fmt.Errorf("add to replied chat: can't write chat: %w", err)
}
b.delAllKeyboards()
return b.ShowHome(nil)
}
// File case: value is a relative path under user root. fs.SafePath
// accepts the whole thing as `filename` when dir is the user root.
existingContent, err := b.fs.Read(fs.DirUserRoot, value)
if err != nil {
return fmt.Errorf("add: can't read: %w", err)
}
content := strings.TrimRight(existingContent, "\n") + "\n\n" + strings.TrimSpace(newContent) + "\n"
if err := b.fs.Write(fs.DirUserRoot, value, content); err != nil {
return fmt.Errorf("add: can't write: %w", err)
}
b.delAllKeyboards()
b.db.SetRecentCommand(CmdMoveToExistingFile)
b.db.SetRecentCommandParams([]string{fs.ShortHash(value)})
return b.ShowHome(nil)
}
func (b *Bot) answerSearch(u Update) error {
query, ok := u.InlineQuery()
if !ok {
return nil
}
query = strings.TrimSpace(query)
if strings.Contains(query, "../") || strings.Contains(query, "/..") {
return fmt.Errorf("insecure input '%s': %w", query, errInvalidInlineQuery)
}
matchedNotes, err := b.fs.SearchFilesByName(query)
if err != nil {
return fmt.Errorf("inline reply: %w", err)
}
if u.InlineQueryOffset() >= len(matchedNotes) {
return nil
}
maxIndex := min(u.InlineQueryOffset()+maxInlineResults, len(matchedNotes))
matchedNotes = matchedNotes[u.InlineQueryOffset():maxIndex]
var results []interface{}
for id, note := range matchedNotes {
// Nested files: show the relative path without leading "/" so the
// search result reads like "happiness/sub/Note.md".
path := note.Name
if note.ParentDir != fs.DirUserRoot && note.ParentDir != "" {
path = fmt.Sprintf("<code>%s/%s</code>", note.ParentDir, note.Name)
}
article := tgbotapi.NewInlineQueryResultArticleHTML(strconv.Itoa(id), note.DisplayName, path)
results = append(results, article)
}
queryID, _ := u.InlineQueryID()
nextOffset := strconv.Itoa(u.InlineQueryOffset() + maxInlineResults)
// First element is usually the file itself, exclude it
if len(query) == 0 {
results = results[1:]
}
err = b.tg.AnswerInlineQuery(queryID, results, inlineResultsCacheTime, nextOffset)
// FakeTG library has a bug of unmarshalling sent result, we'll mute that temporarily
if err != nil && !strings.HasSuffix(err.Error(), "Go value of type tgbotapi.Message") {
return fmt.Errorf("inline reply: %w", err)
}
return nil
}
func (b *Bot) answerFileRequest(msg string) error {
if strings.Contains(msg, "../") || strings.Contains(msg, "/..") {
return fmt.Errorf("insecure input '%s': %w", msg, errInvalidRequestFromInline)
}
// Split on the FIRST slash: dir is the top-level directory (what
// Unhash resolves against root), path is everything after it. Lets
// nested inline-search results like
// "triggers/habits/insights/2022 Habits.md" parse as
// (dir="triggers", path="habits/insights/2022 Habits.md").
msg = strings.TrimSpace(msg)
var dir, path string
if idx := strings.Index(msg, "/"); idx == -1 {
dir = fs.DirUserRoot
path = msg
} else {
dir = strings.TrimSpace(msg[:idx])
path = strings.TrimSpace(msg[idx+1:])
}
if path == "" {
return fmt.Errorf("invalid inline query '%s': %w", msg, errInvalidRequestFromInline)
}
b.delAllKeyboards()
// TODO add tests
// User wants to add his text to a selected file
c := b.db.InputExpectation()
if c != nil {
b.db.DelInputExpectation()
msgHash := c.Params[0]
err := b.moveFromChat(func(content string, timestamp time.Time) error {
if dir == fs.DirUserRoot {
// We have a file
b.db.SetRecentCommand(CmdMoveToExistingFile)
b.db.SetRecentCommandParams([]string{fs.ShortHash(path)})
} else {
// We have a note (a file placed in a subdirectory)
b.db.SetRecentCommand(CmdMoveToExistingNote)
b.db.SetRecentCommandParams([]string{fs.ShortHash(path), fs.ShortHash(dir)})
}
err := b.addToFile(dir, path, content)
if err != nil {
return fmt.Errorf("inline query: can't add to file %s: %w", path, err)
}
return nil
}, false, msgHash)
if err != nil {
return fmt.Errorf("inline query: can't move from chat: %w", err)
}
// Just an informative message
_, _ = b.tg.Send(b.userID, fmt.Sprintf(i18n.Tr("Saved to <b>%s</b>"), fs.DisplayName(path)), nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
return b.showFile([]string{dir, path})
}
func (b *Bot) createOrAdd(dir, filename, content string) error {
exists, err := b.fs.Exists(dir, filename)
if err != nil {
return fmt.Errorf("create: %w", err)
}
if exists {
existingContent, err := b.fs.Read(dir, filename)
if err != nil {
return fmt.Errorf("create: %w", err)
}
existingContent = strings.TrimSpace(existingContent)
if len(existingContent) != 0 {
content = fmt.Sprintf("%s\n%s", strings.TrimSpace(existingContent), content)
}
}
if err := b.fs.Write(dir, filename, content); err != nil {
return fmt.Errorf("create: %w", err)
}
return nil
}
func (b *Bot) extractHeaderAndBody(msg string, maxHeaderLen int) (string, string, error) {
if len(msg) == 0 {
return "", "", fmt.Errorf("extract title: empty msg")
}
parts := strings.Split(msg, "\n")
title := txt.Ucfirst(strings.TrimSpace(parts[0]))
if txt.HasImage(title) {
if len(parts) > 1 {
title = txt.Ucfirst(strings.TrimSpace(parts[1]))
}
if title == "" || len(parts) == 1 {
title = fmt.Sprintf("Img %s", now().Format("02.01.06 15:04"))
}
}
if utf8.RuneCountInString(title) > maxHeaderLen {
title = txt.Substr(title, 0, maxHeaderLen) + "..."
}
sanitizedTitle := fs.SanitizeFilename(title)
content := msg
// If title is the same as content, we don't need to save it
if sanitizedTitle == content {
content = ""
}
// If title is already in the content, remove it.
// See bot.restoreMsg() to see how the message is restored.
if strings.HasPrefix(content, sanitizedTitle) {
content = strings.TrimSpace(strings.TrimPrefix(content, sanitizedTitle))
}
return sanitizedTitle, content, nil
}
// If content is empty, use its filename as content.
// If file has content, add filename to the beginning of the content.
// If file has content, and filename was truncated (...), no need to add filename.
// If file has image and caption underneath it, no need to add title.
// The ugliest method so far.
func (b *Bot) restoreMsg(dir, filename string) (string, error) {
msg, err := b.fs.Read(dir, filename)
if err != nil {
return "", fmt.Errorf("can't restore msg for '%s': %w", filename, err)
}
title := fs.DisplayName(filename)
nonTruncatedTitle := strings.TrimRight(title, "...")
sanitizedContent := strings.ToLower(fs.SanitizeFilename(msg))
contentHasNoTitle := !strings.HasPrefix(sanitizedContent, strings.ToLower(nonTruncatedTitle))
hasNoImg := !txt.HasImage(msg)
if len(msg) == 0 {
return title, nil
} else if contentHasNoTitle && hasNoImg {
return fmt.Sprintf("%s\n%s", title, msg), nil
}
// msg has all the information, title doesn't have anything to add
return msg, nil
}
func (b *Bot) tr(str string, args ...any) string {
str = i18n.Tr(str)
return fmt.Sprintf(str, args...)
}
// Replace last message + keyboard with the new one
// Or show the new one (in case of wimagehoto).
func (b *Bot) showHTML(validHTML string, kb *tg.Keyboard) error {
b.delAllImages()
mid, hasLastKeyboard := b.db.LastKeyboardMsgID()
if !hasLastKeyboard {
b.delAllKeyboards()
mid, err := b.tg.Send(b.userID, validHTML, kb, tg.MarkupHTML)
if err != nil {
return fmt.Errorf("show: %w", err)
}
b.db.SetLastKeyboardMsgID(mid)
return nil
}
return b.tg.Edit(b.userID, mid, validHTML, kb, tg.MarkupHTML)
}
// Replace last message + keyboard with the new ones
// Or show the new one (in case of image).
// Read "Markdown to HTML conversion" section in readme's ADRs
// Chat allows 1-4096 characters AFTER entities parsing,
// meaning we can have 4096 plain chars + any amount of tags.
func (b *Bot) showMD(probablyInvalidMD string, kb *tg.Keyboard) error {
b.delAllImages()
probablyInvalidMD, images, links := txt.ExtractTextImgsLinks(probablyInvalidMD)
for label, link := range links {
dir := fs.DirUserRoot
link = strings.TrimSpace(link)
parts := strings.SplitN(link, "/", 2)
if len(parts) == 2 {
dir = parts[0]
link = parts[1]
}
cmd := tg.NewCmd(CmdShowFile, []string{fs.Hash(dir), fs.Hash(link)})
kb.PrependRow(tg.NewRow(tg.NewBtn(txt.Ucfirst(label), cmd)))
}
mid, hasLastKeyboard := b.db.LastKeyboardMsgID()
textChunks := txt.SplitTextIntoChunks(probablyInvalidMD, maxMsgLength)
if !hasLastKeyboard || len(textChunks) > 1 || len(images) > 0 {
b.delAllKeyboards()
// Sending a gallery of images if there are any
if len(images) > 0 {
// We tolerate errors with the image gallery for now, text is more important
mids, imgErr := b.tg.SendImages(b.userID, images)
if imgErr == nil {
for _, imgMid := range mids {
b.db.AddImgMsgID(imgMid)
}
} else {
slog.Error("Can't send images", "error", imgErr)
}
}
// If our msg is too long, we send maxMsgsToSendAtOnce first messages.
// Keyboard is attached to the last one
textChunks = textChunks[0:min(maxMsgsToSendAtOnce, len(textChunks))]
lastChunk := textChunks[len(textChunks)-1]
textChunks = textChunks[0 : len(textChunks)-1]
for _, textChunk := range textChunks {
_, _ = b.tg.Send(b.userID, txt.MarkdownToHTML(textChunk), nil, tg.MarkupHTML)
}
mid, err := b.tg.Send(b.userID, txt.MarkdownToHTML(lastChunk), kb, tg.MarkupHTML)
if err != nil {
return fmt.Errorf("show: %w", err)
}
b.db.SetLastKeyboardMsgID(mid)
return nil
}
return b.tg.Edit(b.userID, mid, txt.MarkdownToHTML(probablyInvalidMD), kb, tg.MarkupHTML)
}
func (b *Bot) showMoveTo(params []string) error {
msgHash := params[0]
if b.cfg.NotesOnlyMode() {
b.delAllKeyboards()
return b.showMoveToFileOrDir([]string{msgHash})
}
var kb tg.Keyboard
userMoveToBtns := b.moveToBtns(msgHash)
if len(userMoveToBtns) == 0 {
b.delAllKeyboards()
return b.ShowHome(nil)
}
// Add recent command if any
recentBtn := b.recentCmdBtn(msgHash)
if recentBtn != nil {
userMoveToBtns = append(userMoveToBtns, *recentBtn)
}
// This command is "do nothing and leave an item in the inbox"
if !b.cfg.TasksOnlyMode() {
showTodayCmd := tg.NewCmd(CmdShowHome, []string{})
showTodayLabel := "👌"
userMoveToBtns = append(userMoveToBtns, tg.NewBtn(showTodayLabel, showTodayCmd))
}
userBtnsByRows := slice.Chunk(userMoveToBtns, btnsPerRow)
for _, row := range userBtnsByRows {
kb.AddRow(row)
}
b.delAllKeyboards()
msg := b.tr("Saved!")
if err := b.showHTML(msg, &kb); err != nil {
return fmt.Errorf("move: %w", err)
}
return nil
}
func (b *Bot) recentCmdBtn(msgHash string) *tg.Btn {
recentCmd, ok := b.db.RecentCommand()
if !ok {
return nil
}
args, _ := b.db.RecentCommandParams()
args = append(args, msgHash)
targetFilenameHash := args[0]
var unhashedTarget string
icon := "⭐️"
if recentCmd == CmdMoveToExistingFile {
var err error
unhashedTarget, err = b.fs.Unhash(fs.DirUserRoot, targetFilenameHash)
if err != nil {
return nil
}
} else if recentCmd == CmdMoveToExistingNote {
dir, err := b.fs.Unhash(fs.DirUserRoot, args[1])
if err != nil {
return nil
}
unhashedTarget, err = b.fs.Unhash(dir, targetFilenameHash)
if err != nil {
return nil
}
} else {
return nil
}
name := fmt.Sprintf("%s %s", icon, fs.DisplayName(unhashedTarget))
btn := tg.NewBtn(name, tg.NewCmd(recentCmd, args))
return &btn
}
func (b *Bot) ShowHome(_ []string) error {
if b.cfg.NotesOnlyMode() {
return b.showDirs(nil)
}
if b.cfg.JournalOnlyMode() || b.cfg.ChatOnlyMode() {
_, err := b.tg.Send(b.userID, i18n.Tr("What's on your mind?"), nil, tg.MarkupHTML)
if err != nil {
return fmt.Errorf("show today: can't send journal message: %w", err)
}
return nil
}
var kb tg.Keyboard
// Adding records from today
content, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("show today: can't read chat file: %w", err)
}
blocks := readChatMsgs(content)
// Today entry: `- [ ] body` or `- [ ] `HH:MM` body` (timestamp optional).
chatEntryRegex := regexp.MustCompile(`^- \[([ xX])\] (?:` + "`" + `\d{2}:\d{2}` + "` )?")
shownCount := 0
for _, block := range blocks {
m := chatEntryRegex.FindStringSubmatch(block)
if m == nil {
continue
}
// Skip already-completed entries from the visible list. The hash is
// the same for `[ ]` / `[x]` variants, so tapping the button after a
// completion toggle still resolves to the right line.
if m[1] == "x" || m[1] == "X" {
continue
}
shownCount++
msgHash := chatBlockHash(block)
// Strip the matched prefix (optional checkbox + optional timestamp).
block = strings.TrimSpace(block[len(m[0]):])
// Skip image link if any.
parts := strings.Split(block, "\n")
title := txt.Ucfirst(strings.TrimSpace(parts[0]))
if txt.HasImage(title) {
if len(parts) > 1 {
title = txt.Ucfirst(strings.TrimSpace(parts[1]))
}
if title == "" || len(parts) == 1 {
title = fmt.Sprintf("Img %s", now().Format("02.01.06 15:04"))
}
}
isMultiline := len(parts) > 1
if len([]rune(title)) >= maxHeaderLengthForMobile || txt.HasImage(block) || isMultiline {
cmd := tg.NewCmd(CmdShowLongItem, []string{msgHash})
btn := tg.NewBtn(txt.Emoji(i18n.Emoji("eyes"), title), cmd)
kb.AddRow(btn)
} else {
cmd := tg.NewCmd(CmdComplete, []string{msgHash})
btn := tg.NewBtn(txt.Emoji(i18n.Emoji(title), title), cmd)
kb.AddRow(btn)
}
}
// Adding habits
habitsRow := tg.NewRow()
userHabits := make(map[string]habits.Year)
if b.cfg.QuickHabitsEnabled() {
// We can tolerate missing habits
userHabits, _ = habits.LastWeekHabits(b.fs, b.cfg.Timezone())
_, ok := userHabits[habits.MoodHabit]
if ok {
delete(userHabits, habits.MoodHabit)
}
}
for habit, year := range userHabits {
if completed, _ := year[time.Now().YearDay()]; completed == 1 {
continue
}
cmd := tg.NewCmd(CmdCompleteHabit, []string{habit})
habitsRow = append(habitsRow, tg.NewBtn(habits.Emoji(b.fs, habit), cmd))
}
if len(habitsRow) > 0 {
kb.AddRow(habitsRow)
}
// Adding quick buttons
quickBtns := b.quickBtns()
if len(quickBtns) > 0 {
quickBtnsByRows := slice.Chunk(quickBtns, quickBtnsPerRow)
for _, row := range quickBtnsByRows {
kb.AddRow(row)
}
}
msg := b.homeLabel(shownCount)
err = b.showHTML(msg, &kb)
if err != nil {
return fmt.Errorf("show list: %w", err)
}
return nil
}
func (b *Bot) showLaterTasks(_ []string) error {
var kb tg.Keyboard
// Adding tasks from Later.md
laterChecklistMD, err := b.fs.Read(fs.DirUserRoot, fs.LaterFilename)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("show later: can't read later file: %w", err)
}
if len(laterChecklistMD) != 0 {
tasks := txt.IncompleteChecklistItems(laterChecklistMD)
for _, task := range tasks {
cmd := tg.NewCmd(CmdCompleteChecklistItem, []string{fs.Hash(fs.LaterFilename), fs.Hash(task)})
btn := tg.NewBtn(i18n.AddEmoji(task), cmd)
kb.AddRow(btn)
}
}
kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil)))
msg := b.tr("⏳ Your tasks for <b>later</b>:")
err = b.showHTML(msg, &kb)
if err != nil {
return fmt.Errorf("show list: %w", err)
}
return nil
}
// TODO improve a bit
// msgsCount - how many messages (inbox items) were shown to a user
func (b *Bot) homeLabel(msgsCount ...int) string {
var statusBar string
hasPomodoroInToday := false
todayMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err == nil {
_, completed := txt.ChecklistItems(todayMD)
checked, exists := completed[fs.PomodoroTask]
hasPomodoroInToday = exists && !checked
}
if hasPomodoroInToday {
statusBar = i18n.Emoji(fs.DisplayName(fs.PomodoroTask))
}
tasksCount := 0
if len(msgsCount) > 0 && msgsCount[0] > 0 {
tasksCount += msgsCount[0]
}
if tasksCount == 0 {
statusBar += i18n.Emoji("palm")
}
if len(statusBar) != 0 {
statusBar += " "
}
if tasksCount == 0 {
return statusBar + i18n.Tr("Nothing here yet - send me something!")
}
postfix := "items"
if tasksCount == 1 {
postfix = "item"
}
return statusBar + fmt.Sprintf(i18n.Tr("<b>%d</b> %s%s"), tasksCount, postfix, wideSpacer)
}
func (b *Bot) randomNote(_ []string) error {
rootEntries, err := b.fs.FilesAndDirs(fs.DirUserRoot)
if err != nil {
return fmt.Errorf("random note: can't get root: %w", err)
}
type note struct {
dir, name string
}
var notes []note
for _, dir := range fs.OnlyNoteDirs(fs.OnlyDirs(rootEntries)) {
entries, err := b.fs.FilesAndDirs(dir.Name)
if err != nil {
return fmt.Errorf("random note: can't get files in %s: %w", dir.Name, err)
}
for _, f := range fs.OnlyUserMDFiles(entries) {
notes = append(notes, note{dir: dir.Name, name: f.Name})
}
}
if len(notes) == 0 {
return b.ShowHome(nil)
}
pick := notes[rand.Intn(len(notes))]
return b.showFile([]string{fs.Hash(pick.dir), fs.Hash(pick.name)})
}
func (b *Bot) showFiles(_ []string) error {
files, err := b.fs.FilesAndDirs(fs.DirUserRoot)
if err != nil {
return fmt.Errorf("show files: can't get files: %w", err)
}
var kb tg.Keyboard
mdFiles := fs.ExcludeConfig(fs.OnlyUserMDFiles(files))
var fileBtns []tg.Btn
for _, file := range mdFiles {
cmd := tg.NewCmd(CmdShowFile, []string{fs.DirUserRoot, fs.Hash(file.Name)})
btn := tg.NewBtn(fmt.Sprintf("%s", fs.UnsanitizeFilename(file.DisplayName)), cmd)
fileBtns = append(fileBtns, btn)
}
fileBtnsByRows := slice.Chunk(fileBtns, btnsPerRow)
for _, row := range fileBtnsByRows {
kb.AddRow(row)
}
inlineCmd := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat)
footer := tg.NewRow(tg.NewBtn(i18n.Tr("🔎 Search"), inlineCmd))
if !b.cfg.NotesOnlyMode() {
footer = append(footer, tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil)))
}
kb.AddRow(footer)
err = b.showHTML(b.tr("📄 Your files:")+wideSpacer, &kb)
if err != nil {
return fmt.Errorf("show files: %w", err)
}
return nil
}
func (b *Bot) showDirs(_ []string) error {
files, err := b.fs.FilesAndDirs(fs.DirUserRoot)
if err != nil {
return fmt.Errorf("show dirs: can't get dirs: %w", err)
}
dirs := fs.OnlyNoteDirs(fs.OnlyDirs(files))
var dirBtns []tg.Btn
for _, dir := range dirs {
cmd := tg.NewCustomCmd("", []string{dir.Name}, tg.CmdTypeInlineQueryCurrentChat)
btn := tg.NewBtn(fmt.Sprintf("%s %s", i18n.Emoji("dir"), dir.DisplayName), cmd)
dirBtns = append(dirBtns, btn)
}
var kb tg.Keyboard
dirBtnsByRows := slice.Chunk(dirBtns, btnsPerRow)
for _, row := range dirBtnsByRows {
kb.AddRow(row)
}
inlineCmd := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat)
footer := tg.NewRow(tg.NewBtn(i18n.Tr("🔎 Search"), inlineCmd))
if !b.cfg.NotesOnlyMode() {
footer = append(footer, tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil)))
}
kb.AddRow(footer)
err = b.showHTML(b.tr("🗂 Your dirs:")+wideSpacer, &kb)
if err != nil {
return fmt.Errorf("show dirs: %w", err)
}
return nil
}
func (b *Bot) showChecklists(_ []string) error {
checklists, err := b.fs.FilesAndDirs(fs.DirUserRoot)
if err != nil {
return fmt.Errorf("show checklists: %w", err)
}
checklists = fs.OnlyChecklists(checklists)
var kb tg.Keyboard
for _, checklist := range checklists {
cmd := tg.NewCmd(CmdShowChecklist, []string{fs.Hash(checklist.Name)})
btn := tg.NewBtn(i18n.AddEmoji(checklistTitle(checklist.Name)), cmd)
kb.AddRow(btn)
}
kb.AddRow(tg.NewBtn(b.tr("🏠 Home"), tg.NewCmd(CmdShowHome, nil)))
err = b.showHTML(b.tr("☑️ Checklists"), &kb)
if err != nil {
return fmt.Errorf("show checklists: %w", err)
}
return nil
}
func (b *Bot) showPostpone(_ []string) error {
var kb tg.Keyboard
// Inbox items also show in /postpone so the user can send them to Later.md.
inboxMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err == nil {
for _, block := range readChatMsgs(inboxMD) {
if inboxHeaderRegex.MatchString(block) {
continue
}
if strings.HasPrefix(block, "- [x] ") || strings.HasPrefix(block, "- [X] ") {
continue
}
preview := strings.SplitN(stripInboxEntryPrefix(block), "\n", 2)[0]
if len([]rune(preview)) > maxHeaderLengthForMobile {
preview = string([]rune(preview)[:maxHeaderLengthForMobile]) + "…"
}
cmd := tg.NewCmd(CmdPostpone, []string{chatBlockHash(block)})
kb.AddRow(tg.NewBtn("💬 "+preview, cmd))
}
}
kb.AddRow(tg.NewRow(
tg.NewBtn(b.tr("Rename"), tg.NewCmd(CmdShowRename, []string{})),
tg.NewBtn(b.tr("OK"), tg.NewCmd(CmdShowHome, []string{})),
))
err = b.showHTML(b.tr("🦥 Select a task to postpone:"), &kb)
if err != nil {
return fmt.Errorf("show postpone: %w", err)
}
return nil
}
func (b *Bot) showMoveExisting(_ []string) error {
var kb tg.Keyboard
// Show today inbox items
inboxContent, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err == nil {
blocks := readChatMsgs(inboxContent)
for _, block := range blocks {
if inboxHeaderRegex.MatchString(block) {
continue
}
// Skip already-completed entries — they're about to be swept anyway.
if strings.HasPrefix(block, "- [x] ") || strings.HasPrefix(block, "- [X] ") {
continue
}
preview := strings.SplitN(stripInboxEntryPrefix(block), "\n", 2)[0]
if len([]rune(preview)) > maxHeaderLengthForMobile {
preview = string([]rune(preview)[:maxHeaderLengthForMobile]) + "…"
}
cmd := tg.NewCmd(CmdShowMoveTo, []string{chatBlockHash(block)})
kb.AddRow(tg.NewBtn("💬 "+preview, cmd))
}
}
kb.AddRow(tg.NewRow(
tg.NewBtn(b.tr("Rename"), tg.NewCmd(CmdShowRename, []string{})),
tg.NewBtn(b.tr("OK"), tg.NewCmd(CmdShowHome, []string{})),
))
err = b.showHTML(b.tr("🦥 Select an item to move:"), &kb)
if err != nil {
return fmt.Errorf("show move from today: %w", err)
}
return nil
}
func (b *Bot) postpone(params []string) error {
hash := params[0]
err := b.moveFromChat(func(content string, _ time.Time) error {
laterMD, rerr := b.fs.Read(fs.DirUserRoot, fs.LaterFilename)
if rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
return fmt.Errorf("postpone: can't read later file: %w", rerr)
}
return b.fs.Write(fs.DirUserRoot, fs.LaterFilename, txt.AddChecklistItem(laterMD, content, false))
}, false, hash)
if err != nil {
return fmt.Errorf("postpone: can't move inbox entry to later: %w", err)
}
return b.showPostpone(nil)
}
func (b *Bot) showRename(_ []string) error {
var kb tg.Keyboard
inboxMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err == nil {
for _, block := range readChatMsgs(inboxMD) {
if inboxHeaderRegex.MatchString(block) {
continue
}
if strings.HasPrefix(block, "- [x] ") || strings.HasPrefix(block, "- [X] ") {
continue
}
preview := strings.SplitN(stripInboxEntryPrefix(block), "\n", 2)[0]
if len([]rune(preview)) > maxHeaderLengthForMobile {
preview = string([]rune(preview)[:maxHeaderLengthForMobile]) + "…"
}
cmd := tg.NewCmd(CmdShowRenameFile, []string{fs.ChatFilename, chatBlockHash(block)})
kb.AddRow(tg.NewBtn("💬 "+preview, cmd))
}
}
kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil)))
err = b.showHTML(b.homeLabel(), &kb)
if err != nil {
return fmt.Errorf("show rename: %w", err)
}
return nil
}
func (b *Bot) showRenameFile(params []string) error {
checklist := params[0]
itemHash := params[1]
kb := tg.NewKeyboard([]tg.Row{
tg.NewRow(tg.NewBtn(i18n.StrBack, tg.NewCmd(CmdShowHome, []string{}))),
})
cmd := tg.NewCmd(CmdRename, []string{checklist, itemHash, "%s"})
b.db.SetInputExpectation(cmd)
err := b.showHTML(i18n.Tr("OK. Send me the new name for your task"), kb)
if err != nil {
return fmt.Errorf("show rename: %w", err)
}
return nil
}
func (b *Bot) rename(params []string) error {
checklist := params[0]
itemHash := params[1]
newItemNameFromUserInput := params[2]
md, err := b.fs.Read(fs.DirUserRoot, checklist)
if err != nil {
return fmt.Errorf("rename: can't read checklist %s: %w", checklist, err)
}
if checklist == fs.ChatFilename {
md, err = renameChatMsg(md, itemHash, newItemNameFromUserInput)
if err != nil {
return fmt.Errorf("rename: %w", err)
}
} else {
md, _ = txt.RemoveChecklistItem(md, itemHash)
md = txt.AddChecklistItem(md, newItemNameFromUserInput, false)
}
err = b.fs.Write(fs.DirUserRoot, checklist, md)
if err != nil {
return fmt.Errorf("rename: can't write checklist %s: %w", checklist, err)
}
return b.ShowHome(nil)
}
func (b *Bot) showStats(_ []string) error {
report, err := stats.TodayReport(b.fs, b.db, b.userID)
if err != nil {
return fmt.Errorf("show stats: %w", err)
}
kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))})
err = b.showHTML(strings.TrimSpace(report), kb)
if err != nil {
return fmt.Errorf("show stats: %w", err)
}
return nil
}
func (b *Bot) showSchedule(_ []string) error {
scheduledTasks, err := b.cfg.Schedules()
if err != nil {
return fmt.Errorf("show schedule: %w", err)
}
schedule := ScheduleReport(scheduledTasks)
if len(schedule) == 0 {
schedule = i18n.Tr("You don't have any scheduled tasks! 🌴")
}
kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))})
err = b.showHTML(schedule, kb)
if err != nil {
return fmt.Errorf("show stats: %w", err)
}
return nil
}
func (b *Bot) showRead(_ []string) error {
return b.showChecklist([]string{fs.Hash(fs.ReadFilename)})
}
func (b *Bot) showWatch(_ []string) error {
return b.showChecklist([]string{fs.Hash(fs.WatchFilename)})
}
func (b *Bot) showShop(_ []string) error {
return b.showChecklist([]string{fs.Hash(fs.ShopFilename)})
}
// TODO Chat.md move to today/later
func (b *Bot) showLongItemFromChecklist(params []string) error {
checklistHash := params[0]
itemHash := params[1]
checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash)
if err != nil {
return fmt.Errorf("complete checklist item: can't unhash checklist %s: %w", checklistHash, err)
}
checklistMD, err := b.fs.Read(fs.DirUserRoot, checklist)
if err != nil {
return fmt.Errorf("complete checklist item: can't read checklist %s: %w", checklist, err)
}
item := txt.ChecklistItem(checklistMD, itemHash)
cmd := CmdShowHome
if checklist == fs.LaterFilename {
cmd = CmdShowLater
}
kb := tg.NewKeyboard([]tg.Row{
tg.NewRow(
tg.NewBtn(i18n.StrBack, tg.NewCmd(cmd, []string{})),
tg.NewBtn(i18n.StrComplete, tg.NewCmd(CmdCompleteChecklistItem, []string{checklistHash, itemHash})),
),
})
err = b.showMD(item, kb)
if err != nil {
return fmt.Errorf("show task: %w", err)
}
return nil
}
func (b *Bot) showLongItem(params []string) error {
msgHash := params[0]
chatMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err != nil {
return fmt.Errorf("show long item: can't read inbox file: %w", err)
}
_, block, ok := findChatMsgByHash(chatMD, msgHash)
if !ok {
return fmt.Errorf("show long item: msgHash %q not found in inbox", msgHash)
}
// Strip optional `- [ ]` / `- [x] ` prefix + backtick-timestamp prefix.
prefixRegex := regexp.MustCompile(`^(?:- \[[ xX]\] )?` + "`" + `\d{2}:\d{2}` + "`" + ` `)
if loc := prefixRegex.FindStringIndex(block); loc != nil {
block = strings.TrimSpace(block[loc[1]:])
}
kb := tg.NewKeyboard([]tg.Row{
tg.NewRow(
tg.NewBtn(i18n.StrBack, tg.NewCmd(CmdShowHome, []string{})),
tg.NewBtn(i18n.AddEmoji("Move"), tg.NewCmd(CmdShowMoveTo, []string{msgHash})),
tg.NewBtn(txt.Emoji(i18n.Emoji("Archive"), "Complete"), tg.NewCmd(CmdComplete, []string{msgHash})),
),
})
if err := b.showMD(block, kb); err != nil {
return fmt.Errorf("show long item from inbox: %w", err)
}
// Track msgID → msgHash so a user reply to this rendered chat item
// gets routed back to it via addToRepliedFile's chat-item branch.
msgID, hasLastKeyboard := b.db.LastKeyboardMsgID()
if hasLastKeyboard {
b.db.SetHashOrPathByMsgID(msgID, "#"+msgHash)
}
return nil
}
func (b *Bot) showFile(params []string) error {
dirHash := params[0]
filenameHash := params[1]
dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash)
if err != nil {
return fmt.Errorf("show file: can't find dir: %w", err)
}
// Inline-search results pass a nested path like
// "habits/insights/2023 Habits.md" as the second param. Unhash only
// resolves immediate children of a dir, so for any value containing
// "/" we take it as a literal relative path.
var filename string
if strings.Contains(filenameHash, "/") {
filename = filenameHash
} else {
filename, err = b.fs.Unhash(dir, filenameHash)
if err != nil {
return fmt.Errorf("show file: can't find file: %w", err)
}
}
content, err := b.fs.Read(dir, filename)
if err != nil {
return fmt.Errorf("show file: %w", err)
}
isNotesDir := len(fs.OnlyNoteDirs([]fs.File{{Name: dir}})) > 0
row := tg.NewRow()
if isNotesDir {
inlineCmd := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat)
row = append(row, tg.NewBtn(i18n.Tr("🔎 Search"), inlineCmd))
hasChannelsToPrint := len(b.cfg.Channels()) > 0
if hasChannelsToPrint {
cmd := tg.NewCmd(CmdShare, []string{fs.Hash(dir), fs.Hash(filename)})
row = append(row, tg.NewBtn(i18n.Tr("🖨 Share"), cmd))
}
}
row = append(row, tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil)))
kb := tg.NewKeyboard([]tg.Row{row})
md := fmt.Sprintf("**%s**\n\n%s", fs.DisplayName(filename), content)
err = b.showMD(md, kb)
if err != nil {
return fmt.Errorf("show file: %w", err)
}
msgID, hasLastKeyboard := b.db.LastKeyboardMsgID()
if hasLastKeyboard {
b.db.SetHashOrPathByMsgID(msgID, filepath.ToSlash(filepath.Join(dir, filename)))
}
return nil
}
func (b *Bot) showChecklist(params []string) error {
checklistHash := params[0]
checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash)
if err != nil {
return fmt.Errorf("show checklist: %w", err)
}
md, err := b.fs.Read(fs.DirUserRoot, checklist)
if err != nil {
return fmt.Errorf("show checklist: %w", err)
}
items := txt.IncompleteChecklistItems(md)
maxButtons := maxBtns
if checklist == fs.ReadFilename || checklist == fs.WatchFilename {
maxButtons = maxBtnsInChecklist
}
if len(items) > maxButtons {
items = items[:maxButtons]
}
kb := tg.NewKeyboard(nil)
for _, item := range items {
if len([]rune(item)) >= maxHeaderLengthForMobile {
cmd := tg.NewCmd(CmdShowLongItemFromChecklist, []string{fs.Hash(checklist), fs.Hash(item)})
btn := tg.NewBtn(txt.Emoji(i18n.Emoji("eyes"), item), cmd)
kb.AddRow(btn)
} else {
cmd := tg.NewCmd(CmdCompleteChecklistItem, []string{checklistHash, fs.Hash(item)})
btn := tg.NewBtn(i18n.AddEmoji(item), cmd)
kb.AddRow(btn)
}
}
kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil)))
title := checklistTitle(checklist)
err = b.showHTML(title+wideSpacer, kb)
if err != nil {
return fmt.Errorf("show checklist: %w", err)
}
return nil
}
func (b *Bot) showStart(params []string) error {
// For now it is not used
if len(params) > 0 {
mode := strings.ToLower(params[0])
if mode == "notes" {
return b.setNotesOnlyMode(nil)
} else if mode == "tasks" {
return b.setTasksOnlyMode(nil)
} else if mode == "journal" {
return b.setJournalOnlyMode(nil)
} else if mode == "full" {
return b.setFullMode(nil)
}
}
// We can tolerate an error.
_ = b.setFullMode(nil)
_, err := b.tg.Send(b.userID, "Welcome! 👋\n\n<b>Send me anything, and Ill save it to files!</b>\n\nClick /app to connect the web app.\n\nBy default <b>Full Mode</b> is enabled, which can feel a bit overwhelming. You can switch to <b>Notes Only</b> or <b>Tasks Only</b> mode in /settings menu.", nil, tg.MarkupHTML)
return err
}
func (b *Bot) moveToDir(params []string) error {
// TODO Remove input expectations if dir is not today
toDirHash := params[0]
msgHashes := strings.Split(params[1], ",")
toDir, err := b.fs.Unhash(fs.DirUserRoot, toDirHash)
canCreateMissingDir := slices.Contains([]string{fs.DirArchive, fs.DirHabits}, toDirHash)
if err != nil {
if canCreateMissingDir {
// It will be created later in createOrAdd.
toDir = toDirHash
} else {
return fmt.Errorf("move: can't unhash new dir %s: %w", toDir, err)
}
}
err = b.moveFromChat(func(content string, timestamp time.Time) error {
var sanitizedTitle string
sanitizedTitle, content, err = b.extractHeaderAndBody(content, maxHeaderLength)
if err != nil {
return fmt.Errorf("move to dir from chat: can't extract title and content: %w", err)
}
filename := fs.Filename(sanitizedTitle)
notesDir := fs.OnlyNoteDirs([]fs.File{{Name: toDir}})
isNotesDir := len(notesDir) == 1
if isNotesDir {
// We can tolerate this, as this is informative logging
_ = journal.AddRecord(b.fs, fmt.Sprintf("📌 %s", fs.DisplayName(filename)), b.cfg.Timezone())
}
return b.createOrAdd(toDir, filename, content)
}, true, msgHashes...)
b.delAllKeyboards()
msg := txt.Emoji(i18n.Emoji("dir"), fmt.Sprintf(i18n.Tr("Moved to <b>%s</b>"), fs.DisplayName(toDir)))
// Just an informative messages
_, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
func (b *Bot) moveToChecklist(params []string) error {
toChecklistHash := params[0]
msgHashes := strings.Split(params[1], ",")
for _, msgHash := range msgHashes {
_, err := b.addToChecklist(toChecklistHash, msgHash)
if err != nil {
return fmt.Errorf("move to checklist: can't add to checklist: %w", err)
}
}
return b.ShowHome(nil)
}
func (b *Bot) addToChecklist(checklistHash string, msgHash string) (string, error) {
checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash)
// Create known checklist if it doesn't exist
if err != nil {
supportedChecklists := []string{
fs.ChatFilename,
fs.LaterFilename,
fs.ReadFilename,
fs.WatchFilename,
fs.ShopFilename,
}
created := false
for _, supportedChecklist := range supportedChecklists {
if fs.Hash(supportedChecklist) == checklistHash || supportedChecklist == checklistHash {
checklist = supportedChecklist
err = b.fs.Write(fs.DirUserRoot, checklist, "")
if err != nil {
return "", fmt.Errorf("add to checklist: can't create checklist %s: %w", checklist, err)
}
created = true
break
}
}
if !created {
return "", fmt.Errorf("add to checklist: can't unhash checklist %s: %w", checklistHash, err)
}
}
checklistMD, err := b.fs.Read(fs.DirUserRoot, checklist)
if err != nil {
return "", fmt.Errorf("add to checklist: can't read checklist %s: %w", checklist, err)
}
var item string
err = b.moveFromChat(func(content string, timestamp time.Time) error {
item = content
md := txt.AddChecklistItem(checklistMD, content, false)
return b.fs.Write(fs.DirUserRoot, checklist, md)
}, true, msgHash)
if err != nil {
return "", fmt.Errorf("move to checklist: can't move from chat: %w", err)
}
return item, nil
}
func (b *Bot) completeChecklistItem(params []string) error {
checklistHash := params[0]
itemHash := params[1]
checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash)
if err != nil {
return fmt.Errorf("complete checklist item: can't unhash checklist %s: %w", checklistHash, err)
}
checklistMD, err := b.fs.Read(fs.DirUserRoot, checklist)
if err != nil {
return fmt.Errorf("complete checklist item: can't read checklist %s: %w", checklist, err)
}
md, item := txt.CompleteChecklistItem(checklistMD, itemHash)
err = b.fs.Write(fs.DirUserRoot, checklist, md)
if err != nil {
return fmt.Errorf("complete checklist item: can't complete item from chat: %w", err)
}
// We can tolerate failure of writing to journal, since that's not single source of truth.
// AddRecord prepends a fresh `HH:MM`; strip any leading timestamp on
// the item body so we don't end up with two of them.
_ = journal.AddRecord(b.fs, fmt.Sprintf("✅ %s", fs.DisplayName(txt.StripChatTimestamp(item))), b.cfg.Timezone())
if checklist == fs.LaterFilename {
return b.showLaterTasks(nil)
} else if checklist != fs.ChatFilename {
return b.showChecklist([]string{checklist})
}
return b.ShowHome(nil)
}
func (b *Bot) requestNewDirName(params []string) error {
filenameHash := params[0]
err := b.showHTML(i18n.Tr("OK. Send me the name for your new dir"), nil)
if err != nil {
return fmt.Errorf("request new dir: %w", err)
}
b.db.SetInputExpectation(tg.NewCmd(CmdMoveToNewDir, []string{filenameHash, "%s"}))
return nil
}
// moveToNewDir accepts dir name as a second parameter
// which is a bit off, but the thing is sometimes it is replaced with
// inputExpectation, which only can add parameters in the end.
func (b *Bot) moveToNewDir(params []string) error {
msgIndicesStr := params[0]
dir := strings.ToLower(fs.SanitizeFilename(params[1]))
exists, err := b.fs.Exists(fs.DirUserRoot, dir)
if err != nil {
return fmt.Errorf("move to new dir from caht: %w", err)
}
if !exists {
err = b.fs.MakeDir(dir)
if err != nil {
return fmt.Errorf("move to new dir from chat: %w", err)
}
}
return b.moveToDir([]string{dir, msgIndicesStr})
}
// TODO reuse move to existing note as more general?
func (b *Bot) moveToExistingFile(params []string) error {
// TODO Remove input expectations if dir is not today (?)
existingFilenameHash := params[0]
msgHashes := strings.Split(params[1], ",")
existingFilename, err := b.fs.Unhash(fs.DirUserRoot, existingFilenameHash)
if err != nil {
return fmt.Errorf("move to file: can't unhash existing file '%s': %w", existingFilenameHash, err)
}
err = b.moveFromChat(func(content string, timestamp time.Time) error {
return b.addToFile(fs.DirUserRoot, existingFilename, content)
}, true, msgHashes...)
if err != nil {
return fmt.Errorf("move to file: can't add to existing file '%s': %w", existingFilename, err)
}
b.db.SetRecentCommand(CmdMoveToExistingFile)
b.db.SetRecentCommandParams([]string{fs.ShortHash(existingFilename)})
b.delAllKeyboards()
msg := txt.Emoji(i18n.Emoji("file"), fmt.Sprintf(i18n.Tr("Saved to <b>%s</b>"), fs.DisplayName(existingFilename)))
// Just an informative messages
_, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
func (b *Bot) moveToExistingNote(params []string) error {
toFilenameHash := params[0]
toDirHash := params[1]
msgHashes := strings.Split(params[2], ",")
var toDir string
if toDirHash == "" {
toDir = fs.DirUserRoot
} else {
var err error
toDir, err = b.fs.Unhash(fs.DirUserRoot, toDirHash)
if err != nil {
return fmt.Errorf("move to existing note: %w", err)
}
}
toFilename, err := b.fs.Unhash(toDir, toFilenameHash)
if err != nil {
return fmt.Errorf("move to existing note:: %w", err)
}
err = b.moveFromChat(func(content string, t time.Time) error {
err = b.addToFile(toDir, toFilename, content)
if err != nil {
return fmt.Errorf("move to existing note: can't add to file %s: %w", toFilename, err)
}
b.db.SetRecentCommand(CmdMoveToExistingNote)
b.db.SetRecentCommandParams([]string{fs.ShortHash(toFilename), fs.ShortHash(toDir)})
return nil
}, false, msgHashes...)
if err != nil {
return fmt.Errorf("move to existing note: can't read content from chat: %w", err)
}
b.delAllKeyboards()
msg := txt.Emoji(i18n.Emoji("file"), fmt.Sprintf(i18n.Tr("Saved to <b>%s</b>"), fs.DisplayName(toFilename)))
// Just an informative messages
_, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
func (b *Bot) moveToDirChecklist(params []string) error {
msgHashes := strings.Split(params[0], ",")
checklistDirHash := params[1]
checklistDir, err := b.fs.Unhash(fs.DirUserRoot, checklistDirHash)
if err != nil {
return fmt.Errorf("move to checklistDir: %w", err)
}
err = b.moveFromChat(func(content string, t time.Time) error {
isMultiline := txt.IsMultiline(content)
if isMultiline && b.cfg.ShouldSplitChecklist(checklistDir) {
content = strings.TrimSpace(txt.NormNewLines(content))
lines := strings.Split(content, "\n")
for _, line := range lines {
line = fs.SanitizeFilename(line)
err = b.fs.Write(checklistDir, fs.Filename(line), "")
if err != nil {
return fmt.Errorf("move to checklistDir: %w", err)
}
}
} else {
sanitizedTitle, content, err := b.extractHeaderAndBody(content, maxHeaderLengthForMobile)
if err != nil {
return fmt.Errorf("move to checklistDir: %w", err)
}
filename := fs.Filename(sanitizedTitle)
return b.fs.Write(checklistDir, filename, content)
}
return nil
}, false, msgHashes...)
if err != nil {
return fmt.Errorf("move to checklistDir: can't read content from chat: %w", err)
}
return b.ShowHome(nil)
}
func (b *Bot) moveToRead(params []string) error {
msgIndices := params[0]
return b.moveToChecklist([]string{fs.Hash(fs.ReadFilename), msgIndices})
}
func (b *Bot) moveToWatch(params []string) error {
msgIndices := params[0]
return b.moveToChecklist([]string{fs.Hash(fs.WatchFilename), msgIndices})
}
func (b *Bot) moveToShop(params []string) error {
msgIndices := params[0]
return b.moveToChecklist([]string{fs.Hash(fs.ShopFilename), msgIndices})
}
func (b *Bot) moveToNewFile(params []string) error {
msgHash := params[0]
newFilenameFromUserInput := fs.Filename(params[1])
//filename, err := b.fs.Unhash(fs.DirUserRoot, msgIndex)
//if err != nil {
// return fmt.Errorf("move to new file: can't unhash existing file '%s': %w", msgIndex, err)
//}
//
//// Save existing filename to content in case the content of new file is empty (i.e. not multiline)
//content, err := b.fs.Read(fs.DirUserRoot, filename)
//if err != nil {
// return fmt.Errorf("move to new file: can't read file '%s': %w", filename, err)
//}
err := b.moveFromChat(func(content string, t time.Time) error {
content = strings.TrimSpace(content)
//if len(content) == 0 {
// content = fs.DisplayName(filename)
// err = b.fs.Write(fs.DirUserRoot, filename, content)
// if err != nil {
// return fmt.Errorf("move to new file: can't write content of '%s': %w", filename, err)
// }
//}
// TODO check for safety
// TODO won't we lost some text here in case of multiline?
//err = b.fs.Rename(fs.DirUserRoot, filename, fs.DirUserRoot, newFilenameFromUserInput)
//if err != nil {
// return fmt.Errorf("move to new file: can't create empty file: %w", err)
//}
// We can tolerate this
//_ = journal.AddRecord(b.fs, fmt.Sprintf("📄 %s", fs.DisplayName(filename)), b.cfg.Timezone())
b.db.SetRecentCommand(CmdMoveToExistingFile)
b.db.SetRecentCommandParams([]string{fs.ShortHash(newFilenameFromUserInput)})
// TODO add if exists
return b.fs.Write(fs.DirUserRoot, newFilenameFromUserInput, content)
}, false, msgHash)
if err != nil {
return fmt.Errorf("move to new file: can't read content from chat: %w", err)
}
msg := txt.Emoji(i18n.Emoji("file"), fmt.Sprintf(i18n.Tr("Saved to <b>%s</b>"), fs.DisplayName(newFilenameFromUserInput)))
_, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
func (b *Bot) moveToNewChecklist(params []string) error {
msgHash := params[0]
supposedName := params[1]
supposedName = fs.SanitizeFilename(supposedName)
dir := strings.ToLower(supposedName)
dir = fmt.Sprintf("_%s_", dir)
exists, err := b.fs.Exists(fs.DirUserRoot, dir)
if err != nil {
return fmt.Errorf("move to new checklist: %w", err)
}
if !exists {
err = b.fs.MakeDir(dir)
}
return b.moveToDir([]string{dir, msgHash})
}
func (b *Bot) moveToJournal(params []string) error {
msgHashes := params
err := b.moveFromChat(func(content string, t time.Time) error {
// TODO take into account time from chat
return journal.AddRecord(b.fs, content, b.cfg.Timezone())
}, false, msgHashes...)
if err != nil {
return fmt.Errorf("failed to move to journal: can't add record: %w", err)
}
b.delAllKeyboards()
msg := txt.Emoji(i18n.Emoji("journal"), i18n.Tr("Saved to <b>journal</b>"))
_, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML)
if b.cfg.JournalOnlyMode() {
return nil
}
return b.ShowHome(nil)
}
func (b *Bot) addToJournalAndContinue(params []string) error {
content := params[0]
err := journal.AddRecord(b.fs, content, b.cfg.Timezone())
if err != nil {
return fmt.Errorf("failed to move to journal: can't add note: %w", err)
}
// Don't return - continue to save to inbox as well.
msgHash, err := b.appendToChat(content, b.cfg.Timezone())
if err != nil {
return fmt.Errorf("save to inbox: %w", err)
}
return b.showMoveTo([]string{msgHash})
}
func (b *Bot) addToJournalFromShortcut(params []string) error {
content := params[0]
// TODO change to pass text
err := journal.AddRecord(b.fs, content, b.cfg.Timezone())
if err != nil {
return fmt.Errorf("failed to move to journal: can't add note: %w", err)
}
msg := i18n.Tr("Saved to <b>Journal</b>")
_, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
// TODO add tests
func (b *Bot) addToRecentFileOrNoteFromShortcut(params []string) error {
content := params[0]
args, _ := b.db.RecentCommandParams()
cmd, _ := b.db.RecentCommand()
var existingFilename string
if cmd == CmdMoveToExistingFile {
var err error
existingFilename, err = b.fs.Unhash(fs.DirUserRoot, args[0])
if err != nil {
return fmt.Errorf("failed to move to recent file or note: can't unhash filename: %w", err)
}
err = b.addToFile(fs.DirUserRoot, existingFilename, content)
if err != nil {
return fmt.Errorf("failed to move to recent file: can't add note: %w", err)
}
} else if cmd == CmdMoveToExistingNote {
dir, err := b.fs.Unhash(fs.DirUserRoot, args[1])
if err != nil {
return fmt.Errorf("failed to move to recent note: can't unhash dir: %w", err)
}
existingFilename, err = b.fs.Unhash(dir, args[0])
if err != nil {
return fmt.Errorf("failed to move to recent note: can't unhash filename: %w", err)
}
err = b.addToFile(dir, existingFilename, content)
if err != nil {
return fmt.Errorf("failed to move to recent note: can't add note: %w", err)
}
} else {
return nil
}
msg := fmt.Sprintf(i18n.Tr("Added to <b>%s</b>"), fs.DisplayName(existingFilename))
_, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
func (b *Bot) moveToLater(params []string) error {
msgHash := params[0]
return b.moveToChecklist([]string{fs.LaterFilename, msgHash})
}
// complete marks a single Chat.md entry as done ("- [ ]" => "- [x]").
func (b *Bot) complete(params []string) error {
msgHash := params[0]
key, err := b.fs.SafePath(fs.DirUserRoot, "")
if err != nil {
return fmt.Errorf("complete: %w", err)
}
lock := userLock(key)
lock.Lock()
defer lock.Unlock()
content, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err != nil {
return fmt.Errorf("complete: can't read inbox: %w", err)
}
newContent, item := txt.CompleteChecklistItem(content, msgHash)
if item == "" {
return b.ShowHome(nil)
}
if err := b.fs.Write(fs.DirUserRoot, fs.ChatFilename, newContent); err != nil {
return fmt.Errorf("complete: can't write inbox: %w", err)
}
if item == fs.PomodoroTask {
err = b.cfg.AddToSchedule(fs.PomodoroTask, time.Now().Unix()+int64(b.cfg.PomodoroDuration().Seconds()), "")
if err != nil {
return fmt.Errorf("complete: can't add pomodoro to schedule: %w", err)
}
}
return b.ShowHome(nil)
}
func (b *Bot) completeListItem(params []string) error {
dirHash := params[0]
filenameHash := params[1]
dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash)
if err != nil {
return fmt.Errorf("complete: can't unhash dir %s: %w", dir, err)
}
filename, err := b.fs.Unhash(dir, filenameHash)
if err != nil {
return fmt.Errorf("complete: can't unhash filename %s: %w", filename, err)
}
if err = b.fs.Touch(dir, filename); err != nil {
return fmt.Errorf("complete: can't touch %s: %w", filename, err)
}
err = b.fs.Rename(dir, filename, fs.DirArchive, filename)
if err != nil {
return fmt.Errorf("complete: can't complete %s: %w", filename, err)
}
return b.showChecklist([]string{dirHash})
}
func (b *Bot) showChecklistItem(params []string) error {
dirHash := params[0]
filenameHash := params[1]
dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash)
if err != nil {
return fmt.Errorf("show checklist item: can't unhash dir %s: %w", dir, err)
}
filename, err := b.fs.Unhash(dir, filenameHash)
if err != nil {
return fmt.Errorf("show checklist item: can't unhash filename %s: %w", filename, err)
}
content, err := b.fs.Read(dir, filename)
if err != nil {
return fmt.Errorf("show checklist item: can't read content of %s: %w", filename, err)
}
kb := tg.NewKeyboard([]tg.Row{
tg.NewRow(
tg.NewBtn(i18n.StrBack, tg.NewCmd(CmdShowChecklist, []string{dirHash})),
tg.NewBtn(i18n.StrComplete, tg.NewCmd(CmdCompleteListItem, []string{dirHash, filenameHash})),
),
})
err = b.showHTML(content, kb)
if err != nil {
return fmt.Errorf("show checklist item: %w", err)
}
msgID, hasLastKeyboard := b.db.LastKeyboardMsgID()
if hasLastKeyboard {
b.db.SetHashOrPathByMsgID(msgID, filepath.ToSlash(filepath.Join(dir, filename)))
}
return nil
}
func (b *Bot) schedule(params []string) error {
msgHash := params[0]
timeStr := params[1]
cron := params[2]
scheduleTime, err := strconv.ParseInt(timeStr, 10, 64)
if err != nil {
return fmt.Errorf("schedule: can't parse timestamp: %w", err)
}
item, err := b.addToChecklist(fs.LaterFilename, msgHash)
if err != nil {
return fmt.Errorf("schedule: can't move to later: %w", err)
}
err = b.cfg.AddToSchedule(item, scheduleTime, cron)
if err != nil {
return fmt.Errorf("schedule: can't add to schedule: %w", err)
}
return b.ShowHome(nil)
}
func (b *Bot) scheduleForTmrw(params []string) error {
return b.schedule([]string{params[0], txt.I64(Tomorrow()), ""})
}
func (b *Bot) delAllKeyboards() {
var msgIDs []int
mid, hasLastKeyboard := b.db.LastKeyboardMsgID()
if hasLastKeyboard {
b.db.DelLastKeyboardMsgID()
msgIDs = append(msgIDs, mid)
}
// No worries if we fail - it will be cleaned up by the worker
for _, msgID := range msgIDs {
// If we fail to del - user would get a bunch
// of keyboards in one chat, which is messy but not critical
_ = b.tg.Del(b.userID, msgID)
}
}
func (b *Bot) delAllImages() {
mids, hasSentImages := b.db.ImgMsgID()
if !hasSentImages {
return
}
b.db.DelImgMsgID()
for _, mid := range mids {
// If we fail to del - user would get a bunch
// of keyboards in one chat, which is messy but not critical
_ = b.tg.Del(b.userID, mid)
}
}
func (b *Bot) showToADay(params []string) error {
filenameHash := params[0]
kb, err := b.toADayKeyboard(filenameHash)
if err != nil {
return fmt.Errorf("show for a day: %w", err)
}
err = b.showHTML(i18n.Tr("Choose a day"), kb)
if err != nil {
return fmt.Errorf("show for a day: %w", err)
}
return nil
}
func (b *Bot) toADayKeyboard(filenameHash string) (*tg.Keyboard, error) {
newBtn := func(name, cron string) tg.Btn {
return tg.NewBtn(name, tg.NewCmd(CmdSchedule, []string{filenameHash, txt.I64(NextExcludeToday(cron)), ""}))
}
kb := tg.NewKeyboard([]tg.Row{
tg.NewRow(tg.NewBtn(i18n.StrRepeat, tg.NewCmd(CmdShowScheduleForDayRecurring, []string{filenameHash}))),
tg.NewRow(
newBtn(i18n.StrMonday, "0 0 * * 1"),
newBtn(i18n.StrTuesday, "0 0 * * 2"),
newBtn(i18n.StrWednesday, "0 0 * * 3"),
newBtn(i18n.StrThursday, "0 0 * * 4"),
),
tg.NewRow(
newBtn(i18n.StrFriday, "0 0 * * 5"),
newBtn(i18n.StrSaturday, "0 0 * * 6"),
newBtn(i18n.StrSunday, "0 0 * * 0"),
),
})
for _, iAndj := range [][]int{{1, 8}, {9, 16}, {17, 24}, {25, 31}} {
row := tg.NewRow()
for i := iAndj[0]; i <= iAndj[1]; i++ {
cron := fmt.Sprintf("0 0 %d * *", i)
row = append(row, newBtn(txt.I64(int64(i)), cron))
}
kb.AddRow(row)
}
kb.AddRow(tg.NewBtn(i18n.StrToToday, tg.NewCmd(CmdShowHome, nil)))
return kb, nil
}
func (b *Bot) showMoveToFileOrDir(params []string) error {
msgHash := params[0]
maxRecentBtns := maxGroupedBtnsInMoveTo
userWantedAllBtns := len(params) > 1
if userWantedAllBtns {
maxRecentBtns = maxBtns
} else {
//b.db.SetRecentCommand(CmdMoveToExistingFile)
//b.db.SetRecentCommandParams([]string{fs.ShortHash(filename), fs.ShortHash(fs.DirToday)})
}
kb := tg.NewKeyboard(nil)
skippedBtns := false
//fileBtns, err := b.moveToFileBtns(fs.ShortHash(filename))
fileBtns, err := b.moveToFileBtns(msgHash)
if err != nil {
return fmt.Errorf("to file dialog: %w", err)
}
if len(fileBtns) > maxRecentBtns {
fileBtns = fileBtns[:maxRecentBtns]
skippedBtns = true
}
// Move newly created file to the end of the files list
if len(fileBtns) > 0 {
fileBtns = append(fileBtns[1:], fileBtns[0])
}
fileBtnsByRows := slice.Chunk(fileBtns, btnsPerRow)
for _, row := range fileBtnsByRows {
kb.AddRow(row)
}
dirBtns, err := b.moveToDirBtns(msgHash)
if err != nil {
return fmt.Errorf("to file dialog: %w", err)
}
if len(dirBtns) > maxRecentBtns {
dirBtns = dirBtns[:maxRecentBtns]
skippedBtns = true
}
// Add "New Dir" to the end of the dirs list
// TODO add tests for all these cases
if len(dirBtns) == maxRecentBtns {
// Free up space for the new dir button
dirBtns = dirBtns[:len(fileBtns)-1]
}
btn := tg.NewBtn("🗂 New Dir", tg.NewCmd(CmdRequestNewDir, []string{msgHash}))
dirBtns = append(dirBtns, btn)
//shouldAddSeparator := len(fileBtns) > 0
//if shouldAddSeparator {
searchCMD := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat)
kb.AddRow(tg.NewBtn(i18n.Tr("Search"), searchCMD))
//}
dirBtnsByRows := slice.Chunk(dirBtns, btnsPerRow)
for _, row := range dirBtnsByRows {
kb.AddRow(row)
}
if skippedBtns {
kb.AddRow(tg.NewBtn(i18n.Tr("More..."), tg.NewCmd(CmdShowMoveToDirOrFile, []string{msgHash, "full"})))
}
b.db.SetInputExpectation(tg.NewCmd(CmdMoveToNewFile, []string{msgHash, "%s"}))
err = b.showHTML("📄 Select where to save or send a new name:", kb)
if err != nil {
return fmt.Errorf("to file dialog: %w", err)
}
return nil
}
func (b *Bot) showToChecklist(params []string) error {
filenameHash := params[0]
kb, err := b.toChecklistKeyboard(filenameHash)
if err != nil {
return fmt.Errorf("show to checklist: can't get keyboard: %w", err)
}
b.db.SetInputExpectation(tg.NewCmd(CmdMoveToNewChecklist, []string{filenameHash, "%s"}))
err = b.showHTML(i18n.Tr("Choose a checklist or name a new one"), kb)
if err != nil {
return fmt.Errorf("show to checklist: %w", err)
}
return nil
}
func (b *Bot) moveToFileBtns(msgHash string) ([]tg.Btn, error) {
files, err := b.fs.FilesAndDirs(fs.DirUserRoot)
if err != nil {
return nil, fmt.Errorf("to doc keyboard: %w", err)
}
files = fs.OnlyUserMDFiles(files)
files = fs.SortByCtimeDesc(files)
if len(files) == 0 {
return nil, nil
}
var buttons []tg.Btn
newBtn := func(title, existingFilenameHash string) tg.Btn {
title = fmt.Sprintf("%s", title)
params := []string{existingFilenameHash, msgHash}
return tg.NewBtn(title, tg.NewCmd(CmdMoveToExistingFile, params))
}
for _, file := range files {
buttons = append(buttons, newBtn(file.DisplayName, fs.ShortHash(file.Name)))
}
return buttons, nil
}
func (b *Bot) moveToDirBtns(msgHash string) ([]tg.Btn, error) {
newBtn := func(dir string) tg.Btn {
emojifiedDir := fmt.Sprintf("%s %s", i18n.Emoji("dir"), txt.Ucfirst(dir))
return tg.NewBtn(emojifiedDir, tg.NewCmd(CmdMoveToExistingDir, []string{fs.ShortHash(dir), msgHash}))
}
dirs, err := b.fs.FilesAndDirs(fs.DirUserRoot)
if err != nil {
return nil, fmt.Errorf("To File keyboard: %w", err)
}
dirs = fs.OnlyNoteDirs(fs.OnlyDirs(dirs))
dirs = fs.SortByCtimeDesc(dirs)
var buttons []tg.Btn
for _, dir := range dirs {
buttons = append(buttons, newBtn(dir.Name))
}
return buttons, nil
}
func (b *Bot) toChecklistKeyboard(filenameHash string) (*tg.Keyboard, error) {
newBtn := func(dir, title string) tg.Btn {
return tg.NewBtn(title, tg.NewCmd(CmdMoveToDirChecklist, []string{filenameHash, dir}))
}
dirs, err := b.fs.FilesAndDirs(fs.DirUserRoot)
if err != nil {
return nil, fmt.Errorf("to checklist keyboard: %w", err)
}
// TODO handle case with zero dirs (inline_keyboard is null), for all similar cases
dirs = fs.OnlyChecklists(fs.OnlyDirs(dirs))
kb := tg.NewKeyboard(nil)
for _, dir := range dirs {
kb.AddRow(newBtn(dir.Name, dir.DisplayName))
}
return kb, nil
}
func (b *Bot) togglePomodoro(_ []string) error {
// Check if Pomodoro is already running
hasPomodoroInToday := false
todayMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename)
if err == nil {
_, isCompleted := txt.ChecklistItems(todayMD)
_, hasPomodoroInToday = isCompleted[fs.PomodoroTask]
}
if hasPomodoroInToday {
todayMD, _ = txt.RemoveChecklistItem(todayMD, fs.PomodoroTask)
err = b.fs.Write(fs.DirUserRoot, fs.ChatFilename, todayMD)
if err != nil {
return fmt.Errorf("toggle pomodoro: failed to delete pomodoro file: %w", err)
}
}
if hasPomodoroInToday {
_, _ = b.tg.Send(b.userID, "Pomodoro is stopped", nil, tg.MarkupHTML)
return b.ShowHome(nil)
}
// Create Pomodoro checklist item
err = b.fs.Write(fs.DirUserRoot, fs.ChatFilename, txt.AddChecklistItem(todayMD, fs.PomodoroTask, false))
_, err = b.tg.Send(b.userID, i18n.PomodoroStarted, nil, tg.MarkupHTML)
if err != nil {
return fmt.Errorf("toggle pomodoro: failed to show pomodoro hint message %w", err)
}
return b.ShowHome(nil)
}
func (b *Bot) showToADayRecurring(params []string) error {
filenameHash := params[0]
newBtn := func(name, cron string) tg.Btn {
// We need to shorten filehash, otherwise whole payload doesn't fit telegram's restrictions (64 bytes)
cmd := tg.NewCmd(CmdSchedule, []string{txt.Substr(filenameHash, 0, 4), txt.I64(NextExcludeToday(cron)), cron})
return tg.NewBtn(name, cmd)
}
kb := tg.NewKeyboard([]tg.Row{
// Cron format: Minute Hour DayOfMonth Month DayOfWeek
tg.NewRow(
newBtn(i18n.StrWeekdays, "0 0 * * 1-5"),
newBtn(i18n.StrEveryday, "0 0 * * *"),
),
tg.NewRow(
newBtn(i18n.StrMonday, "0 0 * * 1"),
newBtn(i18n.StrTuesday, "0 0 * * 2"),
newBtn(i18n.StrWednesday, "0 0 * * 3"),
newBtn(i18n.StrThursday, "0 0 * * 4"),
),
tg.NewRow(
newBtn(i18n.StrFriday, "0 0 * * 5"),
newBtn(i18n.StrSaturday, "0 0 * * 6"),
newBtn(i18n.StrSunday, "0 0 * * 0"),
),
})
for week := 0; week < 4; week++ {
row := tg.NewRow()
for day := 1; day < 8; day++ {
i := week*7 + day
cron := fmt.Sprintf("0 0 %d * *", i)
row = append(row, newBtn(txt.I64(int64(i)), cron))
}
kb.AddRow(row)
}
kb.AddRow(tg.NewBtn(i18n.StrToToday, tg.NewCmd(CmdShowHome, nil)))
err := b.showHTML(i18n.Tr("Repeat the task"), kb)
if err != nil {
return fmt.Errorf("showRecuringKeyboard : %w", err)
}
return nil
}
// addToFile adds content at the top of the file.
// Creates a file if not exists.
func (b *Bot) addToFile(dir, filename, content string) error {
existingContent, err := b.fs.Read(dir, filename)
// Ignore if file is missing, it would be created.
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("add to file: can't read existing file: %w", err)
}
header := fmt.Sprintf("#### %d %s %d, %s", now().Day(), now().Format("January"), now().Year(), now().Weekday())
newContent := txt.AddHeaderAndText(existingContent, header, content)
err = b.fs.Write(dir, filename, newContent)
if err != nil {
return fmt.Errorf("add to file: can't save file: %w", err)
}
return nil
}
func (b *Bot) openInApp(_ []string) error {
token := sync.GenOneTimeToken(b.userID)
onetimeURL := fmt.Sprintf("%s?token=%s", config.ServerCfg.AppURL, token)
kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.Tr("Open in app"), tg.NewURLCmd(onetimeURL))})
return b.showHTML(i18n.Tr("🔗 Here's your <b>one-time</b> link! <b>Desktop-only</b> for now."), kb)
}
func (b *Bot) showHelp(_ []string) error {
kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))})
return b.showHTML("Refer to files.md for help!", kb)
}
func (b *Bot) download(_ []string) error {
kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))})
return b.showHTML("Not yet implemented 🏗!", kb)
}
func (b *Bot) setTasksOnlyMode(_ []string) error {
err := b.cfg.SetMode(userconfig.ModeTasks)
if err != nil {
return fmt.Errorf("tasks only mode: can't set notes only mode %w", err)
}
cmds, err := b.cfg.MoveToCmds()
if err != nil {
return fmt.Errorf("tasks only mode: can't get quick commands %w", err)
}
for _, cmd := range cmds {
err = b.cfg.DelMoveToCmd(cmd)
if err != nil {
return fmt.Errorf("tasks only mode: can't delete quick command %w", err)
}
}
moveToCmds := []string{
CmdScheduleForTmrw,
CmdMoveToLater,
CmdShowScheduleForDay,
}
for _, cmd := range moveToCmds {
err = b.cfg.AddMoveToCmd(cmd)
if err != nil {
return fmt.Errorf("full mode: can't add quick command %w", err)
}
}
return b.ShowHome(nil)
}
func (b *Bot) setNotesOnlyMode(_ []string) error {
err := b.cfg.SetMode(userconfig.ModeNotes)
if err != nil {
return fmt.Errorf("notes only mode: can't set notes only mode %w", err)
}
return b.ShowHome(nil)
}
func (b *Bot) setJournalOnlyMode(_ []string) error {
err := b.cfg.SetMode(userconfig.ModeJournal)
if err != nil {
return fmt.Errorf("journal only mode: can't set notes only mode %w", err)
}
return b.showHTML(i18n.Tr("What's on your mind?"), nil)
}
func (b *Bot) setFullMode(_ []string) error {
err := b.cfg.SetMode(userconfig.ModeFull)
if err != nil {
return fmt.Errorf("full mode: can't set notes only mode %w", err)
}
moveToCmds := []string{
CmdShowMoveToDirOrFile,
CmdMoveToRead,
CmdMoveToShop,
CmdMoveToWatch,
CmdMoveToJournal,
}
for _, cmd := range moveToCmds {
err = b.cfg.AddMoveToCmd(cmd)
if err != nil {
return fmt.Errorf("full mode: can't add quick command %w", err)
}
}
err = b.fs.CreateSystemDirs()
if err != nil {
return fmt.Errorf("full mode: can't create dirs: %w", err)
}
return b.ShowHome(nil)
}
func (b *Bot) setChatOnlyMode(_ []string) error {
err := b.cfg.SetMode(userconfig.ModeChat)
if err != nil {
return fmt.Errorf("chat only mode: can't set chat only mode %w", err)
}
return b.showHTML(i18n.Tr("What's on your mind?"), nil)
}
func (b *Bot) completeHabit(params []string) error {
habit := params[0]
userHabits, err := habits.Habits(b.fs, time.Now().Year())
if err != nil {
return fmt.Errorf("complete habit: can't get habits: %w", err)
}
userHabits[habit][time.Now().YearDay()] = 1
err = habits.Write(b.fs, time.Now().Year(), userHabits)
if err != nil {
return fmt.Errorf("complete habit: can't write habits: %w", err)
}
emoji := habits.Emoji(b.fs, habit)
userConf := userconfig.NewConfig(b.fs, b.userID, config.ServerCfg.ConfigFilename)
err = journal.AddEmoji(b.fs, emoji, userConf.Timezone())
if err != nil {
return fmt.Errorf("complete habit: can't write emoji to journal: %w", err)
}
record := fmt.Sprintf("%s %s", emoji, habit)
err = journal.AddRecord(b.fs, record, userConf.Timezone())
if err != nil {
return fmt.Errorf("complete habit: can't write record to journal: %w", err)
}
return b.ShowHome(nil)
}
func (b *Bot) shareNote(params []string) error {
dirHash := params[0]
filenameHash := params[1]
dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash)
if err != nil {
return fmt.Errorf("share note: can't find dir: %w", err)
}
filename, err := b.fs.Unhash(dir, filenameHash)
if err != nil {
return fmt.Errorf("share note: can't find file: %w", err)
}
content, err := b.fs.Read(dir, filename)
if err != nil {
return fmt.Errorf("share note: %w", err)
}
for _, channel := range b.cfg.Channels() {
probablyInvalidMD := fmt.Sprintf("**%s/%s**\n\n%s", fs.DisplayName(dir), fs.DisplayName(filename), content)
probablyInvalidMD, images, _ := txt.ExtractTextImgsLinks(probablyInvalidMD)
// Sending a gallery of images if there are any
if len(images) > 0 {
// We tolerate errors with the image gallery for now, text is more important
mids, imgErr := b.tg.SendImages(channel, images)
if imgErr == nil {
for _, imgMid := range mids {
b.db.AddImgMsgID(imgMid)
}
} else {
slog.Error("Can't send images", "error", imgErr)
}
}
// If our msg is too long, we send maxMsgsToSendAtOnce first messages.
// Keyboard is attached to the last one
textChunks := txt.SplitTextIntoChunks(probablyInvalidMD, maxMsgLength)
textChunks = textChunks[0:min(maxMsgsToSendAtOnce, len(textChunks))]
lastChunk := textChunks[len(textChunks)-1]
textChunks = textChunks[0 : len(textChunks)-1]
for _, textChunk := range textChunks {
_, _ = b.tg.Send(b.userID, txt.MarkdownToHTML(textChunk), nil, tg.MarkupHTML)
}
_, err := b.tg.Send(channel, txt.MarkdownToHTML(lastChunk), nil, tg.MarkupHTML)
if err != nil {
return fmt.Errorf("share: %w", err)
}
}
return nil
}
func extractMarkdown(u Update) string {
content := txt.TelegramEntitiesToMarkdown(u.MsgText(), u.MsgEntities())
content = strings.TrimSpace(txt.NormNewLines(content))
return txt.Ucfirst(content)
}
func checklistTitle(checklist string) string {
checklist = strings.TrimSuffix(checklist, filepath.Ext(checklist))
stripChecklistChars := regexp.MustCompile(`^_.*?_(.+)`)
title := stripChecklistChars.ReplaceAllString(checklist, "$1")
title = strings.TrimPrefix(strings.TrimSuffix(title, "_"), "_")
return fs.DisplayName(title)
}
func completedMsg() string {
msgs := []string{
"Completed! 🚀",
"Done! 🎉",
"Awesome! 💪",
"Great job! 🌟",
"Good work! 🎈",
"Nice! 🎊",
"Fantastic! 🎇",
"Excellent! 🎯",
"Perfect! 🏆",
"Bravo! 👏",
"Superb! 🌠",
"You did it! ✅",
"Nicely done! 🎖",
"Nailed it! 🎯",
}
return msgs[rand.Intn(len(msgs))]
}