Skip to content

Commit

Permalink
[Feature] Determine the timezone through Google API (#150)
Browse files Browse the repository at this point in the history
* google maps api

* test command

* ignore cov

* new texts

* new UI + location field

* fixing issues

* onboarding + settings changes

* fixing onboarding flow

* fixing issues

* fixing tests

* tests

* unit tests

* new env var

---------

Co-authored-by: Maksym Bilan <>
  • Loading branch information
maximbilan authored Jan 1, 2025
1 parent 30385ee commit ebc7ed9
Show file tree
Hide file tree
Showing 15 changed files with 372 additions and 33 deletions.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/openai/openai-go v0.1.0-alpha.41
google.golang.org/api v0.214.0
google.golang.org/protobuf v1.36.1
googlemaps.github.io/maps v1.7.0
)

require (
Expand Down Expand Up @@ -41,6 +42,7 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
Expand Down
108 changes: 108 additions & 0 deletions go.sum

Large diffs are not rendered by default.

23 changes: 14 additions & 9 deletions internal/app/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@ const (
Version Command = "/version" // Show the version

// Hidden user commands
MissingNote Command = "/missing_note" // Ask to put a note from the previous text
Timezone Command = "/timezone" // Change the timezone
DownloadData Command = "/download_data" // Download the user data (all notes)
DeleteAccount Command = "/delete_account" // Ask to delete the account
ForceDeleteAccount Command = "/force_delete_account" // Force delete the account
Support Command = "/support" // Give feedback or ask for support
NoteCount Command = "/note_count" // Count of the current user notes
SleepAnalysis Command = "/sleep_analysis" // Sleep analysis of last note
WeeklyAnalysis Command = "/weekly_analysis" // Weekly analysis of the user's journal entries for last week
MissingNote Command = "/missing_note" // Ask to put a note from the previous text
DownloadData Command = "/download_data" // Download the user data (all notes)
DeleteAccount Command = "/delete_account" // Ask to delete the account
ForceDeleteAccount Command = "/force_delete_account" // Force delete the account
Support Command = "/support" // Give feedback or ask for support
NoteCount Command = "/note_count" // Count of the current user notes
SleepAnalysis Command = "/sleep_analysis" // Sleep analysis of last note
WeeklyAnalysis Command = "/weekly_analysis" // Weekly analysis of the user's journal entries for last week

// Reminder commands
Reminders Command = "/reminders" // Set reminders
EnableAllReminders Command = "/enable_all_reminders" // Enable all reminders
DisableAllReminders Command = "/disable_all_reminders" // Disable all reminders
Expand All @@ -39,6 +40,10 @@ const (
SetEveningReminderTime Command = "/set_evening_reminder_time" // Set evening reminder time
SkipReminders Command = "/skip_reminders" // Skip the reminders (during onboarding)

// Timezone commands
Timezone Command = "/timezone" // Change the timezone (manually)
AskForCity Command = "/ask_for_city" // Ask for the city to set the timezone

// Admin commands
TotalUserCount Command = "/total_user_count" // Get the total number of users
TotalActiveUserCount Command = "/total_active_user_count" // Get the number of active users
Expand Down
2 changes: 1 addition & 1 deletion internal/app/reminders.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func handleReminders(session *Session) {
var timezoneButton botservice.BotResultTextButton = botservice.BotResultTextButton{
TextID: "timezone",
Locale: session.Locale(),
Callback: string(Timezone),
Callback: string(AskForCity),
}
var morningReminderButton botservice.BotResultTextButton = botservice.BotResultTextButton{
TextID: "morning_reminder_button",
Expand Down
24 changes: 12 additions & 12 deletions internal/app/reminders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ func TestRemindersOnHandler(t *testing.T) {
if session.Job.Output[0].Buttons[1].TextID != "timezone" {
t.Error("Expected 'timezone', got", session.Job.Output[0].Buttons[1].TextID)
}
if session.Job.Output[0].Buttons[1].Callback != "/timezone" {
t.Error("Expected '/timezone', got", session.Job.Output[0].Buttons[1].Callback)
if session.Job.Output[0].Buttons[1].Callback != "/ask_for_city" {
t.Error("Expected '/ask_for_city', got", session.Job.Output[0].Buttons[1].Callback)
}
if session.Job.Output[0].Buttons[2].TextID != "morning_reminder_button" {
t.Error("Expected 'morning_reminder_button', got", session.Job.Output[0].Buttons[2].TextID)
Expand Down Expand Up @@ -237,8 +237,8 @@ func TestAllRemindersEnabler(t *testing.T) {
if session.Job.Output[0].TextID != "reminders_enabled" {
t.Error("Expected 'reminders_enabled', got", session.Job.Output[0].TextID)
}
if session.Job.Output[1].TextID != "timezone_select" {
t.Error("Expected 'timezone_select', got", session.Job.Output[1].TextID)
if session.Job.Output[1].TextID != "ask_for_city" {
t.Error("Expected 'ask_for_city', got", session.Job.Output[1].TextID)
}
}

Expand Down Expand Up @@ -269,8 +269,8 @@ func TestMorningReminderEnabled(t *testing.T) {
if session.Job.Output[0].TextID != "reminder_set" {
t.Error("Expected 'reminder_set', got", session.Job.Output[0].TextID)
}
if session.Job.Output[1].TextID != "timezone_select" {
t.Error("Expected 'timezone_select', got", session.Job.Output[1].TextID)
if session.Job.Output[1].TextID != "ask_for_city" {
t.Error("Expected 'ask_for_city', got", session.Job.Output[1].TextID)
}
}

Expand Down Expand Up @@ -298,8 +298,8 @@ func TestEveningReminderEnabler(t *testing.T) {
if session.Job.Output[0].TextID != "reminder_set" {
t.Error("Expected 'reminder_set', got", session.Job.Output[0].TextID)
}
if session.Job.Output[1].TextID != "timezone_select" {
t.Error("Expected 'timezone_select', got", session.Job.Output[1].TextID)
if session.Job.Output[1].TextID != "ask_for_city" {
t.Error("Expected 'ask_for_city', got", session.Job.Output[1].TextID)
}
}

Expand Down Expand Up @@ -330,8 +330,8 @@ func TestMorningReminderOffset(t *testing.T) {
if session.Job.Output[0].TextID != "reminder_set" {
t.Error("Expected 'reminder_set', got", session.Job.Output[0].TextID)
}
if session.Job.Output[1].TextID != "timezone_select" {
t.Error("Expected 'timezone_select', got", session.Job.Output[1].TextID)
if session.Job.Output[1].TextID != "ask_for_city" {
t.Error("Expected 'ask_for_city', got", session.Job.Output[1].TextID)
}
}

Expand All @@ -349,8 +349,8 @@ func TestEveningReminderOffset(t *testing.T) {
if session.Job.Output[0].TextID != "reminder_set" {
t.Error("Expected 'reminder_set', got", session.Job.Output[0].TextID)
}
if session.Job.Output[1].TextID != "timezone_select" {
t.Error("Expected 'timezone_select', got", session.Job.Output[1].TextID)
if session.Job.Output[1].TextID != "ask_for_city" {
t.Error("Expected 'ask_for_city', got", session.Job.Output[1].TextID)
}
}

Expand Down
4 changes: 4 additions & 0 deletions internal/app/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ func handleSession(session *Session) {
handleLanguage(session)
case Timezone:
handleTimezone(session, settingsStorage)
case AskForCity:
requestTimezone(session)
case Reminders:
handleReminders(session)
case MorningReminder:
Expand Down Expand Up @@ -145,6 +147,8 @@ func handleSession(session *Session) {
finishNote(*session.Job.Input, session, noteStorage)
case Support:
finishFeedback(session, feedbackStorage)
case AskForCity, EnableAllReminders, EnableMorningReminder, EnableEveningReminder, SetMorningReminderTime, SetEveningReminderTime:
finishCityRequest(session, mapsService, settingsStorage)
default:
// If the user is typing and the last command is not recognized
handleHelp(session)
Expand Down
51 changes: 48 additions & 3 deletions internal/app/timezone.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
package app

import (
"fmt"
"log"
"strconv"
"time"

"github.com/capymind/internal/botservice"
"github.com/capymind/internal/database"
"github.com/capymind/internal/mapsservice"
"github.com/capymind/internal/translator"
"github.com/capymind/internal/utils"
)

// Handle the timezone command
func handleTimezone(session *Session, settingsStorage database.SettingsStorage) {
if len(session.Job.Parameters) == 0 {
requestTimezone(session)
requestTimezoneManually(session)
} else {
setupTimezone(session, settingsStorage)
}
Expand Down Expand Up @@ -40,8 +44,8 @@ func setupTimezone(session *Session, settingsStorage database.SettingsStorage) {
session.SaveSettings(*session.Settings, settingsStorage)
}

// Set the timezone
func requestTimezone(session *Session) {
// Set the timezone manually
func requestTimezoneManually(session *Session) {
var buttons []botservice.BotResultTextButton
timeZones := utils.GetTimeZones()
for _, tz := range timeZones {
Expand All @@ -55,3 +59,44 @@ func requestTimezone(session *Session) {
}
setOutputTextWithButtons("timezone_select", buttons, session)
}

func requestTimezone(session *Session) {
session.User.IsTyping = true
setOutputText("ask_for_city", session)
}

func finishCityRequest(session *Session, mapsService mapsservice.MapsService, settingsStorage database.SettingsStorage) {
session.User.IsTyping = false

city := *session.Job.Input
secondsFromUTC := mapsService.GetTimezone(city)
if secondsFromUTC == nil {
setOutputText("timezone_not_found", session)
requestTimezoneManually(session)
return
}

session.Settings.Location = &city
session.SaveSettings(*session.Settings, settingsStorage)

text := translator.Translate(session.Locale(), "is_this_your_time")
text = text + currentTimeString(time.Now(), *secondsFromUTC)

var yesButton botservice.BotResultTextButton = botservice.BotResultTextButton{
TextID: "yes",
Locale: session.Locale(),
Callback: string(Timezone) + fmt.Sprintf(" %d", *secondsFromUTC),
}
var noButton botservice.BotResultTextButton = botservice.BotResultTextButton{
TextID: "no",
Locale: session.Locale(),
Callback: string(Timezone),
}

setOutputTextWithButtons(text, []botservice.BotResultTextButton{yesButton, noButton}, session)
}

func currentTimeString(currentTime time.Time, offset int) string {
utcTime := currentTime.UTC().Add(time.Duration(offset) * time.Second)
return utcTime.Format("15:04")
}
70 changes: 70 additions & 0 deletions internal/app/timezone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"testing"
"time"

"github.com/capymind/internal/database"
"github.com/capymind/internal/mocks"
Expand Down Expand Up @@ -48,3 +49,72 @@ func TestTimezoneHandlerWithParamOnboarded(t *testing.T) {
t.Error("Expected '0', got", false)
}
}

func TestRequestTimezoneHandler(t *testing.T) {
session := createSession(&Job{Command: "/ask_for_city"}, &database.User{}, nil, nil)
requestTimezone(session)

if session.Job.Output[0].TextID != "ask_for_city" {
t.Error("Expected 'ask_for_city', got", session.Job.Output[0].TextID)
}
if session.User.IsTyping != true {
t.Error("Expected 'true', got", session.User.IsTyping)
}
}

func TestFinishCityInvalidRequestHandler(t *testing.T) {
city := "new york"
session := createSession(&Job{Command: "", Input: &city}, &database.User{}, &database.Settings{}, nil)
mapsService := &mocks.InvalidMapsServiceMock{}
settingsStorage := &mocks.EmptySettingsStorageMock{}
finishCityRequest(session, mapsService, settingsStorage)

if session.User.IsTyping != false {
t.Error("Expected 'false', got", session.User.IsTyping)
}
if session.Job.Output[0].TextID != "timezone_not_found" {
t.Error("Expected 'timezone_not_found', got", session.Job.Output[0].TextID)
}
if session.Job.Output[1].TextID != "timezone_select" {
t.Error("Expected 'timezone_select', got", session.Job.Output[1].TextID)
}
}

func TestFinishCityRequestHandler(t *testing.T) {
city := "portland"
session := createSession(&Job{Command: "", Input: &city}, &database.User{}, &database.Settings{}, nil)
mapsService := &mocks.MapsServiceMock{}
settingsStorage := &mocks.EmptySettingsStorageMock{}
finishCityRequest(session, mapsService, settingsStorage)

if session.User.IsTyping != false {
t.Error("Expected 'false', got", session.User.IsTyping)
}
if *session.Settings.Location != city {
t.Error("Expected 'portland', got", *session.Settings.Location)
}
if len(session.Job.Output[0].Buttons) != 2 {
t.Error("Expected '2', got", len(session.Job.Output[0].Buttons))
}
if session.Job.Output[0].Buttons[0].TextID != "yes" {
t.Error("Expected 'yes', got", session.Job.Output[0].Buttons[0].TextID)
}
if session.Job.Output[0].Buttons[0].Callback != "/timezone 7200" {
t.Error("Expected '/timezone 7200', got", session.Job.Output[0].Buttons[0].Callback)
}
if session.Job.Output[0].Buttons[1].TextID != "no" {
t.Error("Expected 'no', got", session.Job.Output[0].Buttons[1].TextID)
}
if session.Job.Output[0].Buttons[1].Callback != "/timezone" {
t.Error("Expected '/timezone', got", session.Job.Output[0].Buttons[1].Callback)
}
}

func TestCurrentTimeString(t *testing.T) {
time := time.Date(2021, 1, 1, 9, 35, 0, 0, time.UTC)

timeString := currentTimeString(time, 7200)
if timeString != "11:35" {
t.Error("Expected '11:35', got", timeString)
}
}
3 changes: 3 additions & 0 deletions internal/app/vars.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package app
import (
"github.com/capymind/third_party/firestore"
"github.com/capymind/third_party/googledrive"
"github.com/capymind/third_party/googlemaps"
"github.com/capymind/third_party/openai"
"github.com/capymind/third_party/telegram"
)
Expand All @@ -20,3 +21,5 @@ var adminStorage firestore.AdminStorage
var feedbackStorage firestore.FeedbackStorage

var fileStorage googledrive.GoogleDrive

var mapsService googlemaps.GoogleMapsService
11 changes: 6 additions & 5 deletions internal/database/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package database
import "context"

type Settings struct {
SecondsFromUTC *int `json:"secondsFromUTC"`
HasMorningReminder *bool `json:"hasMorningReminder"`
HasEveningReminder *bool `json:"hasEveningReminder"`
MorningReminderOffset *int `json:"morningReminderOffset"`
EveningReminderOffset *int `json:"eveningReminderOffset"`
SecondsFromUTC *int `json:"secondsFromUTC"`
HasMorningReminder *bool `json:"hasMorningReminder"`
HasEveningReminder *bool `json:"hasEveningReminder"`
MorningReminderOffset *int `json:"morningReminderOffset"`
EveningReminderOffset *int `json:"eveningReminderOffset"`
Location *string `json:"location"`
}

type SettingsStorage interface {
Expand Down
7 changes: 7 additions & 0 deletions internal/mapsservice/mapsservice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//coverage:ignore file

package mapsservice

type MapsService interface {
GetTimezone(city string) *int
}
16 changes: 16 additions & 0 deletions internal/mocks/mapsservice_mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//coverage:ignore file

package mocks

type MapsServiceMock struct{}

func (service MapsServiceMock) GetTimezone(city string) *int {
timezone := 7200
return &timezone
}

type InvalidMapsServiceMock struct{}

func (service InvalidMapsServiceMock) GetTimezone(city string) *int {
return nil
}
14 changes: 12 additions & 2 deletions internal/translator/translations.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ const translationsJSON = `{
"morning_reminder_descr": "Set a reminder to make a note in the journal every morning 🌅",
"evening_reminder_descr": "Set a reminder to make a note in the journal every evening 🌙",
"onboarding_reminders": "Would you like to turn on reminders to make journal entries in the morning and evening? 🌅🌙",
"continue": "Continue ➡️"
"continue": "Continue ➡️",
"ask_for_city": "Please enter the name of the city you are currently in 🌍",
"timezone_not_found": "The time zone for the city you entered could not be found. Please set up your time zone manually.",
"is_this_your_time": "Is this your current time? 🕒\n",
"yes": "Yes",
"no": "No"
},
"uk": {
"welcome": "Ласкаво просимо до CapyMind 👋 Ваш особистий журнал для записів про психічне здоров'я тут, щоб допомогти вам на вашому шляху. Рефлексуйте над своїми думками та емоціями, використовуйте нагадування, щоб залишатися на шляху, та досліджуйте інсайти терапії, щоб поглибити свою самосвідомість.",
Expand Down Expand Up @@ -159,6 +164,11 @@ const translationsJSON = `{
"morning_reminder_descr": "Встановіть нагадування зробити запис у журнал кожного ранку 🌅",
"evening_reminder_descr": "Встановіть нагадування зробити запис у журнал кожного вечора 🌙",
"onboarding_reminders": "Бажаєте увімкнути нагадування для ведення записів у журналі вранці та ввечері? 🌅🌙",
"continue": "Продовжити ➡️"
"continue": "Продовжити ➡️",
"ask_for_city": "Будь ласка, введіть назву міста, в якому ви знаходитесь 🌍",
"timezone_not_found": "Часовий пояс для введеного вами міста не вдалося знайти. Будь ласка, встановіть свій часовий пояс вручну.",
"is_this_your_time": "Це ваш поточний час? 🕒\n",
"yes": "Так",
"no": "Ні"
}
}`
2 changes: 1 addition & 1 deletion scripts/deploy_functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ done
ENV_VARS=${ENV_VARS%,}

# Set the secret environment variables
SECRET_PARAMS=("CAPY_TELEGRAM_BOT_TOKEN=telegram_bot_token" "CAPY_AI_KEY=ai_key")
SECRET_PARAMS=("CAPY_TELEGRAM_BOT_TOKEN=telegram_bot_token" "CAPY_AI_KEY=ai_key" "CAPY_GOOGLE_MAPS_API_KEY=maps_key")
SECRETS=""
for PARAM in "${SECRET_PARAMS[@]}"; do
SECRETS+="$PARAM:latest,"
Expand Down
Loading

0 comments on commit ebc7ed9

Please sign in to comment.