Skip to content

Commit

Permalink
Merge pull request #42 from LucaBernstein/subcommands
Browse files Browse the repository at this point in the history
Subcommands + Config + Tags + Reminders
  • Loading branch information
LucaBernstein authored Nov 30, 2021
2 parents 3f8903b + ec5665c commit fb6c6c6
Show file tree
Hide file tree
Showing 19 changed files with 692 additions and 89 deletions.
138 changes: 138 additions & 0 deletions bot/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package bot

import (
"fmt"
"strconv"
"strings"

h "github.com/LucaBernstein/beancount-bot-tg/helpers"
tb "gopkg.in/tucnak/telebot.v2"
)

func (bc *BotController) configHandler(m *tb.Message) {
sc := h.MakeSubcommandHandler("/"+CMD_CONFIG, true)
sc.
Add("currency", bc.configHandleCurrency).
Add("tag", bc.configHandleTag).
Add("notify", bc.configHandleNotification)
err := sc.Handle(m)
if err != nil {
bc.configHelp(m, nil)
}
}

func (bc *BotController) configHelp(m *tb.Message, err error) {
errorMsg := ""
if err != nil {
errorMsg += fmt.Sprintf("Error executing your command: %s\n\n", err.Error())
}
bc.Bot.Send(m.Sender, errorMsg+fmt.Sprintf("Usage help for /%s:\n\n/%s currency <c> - Change default currency"+
"\n\nTags will be added to each new transaction with a '#':\n"+
"\n/%s tag - Get currently set tag"+
"\n/%s tag off - Turn off tag"+
"\n/%s tag <name> - Set tag to apply to new transactions, e.g. when on vacation"+
"\n\nCreate a schedule to be notified of open transactions (i.e. not archived or deleted):\n"+
"\n/%s notify - Get current notification status"+
"\n/%s notify off - Disable reminder notifications"+
"\n/%s notify <delay> <hour> - Notify of open transaction after <delay> days at <hour> of the day",
CMD_CONFIG, CMD_CONFIG, CMD_CONFIG, CMD_CONFIG, CMD_CONFIG, CMD_CONFIG, CMD_CONFIG, CMD_CONFIG))
}

func (bc *BotController) configHandleCurrency(m *tb.Message, params ...string) {
currency := bc.Repo.UserGetCurrency(m)
if len(params) == 0 { // 0 params: GET currency
// Return currently set currency
bc.Bot.Send(m.Sender, fmt.Sprintf("Your current currency is set to '%s'. To change it add the new currency to use to the command like this: '/%s currency EUR'.", currency, CMD_CONFIG))
return
} else if len(params) > 1 { // 2 or more params: too many
bc.configHelp(m, fmt.Errorf("invalid amount of parameters specified"))
return
}
// Set new currency
newCurrency := params[0]
err := bc.Repo.UserSetCurrency(m, newCurrency)
if err != nil {
bc.Bot.Send(m.Sender, "An error ocurred saving your currency preference: "+err.Error())
return
}
bc.Bot.Send(m.Sender, fmt.Sprintf("Changed default currency for all future transactions from '%s' to '%s'.", currency, newCurrency))
}

func (bc *BotController) configHandleTag(m *tb.Message, params ...string) {
if len(params) == 0 {
// GET tag
tag := bc.Repo.UserGetTag(m)
if tag != "" {
bc.Bot.Send(m.Sender, fmt.Sprintf("All new transactions automatically get the tag #%s added (vacation mode enabled)", tag))
} else {
bc.Bot.Send(m.Sender, "No tags are currently added to new transactions (vacation mode disabled).")
}
return
} else if len(params) > 1 { // Only 0 or 1 allowed
bc.configHelp(m, fmt.Errorf("invalid amount of parameters specified"))
return
}
if params[0] == "off" {
// DELETE tag
bc.Repo.UserSetTag(m, "")
bc.Bot.Send(m.Sender, "Disabled automatically set tags on new transactions")
return
}
// SET tag
tag := strings.TrimPrefix(params[0], "#")
bc.Repo.UserSetTag(m, tag)
bc.Bot.Send(m.Sender, fmt.Sprintf("From now on all new transactions automatically get the tag #%s added (vacation mode enabled)", tag))
}

func (bc *BotController) configHandleNotification(m *tb.Message, params ...string) {
if len(params) == 0 {
// GET schedule
daysDelay, hour, err := bc.Repo.UserGetNotificationSetting(m)
if err != nil {
bc.configHelp(m, fmt.Errorf("an application error occurred while retrieving user information from database"))
return
}
if daysDelay < 0 {
bc.Bot.Send(m.Sender, "Notifications are disabled for open transactions.")
return
}
plural_s := "s"
if daysDelay == 1 {
plural_s = ""
}
bc.Bot.Send(m.Sender, fmt.Sprintf("The bot will notify you daily at hour %d if transactions are open for more than %d day%s", hour, daysDelay, plural_s))
return
} else if len(params) == 1 {
// DELETE schedule
if params[0] == "off" {
err := bc.Repo.UserSetNotificationSetting(m, -1, -1)
if err != nil {
bc.configHelp(m, fmt.Errorf("error setting notification schedule: %s", err.Error()))
return
}
bc.Bot.Send(m.Sender, "Successfully disabled notifications for open transactions.")
return
}
bc.configHelp(m, fmt.Errorf("invalid parameters"))
return
} else if len(params) == 2 {
// SET schedule
daysDelay, err := strconv.Atoi(params[0])
if err != nil {
bc.configHelp(m, fmt.Errorf("error converting daysDelay to number: %s: %s", params[0], err.Error()))
return
}
hour, err := strconv.Atoi(params[1])
if err != nil {
bc.configHelp(m, fmt.Errorf("error converting hour to number: %s: %s", params[1], err.Error()))
return
}
err = bc.Repo.UserSetNotificationSetting(m, daysDelay, hour)
if err != nil {
bc.configHelp(m, fmt.Errorf("error setting notification schedule: %s", err.Error()))
}
bc.configHandleNotification(m) // Recursively call with zero params --> GET
return
}
bc.configHelp(m, fmt.Errorf("invalid amount of parameters specified"))
}
76 changes: 76 additions & 0 deletions bot/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package bot

import (
"fmt"
"log"
"strings"
"testing"

"github.com/DATA-DOG/go-sqlmock"
tb "gopkg.in/tucnak/telebot.v2"
)

func TestConfigCurrency(t *testing.T) {
// Test dependencies
chat := &tb.Chat{ID: 12345}
db, mock, err := sqlmock.New()
if err != nil {
log.Fatal(err)
}
mock.
ExpectQuery(`SELECT "currency" FROM "auth::user" WHERE "tgChatId" = ?`).
WithArgs(chat.ID).
WillReturnRows(sqlmock.NewRows([]string{"currency"}))
mock.
ExpectQuery(`SELECT "currency" FROM "auth::user" WHERE "tgChatId" = ?`).
WithArgs(chat.ID).
WillReturnRows(sqlmock.NewRows([]string{"currency"}).AddRow("SOMEEUR"))
mock.
ExpectQuery(`SELECT "currency" FROM "auth::user" WHERE "tgChatId" = ?`).
WithArgs(chat.ID).
WillReturnRows(sqlmock.NewRows([]string{"currency"}).AddRow("SOMEEUR"))
mock.
ExpectExec(`UPDATE "auth::user"`).
WithArgs(12345, "USD").
WillReturnResult(sqlmock.NewResult(1, 1))

bc := NewBotController(db)

bot := &MockBot{}
bc.ConfigureAndAttachBot(bot)

bc.commandConfig(&tb.Message{Text: "/config", Chat: chat})
if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help for /config") {
t.Errorf("/config: %s", bot.LastSentWhat)
}

// Default currency
bc.commandConfig(&tb.Message{Text: "/config currency", Chat: chat})
if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") {
t.Errorf("/config currency: Unexpected usage help: %s", bot.LastSentWhat)
}
if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Your current currency is set to 'EUR'") {
t.Errorf("/config currency default: Expected currency to be retrieved from db: %s", bot.LastSentWhat)
}

// Currency set in db
bc.commandConfig(&tb.Message{Text: "/config currency", Chat: chat})
if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") {
t.Errorf("/config currency: Unexpected usage help: %s", bot.LastSentWhat)
}
if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Your current currency is set to 'SOMEEUR") {
t.Errorf("/config currency set (2): Expected currency to be retrieved from db: %s", bot.LastSentWhat)
}

bc.commandConfig(&tb.Message{Text: "/config currency USD", Chat: chat})
if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") {
t.Errorf("/config currency USD: Unexpected usage help: %s", bot.LastSentWhat)
}
if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "'SOMEEUR' to 'USD'") {
t.Errorf("/config currency (2): Expected currency to be retrieved from db %s", bot.LastSentWhat)
}

if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
79 changes: 61 additions & 18 deletions bot/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"fmt"
"log"
"strings"
"time"

dbWrapper "github.com/LucaBernstein/beancount-bot-tg/db"
"github.com/LucaBernstein/beancount-bot-tg/db/crud"
"github.com/go-co-op/gocron"
tb "gopkg.in/tucnak/telebot.v2"
)

Expand All @@ -28,9 +30,11 @@ type BotController struct {
Repo *crud.Repo
State *StateHandler
Bot IBot

CronScheduler *gocron.Scheduler
}

func (bc *BotController) ConfigureAndAttachBot(b IBot) *BotController {
func (bc *BotController) ConfigureAndAttachBot(b IBot) {
bc.Bot = b

mappings := bc.commandMappings()
Expand All @@ -42,9 +46,15 @@ func (bc *BotController) ConfigureAndAttachBot(b IBot) *BotController {
b.Handle(tb.OnText, bc.handleTextState)

log.Printf("Starting bot '%s'", b.Me().Username)
b.Start()

return bc
// Add CRON scheduler
s := gocron.NewScheduler(time.UTC)
s.Every(1).Hour().At("00:30").Do(bc.cronNotifications)
bc.CronScheduler = s
s.StartAsync()
log.Print(bc.cronInfo())

b.Start() // Blocking
}

const (
Expand All @@ -56,9 +66,10 @@ const (
CMD_ARCHIVE_ALL = "archiveAll"
CMD_DELETE_ALL = "deleteAll"
CMD_SUGGEST = "suggestions"
CMD_CURRENCY = "currency"
CMD_CONFIG = "config"

CMD_ADM_NOTIFY = "admin_notify"
CMD_ADM_CRON = "admin_cron"
)

func (bc *BotController) commandMappings() []*CMD {
Expand All @@ -69,11 +80,12 @@ func (bc *BotController) commandMappings() []*CMD {
{Command: CMD_SIMPLE, Handler: bc.commandCreateSimpleTx, Help: "Record a simple transaction, defaults to today", Optional: "YYYY-MM-DD"},
{Command: CMD_LIST, Handler: bc.commandList, Help: "List your recorded transactions", Optional: "archived"},
{Command: CMD_SUGGEST, Handler: bc.commandSuggestions, Help: "List, add or remove suggestions"},
{Command: CMD_CURRENCY, Handler: bc.commandCurrency, Help: "Set the currency to use globally for subsequent transactions"},
{Command: CMD_CONFIG, Handler: bc.commandConfig, Help: "Bot configurations"},
{Command: CMD_ARCHIVE_ALL, Handler: bc.commandArchiveTransactions, Help: "Archive recorded transactions"},
{Command: CMD_DELETE_ALL, Handler: bc.commandDeleteTransactions, Help: "Permanently delete recorded transactions"},

{Command: CMD_ADM_NOTIFY, Handler: bc.commandAdminNofify, Help: "Send notification to user(s): /" + CMD_ADM_NOTIFY + " [chatId] \"<message>\""},
{Command: CMD_ADM_CRON, Handler: bc.commandAdminCronInfo, Help: "Check cron status"},
}
}

Expand Down Expand Up @@ -199,20 +211,40 @@ func (bc *BotController) commandSuggestions(m *tb.Message) {
bc.suggestionsHandler(m)
}

func (bc *BotController) commandCurrency(m *tb.Message) {
currency := bc.Repo.UserGetCurrency(m)
values := strings.Split(m.Text, " ")
if len(values) != 2 {
bc.Bot.Send(m.Sender, fmt.Sprintf("Your current currency is set to '%s'. To change it add the new currency to use to the command like this: '/currency EUR'.", currency))
return
}
currency = values[1]
err := bc.Repo.UserSetCurrency(m, currency)
func (bc *BotController) commandConfig(m *tb.Message) {
bc.configHandler(m)
}

func (bc *BotController) cronInfo() string {
_, jobTime := bc.CronScheduler.NextRun()
return fmt.Sprintf("Next job running will be at %v\nCurrent timestamp: %s (hour: %d)", jobTime, time.Now(), time.Now().Hour())
}

func (bc *BotController) cronNotifications() {
log.Print("Running notifications job.")
rows, err := bc.Repo.GetUsersToNotify()
if err != nil {
bc.Bot.Send(m.Sender, "An error ocurred saving your currency preference: "+err.Error())
return
log.Printf("Error getting users to notify: %s", err.Error())
}

var (
tgChatId string
openCount int
)
for rows.Next() {
err = rows.Scan(&tgChatId, &openCount)
if err != nil {
log.Printf("Error occurred extracting tgChatId to send open tx notification to: %s", err.Error())
continue
}
log.Printf("Sending notification for %d open transaction(s) to %s", openCount, tgChatId)
bc.Bot.Send(ReceiverImpl{chatId: tgChatId}, fmt.Sprintf(
// TODO: Replace hard-coded command directives:
" This is your reminder to inform you that you currently have %d open transactions. Check '/list' to see them. If you don't need them you can /archiveAll or /delete them."+
"\n\nYou are getting this message because you enabled reminder notifications for open transactions in /config.", openCount))
}
bc.Bot.Send(m.Sender, fmt.Sprintf("For all future transactions the currency '%s' will be used.", currency))

log.Print(bc.cronInfo())
}

type ReceiverImpl struct {
Expand All @@ -223,6 +255,16 @@ func (r ReceiverImpl) Recipient() string {
return r.chatId
}

func (bc *BotController) commandAdminCronInfo(m *tb.Message) {
isAdmin := bc.Repo.UserIsAdmin(m)
if !isAdmin {
log.Printf("Received admin command from non-admin user (%s, %d). Ignoring (treating as normal text input).", m.Chat.Username, m.Chat.ID)
bc.handleTextState(m)
return
}
bc.Bot.Send(m.Sender, bc.cronInfo())
}

func (bc *BotController) commandAdminNofify(m *tb.Message) {
isAdmin := bc.Repo.UserIsAdmin(m)
if !isAdmin {
Expand Down Expand Up @@ -284,7 +326,8 @@ func (bc *BotController) handleTextState(m *tb.Message) {
log.Printf("New data state for %s (ChatID: %d) is %v. (Last input was '%s')", m.Chat.Username, m.Chat.ID, tx.Debug(), m.Text)
if tx.IsDone() {
currency := bc.Repo.UserGetCurrency(m)
transaction, err := tx.FillTemplate(currency)
tag := bc.Repo.UserGetTag(m)
transaction, err := tx.FillTemplate(currency, tag)
if err != nil {
log.Printf("Something went wrong while templating the transaction: " + err.Error())
bc.Bot.Send(m.Sender, "Something went wrong while templating the transaction: "+err.Error(), clearKeyboard())
Expand Down
14 changes: 12 additions & 2 deletions bot/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"log"
"strings"
"testing"
"time"

tb "gopkg.in/tucnak/telebot.v2"

"github.com/DATA-DOG/go-sqlmock"
"github.com/LucaBernstein/beancount-bot-tg/helpers"
)

type MockBot struct {
Expand Down Expand Up @@ -37,10 +39,18 @@ func TestTextHandlingWithoutPriorState(t *testing.T) {
mock.
ExpectQuery(`SELECT "currency" FROM "auth::user" WHERE "tgChatId" = ?`).
WithArgs(chat.ID).
WillReturnRows(sqlmock.NewRows([]string{"TEST_CURRENCY"}))
WillReturnRows(sqlmock.NewRows([]string{"currency"}).AddRow("TEST_CURRENCY"))
mock.
ExpectQuery(`SELECT "tag" FROM "auth::user" WHERE "tgChatId" = ?`).
WithArgs(chat.ID).
WillReturnRows(sqlmock.NewRows([]string{"tag"}).AddRow("vacation2021"))
today := time.Now().Format(helpers.BEANCOUNT_DATE_FORMAT)
mock.
ExpectExec(`INSERT INTO "bot::transaction"`).
WithArgs(chat.ID, sqlmock.AnyArg()).
WithArgs(chat.ID, today+` * "Buy something in the grocery store" #vacation2021
Assets:Wallet -17.34 TEST_CURRENCY
Expenses:Groceries
`).
WillReturnResult(sqlmock.NewResult(1, 1))

bc := NewBotController(db)
Expand Down
Loading

0 comments on commit fb6c6c6

Please sign in to comment.