Bootstrap Python rewrite skeleton

This commit is contained in:
2026-04-19 20:19:58 +02:00
parent 7818a3fb44
commit 31390882ef
72 changed files with 2273 additions and 62 deletions
+18
View File
@@ -0,0 +1,18 @@
# Legacy Code
这个目录用于收纳 Python 重构开始之前就已存在的旧实现与配套资产,方便在重构完成后整块删除。
当前已迁入:
- `go-backend/src/`
- 旧 Go 后端实现
- `go-backend/helper/`
- 旧 Go 部署与辅助脚本
- `go-backend/.github/workflows/`
- 旧 Go 版本对应的 GitHub Actions workflows
原则上:
- 新的 Python 实现继续在仓库根目录的 `app/``tests/``alembic/` 等目录演进
- 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础
- 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/`
+22
View File
@@ -0,0 +1,22 @@
name: Run nightly tests
on:
schedule:
- cron: '0 20 * * *' # Every day at 20:00 UTC
push:
branches:
- main
jobs:
nightly-tests:
runs-on: [ubuntu-latest, cloud]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Test
working-directory: ./src
run: go test -v --short ./...
+21
View File
@@ -0,0 +1,21 @@
name: Run short tests
on:
push:
pull_request:
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
- name: Run short tests with coverage
working-directory: ./src
run: | # TODO: at this moment only Home Assistant component is tested
go test -v --short ./components/homeassistant/... -cover -coverprofile=cover.out
@@ -0,0 +1,15 @@
[program:home_automation_backend]
command=
directory=
user=
group=
environment=
autostart=true
autorestart=true
startsecs=15
startretries=100
stopwaitsecs=30
redirect_stderr=true
stdout_logfile=/var/log/supervisor/%(program_name)s.log
stdout_logfile_maxbytes=5MB
stdout_logfile_backups=5
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/bash
# Argument parsing
if [[ $# -ne 1 ]]; then
echo "Usage: $0 [--install|--uninstall|--help]"
echo " --install Install the automation backend"
echo " --uninstall Uninstall the automation backend"
echo " --update Update the installation"
echo " --help Show this help message"
exit 0
fi
key="$1"
case $key in
--install)
INSTALL=true
;;
--uninstall)
UNINSTALL=true
;;
--update)
UPDATE=true
;;
--help)
echo "Usage: $0 [--install|--uninstall|--update|--help]"
echo " --install Install the automation backend"
echo " --uninstall Uninstall the automation backend"
echo " --update Update the installation"
echo " --help Show this help message"
exit 0
;;
*)
echo "Invalid argument: $key"
exit 1
;;
esac
TARGET_DIR="$HOME/.local/home-automation-backend"
SUPERVISOR_CFG_NAME="home_automation_backend"
APP_NAME="home-automation-backend"
SUPERVISOR_CFG="$SUPERVISOR_CFG_NAME.conf"
BASEDIR=$(dirname "$(realpath "$0")")
# Install or uninstall based on arguments
install_backend() {
# Installation code here
echo "Installing..."
sudo supervisorctl stop $SUPERVISOR_CFG_NAME
mkdir -p $TARGET_DIR
cd $BASEDIR"/../src/" && go build -o $TARGET_DIR/$APP_NAME
cp $BASEDIR/"$SUPERVISOR_CFG_NAME"_template.conf $BASEDIR/$SUPERVISOR_CFG
sed -i "s+command=+command=$TARGET_DIR/$APP_NAME serve+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+directory=+directory=$TARGET_DIR+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+user=+user=$USER+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+group=+group=$USER+g" $BASEDIR/$SUPERVISOR_CFG
sed -i "s+environment=+environment=HOME=\"$HOME\"+g" $BASEDIR/$SUPERVISOR_CFG
sudo mv $BASEDIR/$SUPERVISOR_CFG /etc/supervisor/conf.d/$SUPERVISOR_CFG
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start $SUPERVISOR_CFG_NAME
echo "Installation complete."
}
uninstall_backend() {
# Uninstallation code here
echo "Uninstalling..."
sudo supervisorctl stop $SUPERVISOR_CFG_NAME
sudo supervisorctl remove $SUPERVISOR_CFG_NAME
sudo rm /etc/supervisor/conf.d/$SUPERVISOR_CFG
rm -rf $TARGET_DIR/
echo "Uninstallation complete."
echo "Config files and db is stored in $HOME/.config/home-automation"
}
update_backend() {
uninstall_backend
install_backend
}
if [[ $INSTALL ]]; then
install_backend
elif [[ $UNINSTALL ]]; then
uninstall_backend
elif [[ $UPDATE ]]; then
update_backend
else
echo "Invalid argument: $key"
exit 1
fi
View File
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "home-automation-backend",
Short: "This is the entry point of the home automation backend",
Long: `Home automation backend is a RESTful API server that provides
automation features for may devices.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.home-automation-backend.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
}
+161
View File
@@ -0,0 +1,161 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/gorilla/mux"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/t-liu93/home-automation-backend/components/homeassistant"
"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/ticktickutil"
)
var (
port string
scheduler gocron.Scheduler
ticktick ticktickutil.TicktickUtil
ha *homeassistant.HomeAssistant
)
// serveCmd represents the serve command
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Server automation backend",
Run: serve,
}
func initUtil() {
// init notion
if viper.InConfig("notion.token") {
notion.Init(viper.GetString("notion.token"))
} else {
slog.Error("Notion token not found in config file, exiting..")
os.Exit(1)
}
// init ticktick
ticktick = ticktickutil.Init()
}
func initComponent() {
// init pooRecorder
pooRecorder.Init(&scheduler)
// init location recorder
locationRecorder.Init()
// init homeassistant
ha = homeassistant.NewHomeAssistant(ticktick)
}
func serve(cmd *cobra.Command, args []string) {
slog.Info("Starting server..")
viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("yaml")
viper.AddConfigPath(".") // . is used for dev
viper.AddConfigPath("$HOME/.config/home-automation")
err := viper.ReadInConfig()
if err != nil {
slog.Error(fmt.Sprintf("Cannot read config file, %s, exiting..", err))
os.Exit(1)
}
viper.WatchConfig()
viper.SetDefault("logLevel", "info")
logLevelCfg := viper.GetString("logLevel")
switch logLevelCfg {
case "debug":
slog.SetLogLoggerLevel(slog.LevelDebug)
case "info":
slog.SetLogLoggerLevel(slog.LevelInfo)
case "warn":
slog.SetLogLoggerLevel(slog.LevelWarn)
case "error":
slog.SetLogLoggerLevel(slog.LevelError)
}
if viper.InConfig("port") {
port = viper.GetString("port")
} else {
slog.Error("Port not found in config file, exiting..")
os.Exit(1)
}
scheduler, err = gocron.NewScheduler()
defer scheduler.Shutdown()
if err != nil {
slog.Error(fmt.Sprintf("Cannot create scheduler, %s, exiting..", err))
os.Exit(1)
}
initUtil()
initComponent()
scheduler.Start()
// routing
router := mux.NewRouter()
router.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}).Methods("GET")
router.HandleFunc("/poo/latest", pooRecorder.HandleNotifyLatestPoo).Methods("GET")
router.HandleFunc("/poo/record", pooRecorder.HandleRecordPoo).Methods("POST")
router.HandleFunc("/homeassistant/publish", ha.HandleHaMessage).Methods("POST")
router.HandleFunc("/location/record", locationRecorder.HandleRecordLocation).Methods("POST")
router.HandleFunc("/ticktick/auth/code", ticktick.HandleAuthCode).Methods("GET")
srv := &http.Server{
Addr: ":" + port,
Handler: router,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error(fmt.Sprintf("ListenAndServe error: %v", err))
os.Exit(1)
}
}()
slog.Info(fmt.Sprintln("Server started on port", port))
<-stop
slog.Info(fmt.Sprintln("Shutting down the server..."))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error(fmt.Sprintf("Server Shutdown Failed:%+v", err))
os.Exit(1)
}
slog.Info(fmt.Sprintln("Server gracefully stopped"))
}
func init() {
rootCmd.AddCommand(serveCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// serveCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
serveCmd.Flags().StringVarP(&port, "port", "p", "18881", "Port to listen on")
}
@@ -0,0 +1,152 @@
package homeassistant
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/spf13/viper"
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
)
type haMessage struct {
Target string `json:"target"`
Action string `json:"action"`
Content string `json:"content"`
}
type HomeAssistant struct {
ticktickUtil ticktickutil.TicktickUtil
}
type actionTask struct {
Action string `json:"action"`
DueHour int `json:"due_hour"`
}
func NewHomeAssistant(ticktick ticktickutil.TicktickUtil) *HomeAssistant {
return &HomeAssistant{
ticktickUtil: ticktick,
}
}
func (ha *HomeAssistant) HandleHaMessage(w http.ResponseWriter, r *http.Request) {
var message haMessage
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&message)
if err != nil {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error decoding request body", err))
http.Error(w, "", http.StatusInternalServerError)
return
}
switch message.Target {
case "poo_recorder":
res := ha.handlePooRecorderMsg(message)
if !res {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling poo recorder message"))
http.Error(w, "", http.StatusInternalServerError)
}
case "location_recorder":
res := ha.handleLocationRecorderMsg(message)
if !res {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling location recorder message"))
http.Error(w, "", http.StatusInternalServerError)
}
case "ticktick":
res := ha.handleTicktickMsg(message)
if !res {
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling ticktick message"))
http.Error(w, "", http.StatusInternalServerError)
}
default:
slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Unknown target", message.Target))
http.Error(w, "", http.StatusInternalServerError)
}
}
func (ha *HomeAssistant) handlePooRecorderMsg(message haMessage) bool {
switch message.Action {
case "get_latest":
return ha.handleGetLatestPoo()
default:
slog.Warn(fmt.Sprintln("homeassistant.handlePooRecorderMsg: Unknown action", message.Action))
return false
}
}
func (ha *HomeAssistant) handleLocationRecorderMsg(message haMessage) bool {
if message.Action == "record" {
port := viper.GetString("port")
client := &http.Client{
Timeout: time.Second * 1,
}
_, err := client.Post("http://localhost:"+port+"/location/record", "application/json", strings.NewReader(strings.ReplaceAll(message.Content, "'", "\"")))
if err != nil {
slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Error sending request to location recorder", err))
return false
}
} else {
slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Unknown action", message.Action))
return false
}
return true
}
func (ha *HomeAssistant) handleTicktickMsg(message haMessage) bool {
switch message.Action {
case "create_action_task":
return ha.createActionTask(message)
default:
slog.Warn(fmt.Sprintln("homeassistant.handleTicktickMsg: Unknown action", message.Action))
return false
}
}
func (ha *HomeAssistant) handleGetLatestPoo() bool {
client := &http.Client{
Timeout: time.Second * 1,
}
port := viper.GetString("port")
_, err := client.Get("http://localhost:" + port + "/poo/latest")
if err != nil {
slog.Warn(fmt.Sprintln("homeassistant.handleGetLatestPoo: Error sending request to poo recorder", err))
return false
}
return true
}
func (ha *HomeAssistant) createActionTask(message haMessage) bool {
if !viper.IsSet("homeassistant.actionTaskProjectId") {
slog.Warn("homeassistant.createActionTask: actionTaskProjectId not found in config file")
return false
}
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 false
}
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(ticktickutil.DateTimeLayout)
ticktickTask := ticktickutil.Task{
ProjectId: projectId,
Title: task.Action,
DueDate: dueTicktick,
}
err = ha.ticktickUtil.CreateTask(ticktickTask)
if err != nil {
slog.Warn(fmt.Sprintf("homeassistant.createActionTask: Error creating task %s", err))
return false
}
return true
}
@@ -0,0 +1,280 @@
package homeassistant
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/t-liu93/home-automation-backend/util/ticktickutil"
)
var (
loggerText = new(bytes.Buffer)
)
type MockTicktickUtil struct {
mock.Mock
}
func (m *MockTicktickUtil) HandleAuthCode(w http.ResponseWriter, r *http.Request) {
m.Called(w, r)
}
func (m *MockTicktickUtil) GetTasks(projectId string) []ticktickutil.Task {
args := m.Called(projectId)
return args.Get(0).([]ticktickutil.Task)
}
func (m *MockTicktickUtil) HasDuplicateTask(projectId string, taskTitile string) bool {
args := m.Called(projectId, taskTitile)
return args.Bool(0)
}
func (m *MockTicktickUtil) CreateTask(task ticktickutil.Task) error {
args := m.Called(task)
return args.Error(0)
}
func SetupTearDown(t *testing.T) (func(), *HomeAssistant) {
loggertearDown := loggerSetupTeardown()
mockTicktick := &MockTicktickUtil{}
ha := NewHomeAssistant(mockTicktick)
return func() {
loggertearDown()
viper.Reset()
}, ha
}
func loggerSetupTeardown() func() {
logger := slog.New(slog.NewTextHandler(loggerText, nil))
defaultLogger := slog.Default()
slog.SetDefault(logger)
return func() {
slog.SetDefault(defaultLogger)
loggerText.Reset()
}
}
func TestHandleHaMessageJsonDecodeError(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
invalidRequestBody := ` { "target": "poo_recorder", "action": "get_latest", "content": " }`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Error decoding request body")
}
func TestHandlePooRecorderMsgGetLatest(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/poo/latest", r.URL.Path)
}))
defer server.Close()
port := strings.Split(server.URL, ":")[2]
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, loggerText.String())
}
func TestHandlePooRecorderMsgUnknownAction(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "poo_recorder", "action": "unknown_action", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handlePooRecorderMsg: Unknown action")
}
func TestHandlePooRecorderMsgGetLatestError(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
port := "invalid port"
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleGetLatestPoo: Error sending request to poo recorder")
}
func TestHandleLocationRecorderMsg(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/location/record", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
}))
defer server.Close()
port := strings.Split(server.URL, ":")[2]
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, loggerText.String())
}
func TestHandleLocationRecorderMsgUnknownAction(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "location_recorder", "action": "unknown_action", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Unknown action")
}
func TestHandleLocationRecorderMsgRequestErr(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
port := "invalid port"
viper.Set("port", port)
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Error sending request to location recorder")
}
func TestHandleTicktickMsgCreateActionTask(t *testing.T) {
teardown, _ := SetupTearDown(t)
defer teardown()
const expectedProjectId = "test_project_id"
const dueHour = 12
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(ticktickutil.DateTimeLayout)
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
mockTicktick := &MockTicktickUtil{}
mockTicktick.On("CreateTask", mock.Anything).Return(nil)
ha := NewHomeAssistant(mockTicktick)
viper.Set("homeassistant.actionTaskProjectId", expectedProjectId)
ha.HandleHaMessage(w, req)
expectedTask := ticktickutil.Task{
Title: "test_action",
DueDate: dueTicktick,
ProjectId: expectedProjectId,
}
mockTicktick.AssertCalled(t, "CreateTask", expectedTask)
mockTicktick.AssertNumberOfCalls(t, "CreateTask", 1)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, loggerText.String())
}
func TestHandleTicktickMsgUnknownAction(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "ticktick", "action": "unknown_action", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.handleTicktickMsg: Unknown action")
}
func TestHandleTicktickMsgProjectIdUnset(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: actionTaskProjectId not found in config file")
}
func TestHandleTicktickMsgJsonError(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
invalidRequestBody := ` { "target": "ticktick", "action": "create_action_task", "content": "{'title': 'tes, 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody))
w := httptest.NewRecorder()
viper.Set("homeassistant.actionTaskProjectId", "some project id")
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error unmarshalling")
}
func TestHandleTicktickMsgTicktickUtilErr(t *testing.T) {
teardown, _ := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
mockedTicktickUtil := &MockTicktickUtil{}
viper.Set("homeassistant.actionTaskProjectId", "some project id")
mockedTicktickUtil.On("CreateTask", mock.Anything).Return(errors.New("some error"))
ha := NewHomeAssistant(mockedTicktickUtil)
ha.HandleHaMessage(w, req)
mockedTicktickUtil.AssertCalled(t, "CreateTask", mock.Anything)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error creating task")
}
func TestHandleHaMessageUnknownTarget(t *testing.T) {
teardown, ha := SetupTearDown(t)
defer teardown()
requestBody := `{"target": "unknown_target", "action": "record", "content": ""}`
req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody))
w := httptest.NewRecorder()
ha.HandleHaMessage(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Unknown target")
}
@@ -0,0 +1,194 @@
package locationRecorder
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"github.com/spf13/viper"
)
var (
db *sql.DB
)
const (
currentDBVersion = 2
)
type Location struct {
Person string `json:"person"`
DateTime string `json:"datetime"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude sql.NullFloat64 `json:"altitude,omitempty"`
}
type LocationContent struct {
Person string `json:"person"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
Altitude string `json:"altitude,omitempty"`
}
func Init() {
initDb()
}
func HandleRecordLocation(w http.ResponseWriter, r *http.Request) {
var location LocationContent
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&location)
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordLocation Error decoding request body", err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
latiF64, _ := strconv.ParseFloat(location.Latitude, 64)
longiF64, _ := strconv.ParseFloat(location.Longitude, 64)
altiF64, _ := strconv.ParseFloat(location.Altitude, 64)
InsertLocationNow(location.Person, latiF64, longiF64, altiF64)
}
func InsertLocation(person string, datetime time.Time, latitude float64, longitude float64, altitude float64) {
_, err := db.Exec(`INSERT OR IGNORE INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`,
person, datetime.UTC().Format(time.RFC3339), latitude, longitude, altitude)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorder.InsertLocation Error inserting location", err))
}
}
func InsertLocationNow(person string, latitude float64, longitude float64, altitude float64) {
InsertLocation(person, time.Now(), latitude, longitude, altitude)
}
func initDb() {
if !viper.InConfig("locationRecorder.dbPath") {
slog.Info("LocationRecorderInit dbPath not found in config file, using default: location_recorder.db")
viper.SetDefault("locationRecorder.dbPath", "location_recorder.db")
}
dbPath := viper.GetString("locationRecorder.dbPath")
err := error(nil)
db, err = sql.Open("sqlite", dbPath)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error opening database", err))
os.Exit(1)
}
err = db.Ping()
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error pinging database", err))
os.Exit(1)
}
migrateDb()
}
func migrateDb() {
var userVersion int
err := db.QueryRow("PRAGMA user_version").Scan(&userVersion)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error getting db user version", err))
os.Exit(1)
}
if userVersion == 0 {
migrateDb0To1(&userVersion)
}
if userVersion == 1 {
migrateDb1To2(&userVersion)
}
if userVersion != currentDBVersion {
slog.Error(fmt.Sprintln("LocationRecorderInit Error unsupported database version", userVersion))
os.Exit(1)
}
}
func migrateDb0To1(userVersion *int) {
// this is actually create new db
slog.Info("Creating location recorder database version 1..")
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime))`)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error creating table", err))
os.Exit(1)
}
_, err = db.Exec(`PRAGMA user_version = 1`)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error setting user version to 1", err))
os.Exit(1)
}
*userVersion = 1
}
func migrateDb1To2(userVersion *int) {
// this will change the datetime format into Real RFC3339
slog.Info("Migrating location recorder database version 1 to 2..")
dbTx, err := db.Begin()
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit DB1To2 Error beginning transaction", err))
os.Exit(1)
}
fail := func(err error, step string) {
slog.Error(fmt.Sprintf("LocationRecorderInit DB1To2 Error %s: %s", step, err))
dbTx.Rollback()
os.Exit(1)
}
_, err = dbTx.Exec(`ALTER TABLE location RENAME TO location_old`)
if err != nil {
fail(err, "renaming table")
}
_, err = dbTx.Exec(`CREATE TABLE IF NOT EXISTS location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime))`)
if err != nil {
fail(err, "creating new table")
}
row, err := dbTx.Query(`SELECT person, datetime, latitude, longitude, altitude FROM location_old`)
if err != nil {
fail(err, "selecting from old table")
}
defer row.Close()
for row.Next() {
var location Location
err = row.Scan(&location.Person, &location.DateTime, &location.Latitude, &location.Longitude, &location.Altitude)
if err != nil {
fail(err, "scanning row")
}
dateTime, err := time.Parse("2006-01-02T15:04:05-0700", location.DateTime)
if err != nil {
fail(err, "parsing datetime")
}
_, err = dbTx.Exec(`INSERT INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`, location.Person, dateTime.UTC().Format(time.RFC3339), location.Latitude, location.Longitude, location.Altitude)
if err != nil {
fail(err, "inserting new row")
}
}
_, err = dbTx.Exec(`DROP TABLE location_old`)
if err != nil {
fail(err, "dropping old table")
}
_, err = dbTx.Exec(`PRAGMA user_version = 2`)
if err != nil {
slog.Error(fmt.Sprintln("LocationRecorderInit Error setting user version to 2", err))
os.Exit(1)
}
dbTx.Commit()
*userVersion = 2
}
@@ -0,0 +1,366 @@
package pooRecorder
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/jomei/notionapi"
"github.com/spf13/viper"
"github.com/t-liu93/home-automation-backend/util/homeassistantutil"
"github.com/t-liu93/home-automation-backend/util/notion"
_ "modernc.org/sqlite"
)
var (
db *sql.DB
scheduler *gocron.Scheduler
)
type recordDetail struct {
Status string `json:"status"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
}
type pooStatusSensorAttributes struct {
LastPoo string `json:"last_poo"`
FriendlyName string `json:"friendly_name,"`
}
type pooStatusWebhookBody struct {
Status string `json:"status"`
}
type pooStatusDbEntry struct {
Timestamp string
Status string
Latitude float64
Longitude float64
}
func Init(mainScheduler *gocron.Scheduler) {
initDb()
initScheduler(mainScheduler)
notionDbSync()
publishLatestPooSensor()
}
func HandleRecordPoo(w http.ResponseWriter, r *http.Request) {
var record recordDetail
if !viper.InConfig("pooRecorder.tableId") {
slog.Warn("HandleRecordPoo Table ID not found in config file")
http.Error(w, "Table ID not found in config file", http.StatusInternalServerError)
return
}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&record)
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Error decoding request body", err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
now := time.Now()
err = storeStatus(record, now)
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Error storing status", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
publishLatestPooSensor()
if viper.InConfig("pooRecorder.webhookId") {
homeassistantutil.TriggerWebhook(viper.GetString("pooRecorder.webhookId"), pooStatusWebhookBody{Status: record.Status})
} else {
slog.Warn("HandleRecordPoo Webhook ID not found in config file")
}
}
func HandleNotifyLatestPoo(w http.ResponseWriter, r *http.Request) {
err := publishLatestPooSensor()
if err != nil {
slog.Warn(fmt.Sprintln("HandleNotifyLatestPoo Error publishing latest poo", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
slog.Debug(fmt.Sprintln("HandleGetLatestPoo Latest poo"))
}
func publishLatestPooSensor() error {
var latest pooStatusDbEntry
err := db.QueryRow(`SELECT timestamp, status, latitude, longitude FROM poo_records ORDER BY timestamp DESC LIMIT 1`).Scan(&latest.Timestamp, &latest.Status, &latest.Latitude, &latest.Longitude)
if err != nil {
slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error getting latest poo", err))
return err
}
recordTime, err := time.Parse("2006-01-02T15:04Z07:00", latest.Timestamp)
if err != nil {
slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error parsing timestamp", err))
return err
}
viper.SetDefault("pooRecorder.sensorEntityName", "sensor.test_poo_status")
viper.SetDefault("pooRecorder.sensorFriendlyName", "Poo Status")
sensorEntityName := viper.GetString("pooRecorder.sensorEntityName")
sensorFriendlyName := viper.GetString("pooRecorder.sensorFriendlyName")
recordTime = recordTime.Local()
pooStatus := homeassistantutil.HttpSensor{
EntityId: sensorEntityName,
State: latest.Status,
Attributes: pooStatusSensorAttributes{
LastPoo: recordTime.Format("Mon | 2006-01-02 | 15:04"),
FriendlyName: sensorFriendlyName,
},
}
homeassistantutil.PublishSensor(pooStatus)
return nil
}
func initDb() {
if !viper.InConfig("pooRecorder.dbPath") {
slog.Info("PooRecorderInit dbPath not found in config file, using default: pooRecorder.db")
viper.SetDefault("pooRecorder.dbPath", "pooRecorder.db")
}
dbPath := viper.GetString("pooRecorder.dbPath")
err := error(nil)
db, err = sql.Open("sqlite", dbPath)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error opening database", err))
os.Exit(1)
}
err = db.Ping()
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error pinging database", err))
os.Exit(1)
}
migrateDb()
}
func migrateDb() {
var userVersion int
err := db.QueryRow("PRAGMA user_version").Scan(&userVersion)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error getting db user version", err))
os.Exit(1)
}
if userVersion == 0 {
migrateDb0To1(&userVersion)
}
}
func migrateDb0To1(userVersion *int) {
// this is actually create new db
slog.Info("Creating database version 1..")
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS poo_records (
timestamp TEXT NOT NULL,
status TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
PRIMARY KEY (timestamp))`)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error creating table", err))
os.Exit(1)
}
_, err = db.Exec(`PRAGMA user_version = 1`)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error setting user version to 1", err))
os.Exit(1)
}
*userVersion = 1
}
func initScheduler(mainScheduler *gocron.Scheduler) {
scheduler = mainScheduler
_, err := (*scheduler).NewJob(gocron.CronJob("0 5 * * *", false), gocron.NewTask(
notionDbSync,
))
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderInit Error creating scheduled task", err))
os.Exit(1)
}
}
func notionDbSync() {
slog.Info("PooRecorder Running DB sync with Notion..")
if !viper.InConfig("pooRecorder.tableId") {
slog.Warn("PooRecorder Table ID not found in config file, sync aborted")
return
}
tableId := viper.GetString("pooRecorder.tableId")
rowsNotion, err := notion.GetAllTableRows(tableId)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get table header", err))
return
}
header := rowsNotion[0]
rowsNotion = rowsNotion[1:] // remove header
rowsDb, err := db.Query(`SELECT * FROM poo_records`)
rowsDbMap := make(map[string]pooStatusDbEntry)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err))
return
}
defer rowsDb.Close()
for rowsDb.Next() {
var row pooStatusDbEntry
err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err))
return
}
rowsDbMap[row.Timestamp] = row
}
// notion to db
syncNotionToDb(rowsNotion, rowsDbMap)
// db to notion
syncDbToNotion(header.GetID().String(), tableId, rowsNotion)
}
func syncNotionToDb(rowsNotion []notionapi.TableRowBlock, rowsDbMap map[string]pooStatusDbEntry) {
counter := 0
for _, rowNotion := range rowsNotion {
rowNotionTimestamp := rowNotion.TableRow.Cells[0][0].PlainText + "T" + rowNotion.TableRow.Cells[1][0].PlainText
rowNotionTime, err := time.ParseInLocation("2006-01-02T15:04", rowNotionTimestamp, time.Now().Location())
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse timestamp", err))
return
}
rowNotionTimeInDbFormat := rowNotionTime.UTC().Format("2006-01-02T15:04Z07:00")
_, exists := rowsDbMap[rowNotionTimeInDbFormat]
if !exists {
locationNotion := rowNotion.TableRow.Cells[3][0].PlainText
latitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[0], 64)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse latitude to float", err))
return
}
longitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[1], 64)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse longitude to float", err))
return
}
_, err = db.Exec(`INSERT INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`,
rowNotionTimeInDbFormat, rowNotion.TableRow.Cells[2][0].PlainText, latitude, longitude)
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to insert new row", err))
return
}
counter++
}
}
slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from Notion to DB"))
}
func syncDbToNotion(headerId string, tableId string, rowsNotion []notionapi.TableRowBlock) {
counter := 0
var rowsDbSlice []pooStatusDbEntry
rowsDb, err := db.Query(`SELECT * FROM poo_records ORDER BY timestamp DESC`)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err))
return
}
defer rowsDb.Close()
for rowsDb.Next() {
var row pooStatusDbEntry
err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude)
if err != nil {
slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err))
return
}
rowsDbSlice = append(rowsDbSlice, row)
}
startFromId := headerId
for iNotion, iDb := 0, 0; iNotion < len(rowsNotion) && iDb < len(rowsDbSlice); {
notionTimeStamp := rowsNotion[iNotion].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion].TableRow.Cells[1][0].PlainText
notionTime, err := time.ParseInLocation("2006-01-02T15:04", notionTimeStamp, time.Now().Location())
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse notion timestamp", err))
return
}
notionTimeStampInDbFormat := notionTime.UTC().Format("2006-01-02T15:04Z07:00")
dbTimeStamp := rowsDbSlice[iDb].Timestamp
dbTime, err := time.Parse("2006-01-02T15:04Z07:00", dbTimeStamp)
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse db timestamp", err))
return
}
dbTimeLocal := dbTime.Local()
dbTimeDate := dbTimeLocal.Format("2006-01-02")
dbTimeTime := dbTimeLocal.Format("15:04")
if notionTimeStampInDbFormat == dbTimeStamp {
startFromId = rowsNotion[iNotion].GetID().String()
iNotion++
iDb++
continue
}
if iNotion != len(rowsNotion)-1 {
notionNextTimeStamp := rowsNotion[iNotion+1].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion+1].TableRow.Cells[1][0].PlainText
notionNextTime, err := time.ParseInLocation("2006-01-02T15:04", notionNextTimeStamp, time.Now().Location())
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse next notion timestamp", err))
return
}
if notionNextTime.After(notionTime) {
slog.Error(fmt.Sprintf("PooRecorderSyncDb Notion timestamp %s is after next timestamp %s, checking, aborting", notionTimeStamp, notionNextTimeStamp))
return
}
}
id, err := notion.WriteTableRow([]string{
dbTimeDate,
dbTimeTime,
rowsDbSlice[iDb].Status,
fmt.Sprintf("%s,%s",
strconv.FormatFloat(rowsDbSlice[iDb].Latitude, 'f', -1, 64),
strconv.FormatFloat(rowsDbSlice[iDb].Longitude, 'f', -1, 64))},
tableId,
startFromId)
if err != nil {
slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to write row to Notion", err))
return
}
startFromId = id
iDb++
counter++
time.Sleep(400 * time.Millisecond)
}
slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from DB to Notion"))
}
func storeStatus(record recordDetail, timestamp time.Time) error {
tableId := viper.GetString("pooRecorder.tableId")
recordDate := timestamp.Format("2006-01-02")
recordTime := timestamp.Format("15:04")
slog.Debug(fmt.Sprintln("Recording poo", record.Status, "at", record.Latitude, record.Longitude))
_, err := db.Exec(`INSERT OR IGNORE INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`,
timestamp.UTC().Format("2006-01-02T15:04Z07:00"), record.Status, record.Latitude, record.Longitude)
if err != nil {
return err
}
go func() {
header, err := notion.GetTableRows(tableId, 1, "")
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to get table header", err))
return
}
if len(header) == 0 {
slog.Warn("HandleRecordPoo Table header not found")
return
}
headerId := header[0].GetID()
_, err = notion.WriteTableRow([]string{recordDate, recordTime, record.Status, record.Latitude + "," + record.Longitude}, tableId, headerId.String())
if err != nil {
slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to write table row", err))
}
}()
return nil
}
+54
View File
@@ -0,0 +1,54 @@
module github.com/t-liu93/home-automation-backend
go 1.23.0
require (
github.com/go-co-op/gocron/v2 v2.11.0
github.com/gorilla/mux v1.8.1
github.com/jomei/notionapi v1.13.2
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.10.0
golang.org/x/term v0.24.0
modernc.org/sqlite v1.33.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+140
View File
@@ -0,0 +1,140 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jomei/notionapi v1.13.2 h1:YpHKNpkoTMlUfWTlVIodOmQDgRKjfwmtSNVa6/6yC9E=
github.com/jomei/notionapi v1.13.2/go.mod h1:BqzP6JBddpBnXvMSIxiR5dCoCjKngmz5QNl1ONDlDoM=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -0,0 +1,40 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// addgpxCmd represents the addgpx command
var addgpxCmd = &cobra.Command{
Use: "addgpx",
Short: "A brief description of your command",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("addgpx called")
},
}
func init() {
rootCmd.AddCommand(addgpxCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// addgpxCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// addgpxCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
@@ -0,0 +1,51 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "location_recorder",
Short: "A brief description of your application",
Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.location_recorder.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
@@ -0,0 +1,11 @@
/*
Copyright © 2024 Tianyu Liu
*/
package main
import "github.com/t-liu93/home-automation-backend/helper/location_recorder/cmd"
func main() {
cmd.Execute()
}
@@ -0,0 +1,127 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/jomei/notionapi"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var notionToken string
var notionTableId string
// reverseCmd represents the reverse command
var reverseCmd = &cobra.Command{
Use: "reverse",
Short: "Reverse given poo recording table",
Long: `Reverse the given poo recording table. Provide the Notion API token and the table ID to reverse.
The Notion API token can be obtained from https://www.notion.so/my-integrations. The table ID can be obtained from the URL of the table.
The token and table ID will be input in the following prompt.
`,
Run: readCredentials,
}
func readCredentials(cmd *cobra.Command, args []string) {
if notionToken == "" || notionTableId == "" {
fmt.Print("Enter Notion API token: ")
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatalf("failed to read NOTION API Token: %v", err)
}
notionToken = string(pw)
fmt.Print("\nEnter Notion table ID: ")
tableId, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatalf("failed to read NOTION table ID: %v", err)
}
notionTableId = string(tableId)
}
reverseRun()
}
func reverseRun() {
client := notionapi.NewClient(notionapi.Token(notionToken))
rows := []notionapi.Block{}
fmt.Println("Reverse table ID: ", notionTableId)
block, err := client.Block.Get(context.Background(), notionapi.BlockID(notionTableId))
if err != nil {
log.Fatalf("Failed to get table detail: %v", err)
}
if block.GetType().String() != "table" {
log.Fatalf("Block ID %s is not a table", notionTableId)
}
headerBlock, _ := client.Block.GetChildren(context.Background(), notionapi.BlockID(notionTableId), &notionapi.Pagination{
StartCursor: "",
PageSize: 100,
})
headerId := headerBlock.Results[0].GetID()
nextCursor := headerId.String()
hasMore := true
for hasMore {
blockChildren, _ := client.Block.GetChildren(context.Background(), notionapi.BlockID(notionTableId), &notionapi.Pagination{
StartCursor: notionapi.Cursor(nextCursor),
PageSize: 100,
})
rows = append(rows, blockChildren.Results...)
hasMore = blockChildren.HasMore
nextCursor = blockChildren.NextCursor
}
rows = rows[1:]
rowsR := reverseTable(rows)
nrRowsToDelete := len(rowsR)
for index, row := range rowsR {
client.Block.Delete(context.Background(), row.GetID())
if index%10 == 0 || index == nrRowsToDelete-1 {
fmt.Printf("Deleted %d/%d rows\n", index, nrRowsToDelete)
}
time.Sleep(400 * time.Millisecond)
}
after := headerId
fmt.Println("Writing rows back to table")
for len(rowsR) > 0 {
var rowsToWrite []notionapi.Block
if len(rowsR) > 100 {
rowsToWrite = rowsR[:100]
} else {
rowsToWrite = rowsR
}
client.Block.AppendChildren(context.Background(), notionapi.BlockID(notionTableId), &notionapi.AppendBlockChildrenRequest{
After: after,
Children: rowsToWrite,
})
after = rowsToWrite[len(rowsToWrite)-1].GetID()
rowsR = rowsR[len(rowsToWrite):]
}
}
func reverseTable[T any](rows []T) []T {
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
rows[i], rows[j] = rows[j], rows[i]
}
return rows
}
func init() {
rootCmd.AddCommand(reverseCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// reverseCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// reverseCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
reverseCmd.Flags().StringVar(&notionToken, "token", "", "Notion API token")
reverseCmd.Flags().StringVar(&notionTableId, "table-id", "", "Notion table id to reverse")
}
@@ -0,0 +1,39 @@
/*
Copyright © 2024 Tianyu Liu
*/
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "poo_recorder_helper",
Short: "Poo recorder helper executables.",
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.poo_recorder_helper.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
}
@@ -0,0 +1,11 @@
/*
Copyright © 2024 Tianyu Liu
*/
package main
import "github.com/t-liu93/home-automation-backend/helper/poo_recorder_helper/cmd"
func main() {
cmd.Execute()
}
+11
View File
@@ -0,0 +1,11 @@
/*
Copyright © 2024 Tianyu Liu
*/
package main
import "github.com/t-liu93/home-automation-backend/cmd"
func main() {
cmd.Execute()
}
@@ -0,0 +1,96 @@
package homeassistantutil
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/spf13/viper"
)
const (
ipField string = "homeassistant.ip"
portField string = "homeassistant.port"
authTokenField string = "homeassistant.authToken"
webhookPath string = "/api/webhook/"
sensorPath string = "/api/states/"
)
type HttpSensor struct {
EntityId string `json:"entity_id"`
State string `json:"state"`
Attributes interface{} `json:"attributes"`
}
type WebhookBody interface{}
func TriggerWebhook(webhookId string, body WebhookBody) {
if viper.InConfig(ipField) &&
viper.InConfig(portField) &&
viper.InConfig(authTokenField) {
url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), webhookPath, webhookId)
payload, err := json.Marshal(body)
if err != nil {
slog.Warn(fmt.Sprintln("TriggerWebhook Error marshalling", err))
return
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
slog.Warn(fmt.Sprintln("TriggerWebhook Error creating request", err))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField))
client := &http.Client{
Timeout: time.Second * 1,
}
go func() {
resp, err := client.Do(req)
if err != nil {
slog.Warn(fmt.Sprintln("TriggerWebhook Error sending request", err))
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
slog.Warn(fmt.Sprintln("TriggerWebhook Unexpected response status", resp.StatusCode))
}
defer resp.Body.Close()
}()
} else {
slog.Warn("TriggerWebhook Home Assistant IP, port, or token not found in config file")
}
}
func PublishSensor(sensor HttpSensor) {
if viper.InConfig(ipField) &&
viper.InConfig(portField) &&
viper.InConfig(authTokenField) {
url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), sensorPath, sensor.EntityId)
payload, err := json.Marshal(sensor)
if err != nil {
slog.Warn(fmt.Sprintln("PublishSensor Error marshalling", err))
return
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
slog.Warn(fmt.Sprintln("PublishSensor Error creating request", err))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField))
client := &http.Client{
Timeout: time.Second * 1,
}
resp, err := client.Do(req)
if err != nil {
slog.Warn(fmt.Sprintln("PublishSensor Error sending request", err))
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
slog.Warn(fmt.Sprintln("PublishSensor Unexpected response status", resp.StatusCode))
}
defer resp.Body.Close()
} else {
slog.Warn("PublishSensor Home Assistant IP, port, or token not found in config file")
}
}
+129
View File
@@ -0,0 +1,129 @@
package notion
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jomei/notionapi"
)
var client *notionapi.Client
func Init(token string) {
client = notionapi.NewClient(notionapi.Token(token))
}
func GetClient() *notionapi.Client {
return client
}
func GetTableRows(tableId string, numberOfRows int, startFromId string) ([]notionapi.TableRowBlock, error) {
if client == nil {
return nil, errors.New("notion client not initialized")
}
var rows []notionapi.TableRowBlock
var nextNumberToGet int
if numberOfRows > 100 {
nextNumberToGet = 100
} else {
nextNumberToGet = numberOfRows
}
for numberOfRows > 0 {
block, err := client.Block.GetChildren(context.Background(), notionapi.BlockID(tableId), &notionapi.Pagination{
StartCursor: notionapi.Cursor(startFromId),
PageSize: nextNumberToGet,
})
if err != nil {
return nil, err
}
for _, block := range block.Results {
if block.GetType().String() == "table_row" {
tableRow, ok := block.(*notionapi.TableRowBlock)
if !ok {
slog.Error("Notion.GetTableRows Failed to cast block to table row")
return nil, errors.New("Notion.GetTableRows failed to cast block to table row")
}
rows = append(rows, *tableRow)
} else {
slog.Error(fmt.Sprintf("Block ID %s is not a table row", block.GetID()))
return nil, errors.New("Notion.GetAllTableRows block ID is not a table row")
}
}
numberOfRows -= nextNumberToGet
if numberOfRows > 100 {
nextNumberToGet = 100
} else {
nextNumberToGet = numberOfRows
}
}
return rows, nil
}
func GetAllTableRows(tableId string) ([]notionapi.TableRowBlock, error) {
if client == nil {
return nil, errors.New("notion client not initialized")
}
rows := []notionapi.TableRowBlock{}
nextCursor := ""
hasMore := true
for hasMore {
blockChildren, err := client.Block.GetChildren(context.Background(), notionapi.BlockID(tableId), &notionapi.Pagination{
StartCursor: notionapi.Cursor(nextCursor),
PageSize: 100,
})
if err != nil {
return nil, err
}
for _, block := range blockChildren.Results {
if block.GetType().String() == "table_row" {
tableRow, ok := block.(*notionapi.TableRowBlock)
if !ok {
slog.Error("Notion.GetAllTableRows Failed to cast block to table row")
return nil, errors.New("Notion.GetAllTableRows failed to cast block to table row")
}
rows = append(rows, *tableRow)
} else {
slog.Error(fmt.Sprintf("Block ID %s is not a table row", block.GetID()))
return nil, errors.New("Notion.GetAllTableRows block ID is not a table row")
}
}
nextCursor = blockChildren.NextCursor
hasMore = blockChildren.HasMore
}
return rows, nil
}
func WriteTableRow(content []string, tableId string, after string) (string, error) {
if client == nil {
return "", errors.New("notion client not initialized")
}
rich := [][]notionapi.RichText{}
for _, c := range content {
rich = append(rich, []notionapi.RichText{
{
Type: "text",
Text: &notionapi.Text{
Content: c,
},
},
})
}
tableRow := notionapi.TableRowBlock{
BasicBlock: notionapi.BasicBlock{
Object: "block",
Type: "table_row",
},
TableRow: notionapi.TableRow{
Cells: rich,
},
}
res, err := client.Block.AppendChildren(context.Background(), notionapi.BlockID(tableId), &notionapi.AppendBlockChildrenRequest{
After: notionapi.BlockID(after),
Children: []notionapi.Block{tableRow},
})
return res.Results[0].GetID().String(), err
}
@@ -0,0 +1,297 @@
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()))
}