Add homeassistant to ticktick
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/t-liu93/home-automation-backend/components/locationRecorder"
|
"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/components/pooRecorder"
|
||||||
"github.com/t-liu93/home-automation-backend/util/notion"
|
"github.com/t-liu93/home-automation-backend/util/notion"
|
||||||
|
"github.com/t-liu93/home-automation-backend/util/ticktick"
|
||||||
)
|
)
|
||||||
|
|
||||||
var port string
|
var port string
|
||||||
@@ -41,6 +42,8 @@ func initUtil() {
|
|||||||
slog.Error("Notion token not found in config file, exiting..")
|
slog.Error("Notion token not found in config file, exiting..")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// init ticktick
|
||||||
|
ticktick.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
func initComponent() {
|
func initComponent() {
|
||||||
@@ -55,8 +58,8 @@ func serve(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
viper.SetConfigName("config") // name of config file (without extension)
|
viper.SetConfigName("config") // name of config file (without extension)
|
||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
|
viper.AddConfigPath(".") // . is used for dev
|
||||||
viper.AddConfigPath("$HOME/.config/home-automation")
|
viper.AddConfigPath("$HOME/.config/home-automation")
|
||||||
viper.AddConfigPath(".") // optionally look for config in the working directory
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error(fmt.Sprintf("Cannot read config file, %s, exiting..", err))
|
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("/location/record", locationRecorder.HandleRecordLocation).Methods("POST")
|
||||||
|
|
||||||
|
router.HandleFunc("/ticktick/auth/code", ticktick.HandleAuthCode).Methods("GET")
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"github.com/t-liu93/home-automation-backend/util/ticktick"
|
||||||
)
|
)
|
||||||
|
|
||||||
type haMessage struct {
|
type haMessage struct {
|
||||||
@@ -17,6 +18,11 @@ type haMessage struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type actionTask struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
DueHour int `json:"due_hour"`
|
||||||
|
}
|
||||||
|
|
||||||
func HandleHaMessage(w http.ResponseWriter, r *http.Request) {
|
func HandleHaMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
var message haMessage
|
var message haMessage
|
||||||
decoder := json.NewDecoder(r.Body)
|
decoder := json.NewDecoder(r.Body)
|
||||||
@@ -33,6 +39,8 @@ func HandleHaMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
handlePooRecorderMsg(message)
|
handlePooRecorderMsg(message)
|
||||||
case "location_recorder":
|
case "location_recorder":
|
||||||
handleLocationRecorderMsg(message)
|
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() {
|
func handleGetLatestPoo() {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: time.Second * 1,
|
Timeout: time.Second * 1,
|
||||||
@@ -76,3 +91,31 @@ func handleGetLatestPoo() {
|
|||||||
slog.Warn(fmt.Sprintln("handleGetLatestPoo Error sending request to poo recorder", err))
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
284
src/util/ticktick/ticktick.go
Normal file
284
src/util/ticktick/ticktick.go
Normal file
@@ -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()))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user