diff --git a/src/cmd/serve.go b/src/cmd/serve.go index 6732fd6..fa5963e 100644 --- a/src/cmd/serve.go +++ b/src/cmd/serve.go @@ -21,6 +21,7 @@ import ( "github.com/t-liu93/home-automation-backend/components/locationRecorder" "github.com/t-liu93/home-automation-backend/components/pooRecorder" "github.com/t-liu93/home-automation-backend/util/notion" + "github.com/t-liu93/home-automation-backend/util/ticktick" ) var port string @@ -41,6 +42,8 @@ func initUtil() { slog.Error("Notion token not found in config file, exiting..") os.Exit(1) } + // init ticktick + ticktick.Init() } func initComponent() { @@ -55,8 +58,8 @@ func serve(cmd *cobra.Command, args []string) { viper.SetConfigName("config") // name of config file (without extension) viper.SetConfigType("yaml") + viper.AddConfigPath(".") // . is used for dev viper.AddConfigPath("$HOME/.config/home-automation") - viper.AddConfigPath(".") // optionally look for config in the working directory err := viper.ReadInConfig() if err != nil { slog.Error(fmt.Sprintf("Cannot read config file, %s, exiting..", err)) @@ -105,6 +108,8 @@ func serve(cmd *cobra.Command, args []string) { router.HandleFunc("/location/record", locationRecorder.HandleRecordLocation).Methods("POST") + router.HandleFunc("/ticktick/auth/code", ticktick.HandleAuthCode).Methods("GET") + srv := &http.Server{ Addr: ":" + port, Handler: router, diff --git a/src/components/homeassistant/homeassistant.go b/src/components/homeassistant/homeassistant.go index 24fee0b..4043f2d 100644 --- a/src/components/homeassistant/homeassistant.go +++ b/src/components/homeassistant/homeassistant.go @@ -9,6 +9,7 @@ import ( "time" "github.com/spf13/viper" + "github.com/t-liu93/home-automation-backend/util/ticktick" ) type haMessage struct { @@ -17,6 +18,11 @@ type haMessage struct { Content string `json:"content"` } +type actionTask struct { + Action string `json:"action"` + DueHour int `json:"due_hour"` +} + func HandleHaMessage(w http.ResponseWriter, r *http.Request) { var message haMessage decoder := json.NewDecoder(r.Body) @@ -33,6 +39,8 @@ func HandleHaMessage(w http.ResponseWriter, r *http.Request) { handlePooRecorderMsg(message) case "location_recorder": handleLocationRecorderMsg(message) + case "ticktick": + handleTicktickMsg(message) } } @@ -66,6 +74,13 @@ func handleLocationRecorderMsg(message haMessage) { } } +func handleTicktickMsg(message haMessage) { + switch message.Action { + case "create_action_task": + createActionTask(message) + } +} + func handleGetLatestPoo() { client := &http.Client{ Timeout: time.Second * 1, @@ -76,3 +91,31 @@ func handleGetLatestPoo() { slog.Warn(fmt.Sprintln("handleGetLatestPoo Error sending request to poo recorder", err)) } } + +func createActionTask(message haMessage) { + if !viper.InConfig("homeassistant.actionTaskProjectId") { + slog.Warn("Homeassistant.createActionTask actionTaskProjectId not found in config file") + return + } + projectId := viper.GetString("homeassistant.actionTaskProjectId") + detail := strings.ReplaceAll(message.Content, "'", "\"") + var task actionTask + err := json.Unmarshal([]byte(detail), &task) + if err != nil { + slog.Warn(fmt.Sprintln("Homeassistant.createActionTask Error unmarshalling", err)) + return + } + dueHour := task.DueHour + due := time.Now().Add(time.Hour * time.Duration(dueHour)) + dueNextMidnight := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1) + dueTicktick := dueNextMidnight.UTC().Format(ticktick.DateTimeLayout) + ticktickTask := ticktick.Task{ + ProjectId: projectId, + Title: task.Action, + DueDate: dueTicktick, + } + err = ticktick.CreateTask(ticktickTask) + if err != nil { + slog.Warn(fmt.Sprintf("Homeassistant.createActionTask Error creating task %s", err)) + } +} diff --git a/src/util/ticktick/ticktick.go b/src/util/ticktick/ticktick.go new file mode 100644 index 0000000..3344e5b --- /dev/null +++ b/src/util/ticktick/ticktick.go @@ -0,0 +1,284 @@ +package ticktick + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "time" + + "github.com/spf13/viper" +) + +var ( + authState string +) + +const ( + DateTimeLayout = "2006-01-02T15:04:05-0700" +) + +type Project struct { + Id string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` + SortOrder int64 `json:"sortOrder,omitempty"` + Closed bool `json:"closed,omitempty"` + GroupId string `json:"groupId,omitempty"` + ViewMode string `json:"viewMode,omitempty"` + Permission string `json:"permission,omitempty"` + Kind string `json:"kind,omitempty"` +} + +type Column struct { + Id string `json:"id"` + Name string `json:"name"` + ProjectId string `json:"projectId"` + SortOrder int64 `json:"sortOrder,omitempty"` +} + +type Task struct { + Id string `json:"id"` + ProjectId string `json:"projectId"` + Title string `json:"title"` + IsAllDay bool `json:"isAllDay,omitempty"` + CompletedTime string `json:"completedTime,omitempty"` + Content string `json:"content,omitempty"` + Desc string `json:"desc,omitempty"` + DueDate string `json:"dueDate,omitempty"` + Items []interface{} `json:"items,omitempty"` + Priority int `json:"priority,omitempty"` + Reminders []string `json:"reminders,omitempty"` + RepeatFlag string `json:"repeatFlag,omitempty"` + SortOrder int64 `json:"sortOrder,omitempty"` + StartDate string `json:"startDate,omitempty"` + Status int32 `json:"status,omitempty"` + TimeZone string `json:"timeZone,omitempty"` +} + +type ProjectData struct { + Project Project `json:"project"` + Tasks []Task `json:"tasks"` + Columns []Column `json:"columns,omitempty"` +} + +func Init() { + if !viper.InConfig("ticktick.clientId") { + slog.Error("TickTick clientId not found in config file, exiting..") + os.Exit(1) + } + if !viper.InConfig("ticktick.clientSecret") { + slog.Error("TickTick clientSecret not found in config file, exiting..") + os.Exit(1) + } + if viper.InConfig("ticktick.token") { + _, err := getProjects() + if err != nil { + if err.Error() == "error response from TickTick: 401 Unauthorized" { + } + } + } else { + beginAuth() + } +} + +func HandleAuthCode(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + if state != authState { + slog.Warn(fmt.Sprintln("HandleAuthCode Invalid state", state)) + http.Error(w, "Invalid state", http.StatusBadRequest) + return + } + params := map[string]string{ + "code": code, + "grant_type": "authorization_code", + "scope": "tasks:read tasks:write", + "redirect_uri": viper.GetString("ticktick.redirectUri"), + } + formedParams := url.Values{} + for key, value := range params { + formedParams.Add(key, value) + } + + req, err := http.NewRequest("POST", "https://ticktick.com/oauth/token", bytes.NewBufferString(formedParams.Encode())) + if err != nil { + slog.Warn(fmt.Sprintln("HandleAuthCode Error creating request", err)) + http.Error(w, "Error creating request", http.StatusInternalServerError) + return + } + client := &http.Client{ + Timeout: time.Second * 10, + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(viper.GetString("ticktick.clientId"), viper.GetString("ticktick.clientSecret")) + resp, err := client.Do(req) + if err != nil { + slog.Warn(fmt.Sprintln("HandleAuthCode Error sending request", err)) + http.Error(w, "Error sending request", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + slog.Warn(fmt.Sprintln("HandleAuthCode Unexpected response status", resp.StatusCode)) + http.Error(w, "Unexpected response status", http.StatusInternalServerError) + return + } + decoder := json.NewDecoder(resp.Body) + var tokenResponse map[string]interface{} + err = decoder.Decode(&tokenResponse) + if err != nil { + slog.Warn(fmt.Sprintln("HandleAuthCode Error decoding response", err)) + http.Error(w, "Error decoding response", http.StatusInternalServerError) + return + } + token := tokenResponse["access_token"].(string) + viper.Set("ticktick.token", token) + err = viper.WriteConfig() + if err != nil { + slog.Warn(fmt.Sprintln("HandleAuthCode Error writing config", err)) + http.Error(w, "Error writing config", http.StatusInternalServerError) + return + } + w.Write([]byte("Authorization successful")) +} + +func GetTasks(projectId string) []Task { + getTaskUrl := fmt.Sprintf("https://api.ticktick.com/open/v1/project/%s/data", projectId) + token := viper.GetString("ticktick.token") + req, err := http.NewRequest("GET", getTaskUrl, nil) + req.Header.Set("Authorization", "Bearer "+token) + if err != nil { + slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error creating request to TickTick", err)) + return nil + } + var projectData ProjectData + client := &http.Client{ + Timeout: time.Second * 10, + } + resp, err := client.Do(req) + if err != nil { + slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error sending request to TickTick", err)) + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { + slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error response from TickTick", resp.Status)) + return nil + } + if resp.StatusCode == http.StatusNotFound { + return nil + } + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&projectData) + if err != nil { + slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error decoding response from TickTick", err)) + return nil + } + + return projectData.Tasks +} + +func HasDuplicateTask(projectId string, taskTitile string) bool { + tasks := GetTasks(projectId) + for _, task := range tasks { + if task.Title == taskTitile { + return true + } + } + return false +} + +func CreateTask(task Task) error { + if HasDuplicateTask(task.ProjectId, task.Title) { + return nil + } + token := viper.GetString("ticktick.token") + createTaskUrl := "https://api.ticktick.com/open/v1/task" + payload, err := json.Marshal(task) + if err != nil { + slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error marshalling", err)) + return err + } + req, err := http.NewRequest("POST", createTaskUrl, bytes.NewBuffer(payload)) + if err != nil { + slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error creating request to TickTick", err)) + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{ + Timeout: time.Second * 10, + } + resp, err := client.Do(req) + if err != nil { + slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error sending request to TickTick", err)) + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error response from TickTick", resp.Status)) + return fmt.Errorf("error response from TickTick: %s", resp.Status) + } + return nil +} + +func getProjects() ([]Project, error) { + token := viper.GetString("ticktick.token") + req, err := http.NewRequest("GET", "https://api.ticktick.com/open/v1/project/", nil) + req.Header.Set("Authorization", "Bearer "+token) + if err != nil { + slog.Warn(fmt.Sprintln("Error creating request to TickTick", err)) + return nil, err + } + client := &http.Client{ + Timeout: time.Second * 10, + } + resp, err := client.Do(req) + if err != nil { + slog.Warn(fmt.Sprintln("Error sending request to TickTick", err)) + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + slog.Warn(fmt.Sprintln("Error response from TickTick", resp.Status)) + return nil, fmt.Errorf("error response from TickTick: %s", resp.Status) + } + var projects []Project + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&projects) + if err != nil { + slog.Warn(fmt.Sprintln("Error decoding response from TickTick", err)) + return nil, err + } + return projects, nil +} + +func beginAuth() { + if !viper.InConfig("ticktick.redirectUri") { + slog.Error("TickTick redirectUri not found in config file, exiting..") + os.Exit(1) + } + baseUrl := "https://ticktick.com/oauth/authorize?" + authUrl, _ := url.Parse(baseUrl) + authStateBytes := make([]byte, 6) + _, err := rand.Read(authStateBytes) + if err != nil { + slog.Error(fmt.Sprintln("Error generating auth state", err)) + os.Exit(1) + } + authState = hex.EncodeToString(authStateBytes) + params := url.Values{} + params.Add("client_id", viper.GetString("ticktick.clientId")) + params.Add("response_type", "code") + params.Add("redirect_uri", viper.GetString("ticktick.redirectUri")) + params.Add("state", authState) + params.Add("scope", "tasks:read tasks:write") + authUrl.RawQuery = params.Encode() + slog.Info(fmt.Sprintln("Please visit the following URL to authorize TickTick:", authUrl.String())) +}