Files
home-automation-backend/src/util/ticktickutil/ticktickutil.go
2025-05-19 20:25:53 +02:00

298 lines
9.1 KiB
Go

package ticktickutil
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"time"
"github.com/spf13/viper"
)
const (
DateTimeLayout = "2006-01-02T15:04:05-0700"
)
type (
TicktickUtil interface {
HandleAuthCode(w http.ResponseWriter, r *http.Request)
GetTasks(projectId string) []Task
HasDuplicateTask(projectId string, taskTitile string) bool
CreateTask(task Task) error
}
TicktickUtilImpl struct {
authState string
}
)
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"`
}
Column struct {
Id string `json:"id"`
Name string `json:"name"`
ProjectId string `json:"projectId"`
SortOrder int64 `json:"sortOrder,omitempty"`
}
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"`
}
ProjectData struct {
Project Project `json:"project"`
Tasks []Task `json:"tasks"`
Columns []Column `json:"columns,omitempty"`
}
)
func Init() TicktickUtil { // TODO: Will modify Init to a proper behavior
ticktickUtilImpl := &TicktickUtilImpl{}
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 {
ticktickUtilImpl.beginAuth()
}
return ticktickUtilImpl
}
func (t *TicktickUtilImpl) HandleAuthCode(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
if state != t.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 (t *TicktickUtilImpl) 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 (t *TicktickUtilImpl) HasDuplicateTask(projectId string, taskTitile string) bool {
tasks := t.GetTasks(projectId)
for _, task := range tasks {
if task.Title == taskTitile {
return true
}
}
return false
}
func (t *TicktickUtilImpl) CreateTask(task Task) error {
if t.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 (t *TicktickUtilImpl) 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)
}
t.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", t.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()))
}