commit 1724a42f80f8df03ff0336f064330b8e8025f24e Author: James Coleman Date: Sat Sep 9 22:01:33 2023 -0500 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77d522c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.yaml +service-notifications +service-notifications.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d42f48 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# service-notifications + +A tool that creates slack channels for services in planning center and adds people who are assigned to the plan. This is to make it easy to communicate with people assigned to a plan, either automatically via the API included with tool, or manually in Slack. I wrote this tool to send notifications when a slide in ProPresenter is clicked, using the https://github.com/GRMrGecko/midi-request-trigger MIDI bridge. + +## Install + +You can install by building. + +### Building + +Building should be as simple as running: + +```bash +go build +``` + +### Running as a service + +You are likely going to want to run the tool as a service to ensure it runs at boot and restarts in case of failures. Below is an example service config file you can place in `/etc/systemd/system/midi-request-trigger.service` on a linux system to run as a service if you install the binary in `/usr/local/bin/`. + +```systemd +[Unit] +Description=Service Notifications +After=network.target +StartLimitIntervalSec=500 +StartLimitBurst=5 + +[Service] +ExecStart=/usr/local/bin/service-notifications +ExecReload=/bin/kill -s HUP $MAINPID +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +Once the service file is installed, you can run the following to start it: + +```bash +systemctl daemon-reload +systemctl start service-notifications.service +``` + +On MacOS, you can setup a Launch Agent in `~/Library/LaunchAgents/com.mrgeckosmedia.service-notifications.plist` as follows: + +```xml + + + + + Label + com.mrgeckosmedia.service-notifications + ProgramArguments + + /path/to/bin/service-notifications + -c + /path/to/config.yaml + + KeepAlive + + Crashed + + SuccessfulExit + + + RunAtLoad + + OnDemand + + + + +``` + +Start with: +```bash +launchctl load ~/Library/LaunchAgents/com.mrgeckosmedia.midi-request-trigger.plist +``` + +Check status with: +```bash +launchctl list com.mrgeckosmedia.midi-request-trigger +``` + +Stop with: +```bash +launchctl unload ~/Library/LaunchAgents/com.mrgeckosmedia.midi-request-trigger.plist +``` + +## Cron job + +The idea is to setup cron jobs to update data/create the slack channels on a particular day. The following is an example of what I would use. + +```crontab +0 6 * * 3 /path/to/bin/service-notifications --update +``` + +## Config + +The default configuration paths are: + +- `./config.yaml` - A file in the current working directory. +- `~/.config/service-notifications/config.yaml` - A file in your home directory's config path. +- `/etc/service-notifications/config.yaml` - A file in the etc config folder. + +### Basic config + +Get Slack API token by creating an app at https://api.slack.com/apps then go to "Install App" to get the token. + +Get Planning Center API secrets at https://api.planningcenteronline.com/oauth/applications by creating a personal access token. + + +```yaml +--- +database: + debug: true + +planning_center: + app_id: PC_APP_ID + secret: PC_SECRET + +slack: + api_token: SLACK_API_TOKEN + admin_id: SLACK_UID + +``` \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..4db92c6 --- /dev/null +++ b/api.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/slack-go/slack" +) + +// Commonly used strings. +const ( + APIOK = "ok" + APIERR = "error" + APIForbidden = "Forbidden" + APINoEndpoint = "No endpoint found" +) + +// Main response structure. +type APIGeneralResp struct { + Status string `json:"status"` + Error string `json:"error"` +} + +// Typical API responses are done with JSON. To make it easier to respond, this function will marshal/send json to a response writer. +func (s *HTTPServer) JSONResponse(w http.ResponseWriter, resp interface{}) { + // Encode response as json. + js, err := json.Marshal(resp) + if err != nil { + // Error should not happen normally... + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // If no error, we can set content type header and send response. + w.Header().Set("Content-Type", "application/json") + w.Write(js) + w.Write([]byte{'\n'}) +} + +// There are quite a few request that send a general response on error. This function is to make it easy to build/send a general response. +func (s *HTTPServer) APISendGeneralResp(w http.ResponseWriter, status, err string) { + resp := APIGeneralResp{} + resp.Status = status + resp.Error = err + s.JSONResponse(w, resp) +} + +// Verifies that the client connectiong is authenticated. +func (s *HTTPServer) APIAuthenticationMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiKey := r.Header.Get("X-API-Key") + + if s.config.APIKey != "" && s.config.APIKey != apiKey { + s.APISendGeneralResp(w, APIERR, APIForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} + +// Setup HTTP router with routes for the API calls. +func (s *HTTPServer) RegisterAPIRoutes(r *mux.Router) { + api := r.PathPrefix("/api").Subrouter() + + // Requires authentication. + api.Use(s.APIAuthenticationMiddleware) + + // Just a test call. + api.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + s.APISendGeneralResp(w, APIOK, "") + }) + + // Send message to slack channel for the current service. + // Defaults to admin if no service currently occuring. + api.HandleFunc("/send_message", func(w http.ResponseWriter, r *http.Request) { + // Get message, either from URL query or multi part form. + var message string + err := r.ParseMultipartForm(32 << 20) // maxMemory 32MB + if err != nil { + message = r.Form.Get("message") + } + if message == "" { + message = r.URL.Query().Get("message") + } + + // If no message provided, fail. + if message == "" { + s.APISendGeneralResp(w, APIERR, "No message provided") + return + } + + // Get current time and default conversation. + now := time.Now() + conversation := app.config.Slack.AdminID + + // Find plan times that are occuring right now. + var planTime PlanTimes + app.db.Where("time_type='service' AND starts_at < ? AND ends_at < ?", now, now).First(&planTime) + if planTime.Plan != 0 { + // If plan found, check for the slack channel. + var channel SlackChannels + app.db.Where("pc_plan = ?", planTime.Plan).First(&channel) + if channel.ID != "" { + // If slack channel found, update the conversation to the channel ID. + conversation = channel.ID + } + } + + // If no conversation found, likely will happen if no admin is configured, return error. + if conversation == "" { + s.APISendGeneralResp(w, APIERR, "No conversation found") + return + } + + // Send message to Slack. + _, _, err = app.slack.PostMessage(conversation, slack.MsgOptionText(message, false)) + if err != nil { + log.Println("Error sending message:", err) + s.APISendGeneralResp(w, APIERR, "Error sending message") + return + } + + // Return a success. + s.APISendGeneralResp(w, APIOK, "") + }).Methods(http.MethodPost) + + // If nothing else, we return a not found response. + api.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.APISendGeneralResp(w, APIERR, APINoEndpoint) + }) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..5efd3d3 --- /dev/null +++ b/config.go @@ -0,0 +1,114 @@ +package main + +import ( + "log" + "os" + "os/user" + "path" + "path/filepath" + "time" + + "github.com/kkyr/fig" +) + +// Configurations relating to HTTP server. +type HTTPConfig struct { + BindAddr string `fig:"bind_addr"` + Port uint `fig:"port"` + Debug bool `fig:"debug"` + APIKey string `fig:"api_key"` +} + +// Configurations relating to database. +type DBConfig struct { + Type string `fig:"type"` // Review documentation at http://gorm.io/docs/connecting_to_the_database.html + Connection string `fig:"connection"` + Debug bool `fig:"debug"` +} + +// Configurations relating to Planning Center API/Sync. +type PlanningCenterConfig struct { + AppID string `fig:"app_id"` + Secret string `fig:"secret"` + ServiceTypeIDs []uint64 `fig:"service_type_ids"` // Filter to service type IDs listed. +} + +// Configurations relating to Slack API/channel creation. +type SlackConfig struct { + CreateChannelsAhead time.Duration `fig:"create_channels_ahead"` // Amount of time of future services to create channels head for. Defaults to 8 days head. + APIToken string `fig:"api_token"` + AdminID string `fig:"admin_id"` // Slack user that administers this app. +} + +// Configuration Structure. +type Config struct { + HTTP HTTPConfig `fig:"http"` + DB DBConfig `fig:"database"` + PlanningCenter PlanningCenterConfig `fig:"planning_center"` + Slack SlackConfig `fig:"slack"` +} + +// Load the configuration. +func (a *App) ReadConfig() { + usr, err := user.Current() + if err != nil { + log.Fatal(err) + } + + // Configuration paths. + localConfig, _ := filepath.Abs("./config.yaml") + homeDirConfig := usr.HomeDir + "/.config/service-notifications/config.yaml" + etcConfig := "/etc/service-notifications/config.yaml" + + // Determine which configuration to use. + var configFile string + if _, err := os.Stat(app.flags.ConfigPath); err == nil && app.flags.ConfigPath != "" { + configFile = app.flags.ConfigPath + } else if _, err := os.Stat(localConfig); err == nil { + configFile = localConfig + } else if _, err := os.Stat(homeDirConfig); err == nil { + configFile = homeDirConfig + } else if _, err := os.Stat(etcConfig); err == nil { + configFile = etcConfig + } else { + log.Fatal("Unable to find a configuration file.") + } + + // Load the configuration file. + config := &Config{ + HTTP: HTTPConfig{ + BindAddr: "", + Port: 34935, + Debug: true, + }, + DB: DBConfig{ + Type: "sqlite3", + Connection: "service-notifications.db", + }, + Slack: SlackConfig{ + CreateChannelsAhead: time.Hour * 24 * 8, + }, + } + + // Load configuration. + filePath, fileName := path.Split(configFile) + err = fig.Load(config, + fig.File(fileName), + fig.Dirs(filePath), + ) + if err != nil { + log.Printf("Error parsing configuration: %s\n", err) + return + } + + // Override flags. + if app.flags.HTTPBind != "" { + config.HTTP.BindAddr = app.flags.HTTPBind + } + if app.flags.HTTPPort != 0 { + config.HTTP.Port = app.flags.HTTPPort + } + + // Set global config structure. + app.config = config +} diff --git a/database.go b/database.go new file mode 100644 index 0000000..44db7ad --- /dev/null +++ b/database.go @@ -0,0 +1,150 @@ +package main + +import ( + "log" + "time" + + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Planning Center service types. +type ServiceTypes struct { + ID uint64 `gorm:"primary_key" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ArchivedAt time.Time `json:"archived_at"` + DeletedAt time.Time `json:"deleted_at"` + Name string `json:"name"` +} + +// Planning Center plans. +type Plans struct { + ID uint64 `gorm:"primary_key" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SeriesTitle string `json:"series_title"` + Title string `json:"title"` + FirstTimeAt time.Time `json:"first_time_at"` + LastTimeAt time.Time `json:"last_time_at"` + MultiDay bool `json:"multi_day"` + Dates string `json:"dates"` + ServiceType uint64 `json:"service_type"` +} + +// Planning Center plan times, different times a plan has assigned. +type PlanTimes struct { + ID uint64 `gorm:"primary_key" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + TimeType string `json:"time_type"` + StartsAt time.Time `json:"starts_at"` + EndsAt time.Time `json:"ends_at"` + LiveStartsAt time.Time `json:"live_starts_at"` + LiveEndsAt time.Time `json:"live_ends_at"` + Plan uint64 `json:"plan"` +} + +// Planning Center people assigned to a plan. +type PlanPeople struct { + ID uint64 `gorm:"primary_key" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Status string `json:"status"` + TeamPositionName string `json:"team_position_name"` + Person uint64 `json:"person"` + Plan uint64 `json:"plan"` +} + +// Planning Center people information. +type People struct { + ID uint64 `gorm:"primary_key" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ArchivedAt time.Time `json:"archived_at"` + Birthdate time.Time `json:"birthdate"` + Anniversary time.Time `json:"anniversary"` + Status string `json:"status"` + Permissions string `json:"permissions"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + FacebookID uint64 `json:"facebook_id"` + Distance uint64 `gorm:"-:all"` +} + +// Slack users and their association with Planning Center people. +type SlackUsers struct { + ID string `gorm:"primary_key" json:"id"` + Name string `json:"name"` + RealName string `json:"real_name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Phone string `json:"phone"` + Deleted bool `json:"deleted"` + IsBot bool `json:"is_bot"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` + IsRestricted bool `json:"is_restricted"` + IsUltraRestricted bool `json:"is_ultra_restricted"` + IsStranger bool `json:"is_stranger"` + IsAppUser bool `json:"is_app_user"` + IsInvitedUser bool `json:"is_invited_user"` + Updated time.Time `json:"updated"` + PCID uint64 `json:"pc_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Slack channels that were created and state information. +type SlackChannels struct { + ID string `gorm:"primary_key" json:"id"` + Name string `json:"name"` + Description string `json:"description"` + PCPlan uint64 `json:"pc_plan"` + StartsAt time.Time `json:"starts_at"` + EndsAt time.Time `json:"ends_at"` + UsersInvited string `json:"users_invited"` + Archived bool `json:"archived"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Configure the database and add tables/adjust tables to match structures above. +func (a *App) InitDB() { + var err error + dbConfig := &gorm.Config{} + // If debug is enabled, enable the logger. + if a.config.DB.Debug { + dbConfig.Logger = logger.Default.LogMode(logger.Info) + } + // Depending on connection configuration, open the database. + if a.config.DB.Type == "sqlite3" { + a.db, err = gorm.Open(sqlite.Open(a.config.DB.Connection), dbConfig) + } else if a.config.DB.Type == "mysql" { + a.db, err = gorm.Open(mysql.Open(a.config.DB.Connection), dbConfig) + } else if a.config.DB.Type == "postgres" { + a.db, err = gorm.Open(postgres.Open(a.config.DB.Connection), dbConfig) + } else { + log.Fatal("Incorrect database config") + } + // If a error occurs connecting to the database, fail. + if err != nil { + log.Fatal(err) + } + + // Update tables on database to match the above definitions. + a.db.AutoMigrate(&ServiceTypes{}) + a.db.AutoMigrate(&Plans{}) + a.db.AutoMigrate(&PlanTimes{}) + a.db.AutoMigrate(&PlanPeople{}) + a.db.AutoMigrate(&People{}) + a.db.AutoMigrate(&SlackUsers{}) + a.db.AutoMigrate(&SlackChannels{}) +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..eed6931 --- /dev/null +++ b/flags.go @@ -0,0 +1,52 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +// Flags supplied to cli. +type Flags struct { + ConfigPath string + HTTPBind string + HTTPPort uint + Update bool +} + +// Parse the supplied flags. +func (a *App) ParseFlags() { + app.flags = new(Flags) + flag.Usage = func() { + fmt.Printf(serviceName + ": " + serviceDescription + ".\n\nUsage:\n") + flag.PrintDefaults() + } + + // If version is requested. + var printVersion bool + flag.BoolVar(&printVersion, "v", false, "Print version") + + // Override configuration path. + usage := "Load configuration from `FILE`" + flag.StringVar(&app.flags.ConfigPath, "config", "", usage) + flag.StringVar(&app.flags.ConfigPath, "c", "", usage+" (shorthand)") + + // Config overrides for http configurations. + flag.StringVar(&app.flags.HTTPBind, "http-bind", "", "Bind address for http server") + flag.UintVar(&app.flags.HTTPPort, "http-port", 0, "Bind port for http server") + + // Runs database update for Slack and Planning Center information, + // then it creates slack channels if needed. + usage = "Update database and create channels" + flag.BoolVar(&app.flags.Update, "update", false, usage) + flag.BoolVar(&app.flags.Update, "u", false, usage+" (shorthand)") + + // Parse the flags. + flag.Parse() + + // Print version and exit if requested. + if printVersion { + fmt.Println(serviceName + ": " + serviceVersion) + os.Exit(0) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..05bab7f --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/GRMrGecko/service-notifications + +go 1.20 + +require ( + github.com/agnivade/levenshtein v1.1.1 + github.com/gorilla/handlers v1.5.1 + github.com/gorilla/mux v1.8.0 + github.com/kkyr/fig v0.3.2 + github.com/slack-go/slack v0.12.3 + gorm.io/driver/mysql v1.5.1 + gorm.io/driver/postgres v1.5.2 + gorm.io/driver/sqlite v1.5.3 + gorm.io/gorm v1.25.4 +) + +require ( + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.3.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + golang.org/x/crypto v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..11399b2 --- /dev/null +++ b/go.sum @@ -0,0 +1,77 @@ +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= +github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kkyr/fig v0.3.2 h1:+vMj52FL6RJUxeKOBB6JXIMyyi1/2j1ERDrZXjoBjzM= +github.com/kkyr/fig v0.3.2/go.mod h1:ItUILF8IIzgZOMhx5xpJ1W/bviQsWRKOwKXfE/tqUoA= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +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/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/slack-go/slack v0.12.3 h1:92/dfFU8Q5XP6Wp5rr5/T5JHLM5c5Smtn53fhToAP88= +github.com/slack-go/slack v0.12.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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/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= +gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw= +gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o= +gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= +gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= +gorm.io/driver/sqlite v1.5.3 h1:7/0dUgX28KAcopdfbRWWl68Rflh6osa4rDh+m51KL2g= +gorm.io/driver/sqlite v1.5.3/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= +gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw= +gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= diff --git a/http.go b/http.go new file mode 100644 index 0000000..6eb0766 --- /dev/null +++ b/http.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" +) + +// Basic HTTP server structure. +type HTTPServer struct { + server *http.Server + mux *mux.Router + config *HTTPConfig +} + +// This functions starts the HTTP server. +func NewHTTPServer() *HTTPServer { + s := new(HTTPServer) + // Update config reference. + s.config = &app.config.HTTP + s.server = &http.Server{} + s.server.Addr = fmt.Sprintf("%s:%d", s.config.BindAddr, s.config.Port) + + // Setup router. + r := mux.NewRouter() + s.mux = r + // Register API routes. + s.RegisterAPIRoutes(r) + // Default to notice of service being online. + r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "Srvice Notifications is available\n") + }) + + s.server.Handler = r + // If the debug log is enabled, we'll add a middleware handler to log then pass the request to mux router. + if app.config.HTTP.Debug { + s.server.Handler = handlers.CombinedLoggingHandler(os.Stdout, r) + } + + return s +} + +// Start the HTTP server. +func (s *HTTPServer) Start(ctx context.Context) { + isListening := make(chan bool) + // Start server. + go s.StartWithIsListening(ctx, isListening) + // Allow the http server to initialize. + <-isListening +} + +// Starts the HTTP server with a listening channel. +func (s *HTTPServer) StartWithIsListening(ctx context.Context, isListening chan bool) { + // Watch the background context for when we need to shutdown. + go func() { + <-ctx.Done() + err := s.server.Shutdown(context.Background()) + if err != nil { + // Error from closing listeners, or context timeout: + log.Println("Error shutting down http server:", err) + } + }() + + // Start the server. + log.Println("Starting http server:", s.server.Addr) + l, err := net.Listen("tcp", s.server.Addr) + if err != nil { + log.Fatal("Listen: ", err) + } + // Now notify we are listening. + isListening <- true + // Serve http server on the listening port. + err = s.server.Serve(l) + if err != nil { + log.Println("HTTP server failure:", err) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f03cfe1 --- /dev/null +++ b/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/slack-go/slack" + "gorm.io/gorm" +) + +const ( + serviceName = "service-notifications" + serviceDescription = "Notifications for church services" + serviceVersion = "0.1" +) + +// App is the global application structure for communicating between servers and storing information. +type App struct { + flags *Flags + config *Config + db *gorm.DB + slack *slack.Client + http *HTTPServer +} + +var app *App + +func main() { + app = new(App) + app.ParseFlags() + app.ReadConfig() + app.InitDB() + app.slack = slack.New(app.config.Slack.APIToken) + + // If update is requested, run updates and end the program. + if app.flags.Update { + UpdatePCData() + UpdateSlackData() + CreateSlackChannels() + return + } + + // Configure the HTTP server. + app.http = NewHTTPServer() + + // Setup context with cancellation function to allow background services to gracefully stop. + ctx, ctxCancel := context.WithCancel(context.Background()) + app.http.Start(ctx) + + // Monitor common signals. + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + // Wiat for a signal. + <-c + // Stop the HTTP server and end. + ctxCancel() +} diff --git a/planningcenter.go b/planningcenter.go new file mode 100644 index 0000000..b724175 --- /dev/null +++ b/planningcenter.go @@ -0,0 +1,215 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Make an API request to Planning Center. +func NewPCRequest(uri string) (*http.Request, error) { + url := uri + // If request URI doesn't include full URL, prepend the PC API URL. + if !strings.HasPrefix(url, "http") { + url = "https://api.planningcenteronline.com" + uri + } + // Make the request. + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + // Append the basic authentication from the configuration. + auth := app.config.PlanningCenter.AppID + ":" + app.config.PlanningCenter.Secret + authString := base64.StdEncoding.EncodeToString([]byte(auth)) + req.Header.Add("Authorization", "Basic "+authString) + + // Return the request made. + return req, nil +} + +// Planning center meta data/information about request. +type PCMeta struct { + TotalCount uint64 `json:"total_count"` + Count uint64 `json:"count"` + + Prev struct { + Offset uint64 `json:"offset"` + } `json:"prev"` + Next struct { + Offset uint64 `json:"offset"` + } `json:"next"` + + CanOrderBy []string `json:"can_order_by"` + CanQueryBy []string `json:"can_query_by"` + CanInclude []string `json:"can_include"` + + Parent struct { + Id string `json:"id"` + Type string `json:"type"` + } `json:"parent"` +} + +// A dictionary for planning center response parsing. +type PCDict map[string]interface{} + +// Common response error structure. +type PCError struct { + Status string `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` +} + +// Basic PC response structure. +type PCResponse struct { + Links struct { + Self string `json:"self"` + Prev string `json:"prev"` + Next string `json:"next"` + } `json:"links"` + Data []PCDict `json:"data"` + Included []interface{} `json:"included"` + Meta PCMeta `json:"meta"` + Errors []PCError `json:"errors"` +} + +// Parse a planning center reponse body. +func PCParseResponse(body io.Reader) (*PCResponse, error) { + // Decode JSON response. + res := new(PCResponse) + err := json.NewDecoder(body).Decode(res) + if err != nil { + return nil, err + } + // If an error was provided from the API, return it. + if len(res.Errors) != 0 { + return nil, fmt.Errorf(res.Errors[0].Detail) + } + // We expect result to be provided on a valid response. + if res.Data == nil { + return nil, fmt.Errorf("no data in response") + } + // A valid response was decoded, return it. + return res, nil +} + +// Query Planning Center API and get data from all pages. +func PCGetAll(uri string) ([]PCDict, error) { + // The data array to store all found data. + var data []PCDict + + // Set the first URL to the requested URL. + url := uri + // Make requests until the last page was loaded. + for { + // Make the request. + req, err := NewPCRequest(url) + if err != nil { + return nil, err + } + + // Perform the request. + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + // Close body when done. + defer res.Body.Close() + + // Parse the response. + response, err := PCParseResponse(res.Body) + if err != nil { + return nil, err + } + + // Add data from response to global data array. + data = append(data, response.Data...) + + // If no next link provided, stop here. + if response.Links.Next == "" { + break + } + // If next link provided, set it for the next request. + url = response.Links.Next + } + // Return all found data. + return data, nil +} + +// Below are a bunch of helper functions. +// I would recommend using a tool like Insomnia to test API requests, +// then you will know what the data structure is like for an API request. +// Planning center does have some ok documentation available: +// https://developer.planning.center/docs/#/overview + +// Get a string from a dictionary. +func (p PCDict) GetString(key string) string { + s, ok := p[key].(string) + if !ok { + return "" + } + return s +} + +// Get a bool from a dictionary. +func (p PCDict) GetBool(key string) bool { + b, ok := p[key].(bool) + if !ok { + return false + } + return b +} + +// Get an unsigned int from dictionary. +func (p PCDict) GetUint64(key string) uint64 { + s, ok := p[key].(string) + var i uint64 + // Try parsing a string if its ok. + if ok { + i, _ = strconv.ParseUint(s, 10, 64) + } else { + // Otherwise, try converting to an integer. + i, ok = p[key].(uint64) + if !ok { + return 0 + } + } + return i +} + +// Get a dictionary from a dictionary. +func (p PCDict) GetDict(key string) PCDict { + d, ok := p[key].(map[string]interface{}) + if !ok { + return make(map[string]interface{}) + } + return d +} + +// Standard date layouts. +const ( + PCDateTimeLayout = "2006-01-02T15:04:05Z" + PCDateLayout = "2006-01-02" +) + +// Get a date from a dictionary. +func (p PCDict) GetDate(key string) time.Time { + var t time.Time + var err error + s, ok := p[key].(string) + if ok { + // Try parsing with the time layout first. + t, err = time.Parse(PCDateTimeLayout, s) + if err != nil { + // If that fialed, try using the date layout. + t, _ = time.Parse(PCDateLayout, s) + } + } + return t +} diff --git a/update.go b/update.go new file mode 100644 index 0000000..21b85f9 --- /dev/null +++ b/update.go @@ -0,0 +1,486 @@ +package main + +import ( + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/agnivade/levenshtein" + "github.com/slack-go/slack" +) + +// Update planning center database tables with data from PC API. +func UpdatePCData() { + // Get all people. + allPeople, err := PCGetAll("/services/v2/people") + if err != nil { + log.Fatalln(err) + } + // For each person, parse data and save to database. + for _, data := range allPeople { + // Parse the ID and attributes. + id := data.GetUint64("id") + attributes := data.GetDict("attributes") + + // Check if this person is already in our database. + var p People + app.db.Where("id = ?", id).First(&p) + + // Update all fields with new data. + p.UpdatedAt = attributes.GetDate("updated_at") + p.ArchivedAt = attributes.GetDate("archived_at") + p.Birthdate = attributes.GetDate("birthdate") + p.Anniversary = attributes.GetDate("anniversary") + p.Status = attributes.GetString("status") + p.Permissions = attributes.GetString("permissions") + p.FirstName = attributes.GetString("first_name") + p.LastName = attributes.GetString("last_name") + p.FacebookID = attributes.GetUint64("facebook_id") + + // If the person wasn't in the database, create it. + if p.ID == 0 { + p.ID = id + p.CreatedAt = attributes.GetDate("created_at") + app.db.Create(&p) + } else { + // If th e person was in the database, update it. + app.db.Save(&p) + } + } + + // Get service types. + allServiceTypes, err := PCGetAll("/services/v2/service_types") + if err != nil { + log.Fatalln(err) + } + // Keep track of service type IDs incase no filter is supplied. + var allServiceTypeIDs []uint64 + // For each service type, parse data and save to database. + for _, data := range allServiceTypes { + // Get the service ID and attributes. + id := data.GetUint64("id") + allServiceTypeIDs = append(allServiceTypeIDs, id) + attributes := data.GetDict("attributes") + + // Check if service type was already in database. + var s ServiceTypes + app.db.Where("id = ?", id).First(&s) + + // Update fields with new data. + s.UpdatedAt = attributes.GetDate("updated_at") + s.ArchivedAt = attributes.GetDate("archived_at") + s.DeletedAt = attributes.GetDate("deleted_at") + s.Name = attributes.GetString("name") + + // If service type wasn't already existing, create it. + if s.ID == 0 { + s.ID = id + s.CreatedAt = attributes.GetDate("created_at") + app.db.Create(&s) + } else { + // Save if already existing/ + app.db.Save(&s) + } + } + + // Get service type filter from the config. + servicesTypesToPull := app.config.PlanningCenter.ServiceTypeIDs + // If no filter, use the found service types above. + if len(servicesTypesToPull) == 0 { + servicesTypesToPull = allServiceTypeIDs + } + + // For each service type, pull plans and plan info. + for _, serviceTypeID := range servicesTypesToPull { + // Get the plans for this service type. + allPlans, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans", serviceTypeID)) + if err != nil { + log.Fatalln(err) + } + // For each plan, update data in database and pull other plan releated items for updates. + for _, data := range allPlans { + // Get the plan ID and attributes. + planID := data.GetUint64("id") + attributes := data.GetDict("attributes") + + // Check if plan was already in the database. + var p Plans + app.db.Where("id = ?", planID).First(&p) + + // Update with new data. + p.UpdatedAt = attributes.GetDate("updated_at") + p.SeriesTitle = attributes.GetString("series_title") + p.Title = attributes.GetString("title") + p.FirstTimeAt = attributes.GetDate("sort_date") + p.LastTimeAt = attributes.GetDate("last_time_at") + p.MultiDay = attributes.GetBool("multi_day") + p.Dates = attributes.GetString("dates") + + // If plan wasn't already created, create it. + if p.ID == 0 { + p.ID = planID + p.CreatedAt = attributes.GetDate("created_at") + p.ServiceType = serviceTypeID + app.db.Create(&p) + } else { + // Save plan if already existing. + app.db.Save(&p) + } + + // Get all times for this plan. + allPlanTimes, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans/%d/plan_times", serviceTypeID, planID)) + if err != nil { + log.Fatalln(err) + } + // With each time, save it to the database. + for _, data := range allPlanTimes { + // Get the plan time ID and attributes. + id := data.GetUint64("id") + attributes := data.GetDict("attributes") + + // Get from database if already existing. + var p PlanTimes + app.db.Where("id = ?", id).First(&p) + + // Update data. + p.UpdatedAt = attributes.GetDate("updated_at") + p.Name = attributes.GetString("name") + p.TimeType = attributes.GetString("time_type") + p.StartsAt = attributes.GetDate("starts_at") + p.EndsAt = attributes.GetDate("ends_at") + p.LiveStartsAt = attributes.GetDate("live_starts_at") + p.LiveEndsAt = attributes.GetDate("live_ends_at") + + // If not already existing, create it. + if p.ID == 0 { + p.ID = id + p.CreatedAt = attributes.GetDate("created_at") + p.Plan = planID + app.db.Create(&p) + } else { + // If already existing, save it. + app.db.Save(&p) + } + } + + // Get all members of the plan. + allTeamMembers, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans/%d/team_members", serviceTypeID, planID)) + if err != nil { + log.Fatalln(err) + } + // With each member, update the database. + for _, data := range allTeamMembers { + // Get the member ID and attributes. + id := data.GetUint64("id") + attributes := data.GetDict("attributes") + + // Get person data from the database. + var p PlanPeople + app.db.Where("id = ?", id).First(&p) + + // Update data. + p.UpdatedAt = attributes.GetDate("updated_at") + p.Status = attributes.GetString("status") + p.TeamPositionName = attributes.GetString("team_position_name") + + // If person wasn't existing, create them. + if p.ID == 0 { + p.ID = id + p.CreatedAt = attributes.GetDate("created_at") + p.Person = data.GetDict("relationships").GetDict("person").GetDict("data").GetUint64("id") + p.Plan = planID + app.db.Create(&p) + } else { + // Otherwise save new info. + app.db.Save(&p) + } + } + } + } +} + +// Update slack information. +func UpdateSlackData() { + // Get all users from Slack. + users, err := app.slack.GetUsers() + if err != nil { + log.Fatalln(err) + } + // If no users returned, error as we should have some... + if len(users) == 0 { + log.Fatalln("No users found in Slack.") + } + // With each user, update the database. + for _, user := range users { + // Check if user already is in database. + var u SlackUsers + app.db.Where("id = ?", user.ID).First(&u) + + // Update data. + u.Name = user.Name + u.RealName = user.RealName + u.FirstName = user.Profile.FirstName + u.LastName = user.Profile.LastName + u.Email = user.Profile.Email + u.Phone = user.Profile.Phone + u.Deleted = user.Deleted + u.IsBot = user.IsBot + u.IsAdmin = user.IsAdmin + u.IsOwner = user.IsOwner + u.IsPrimaryOwner = user.IsPrimaryOwner + u.IsRestricted = user.IsRestricted + u.IsUltraRestricted = user.IsUltraRestricted + u.IsStranger = user.IsStranger + u.IsAppUser = user.IsAppUser + u.IsInvitedUser = user.IsInvitedUser + u.Updated = user.Updated.Time() + + // Try and find a match for this Slack user to the Planning Center people. + var people []People + // Get all people from Planning Center. + app.db.Find(&people) + if len(people) != 0 { + // For each person, compute how close of a match they are to the Slack user. + for i, person := range people { + distance := levenshtein.ComputeDistance(u.Name, person.FirstName+" "+person.LastName) + newDistance := levenshtein.ComputeDistance(u.RealName, person.FirstName+" "+person.LastName) + // The lowest score of the first+lastname match is used. + if newDistance < distance { + distance = newDistance + } + // Compute a score of first+last name. + newDistance = levenshtein.ComputeDistance(u.FirstName, person.FirstName) + newDistance += levenshtein.ComputeDistance(u.LastName, person.LastName) + // If this score is lower than the last score, return it. + if newDistance < distance { + distance = newDistance + } + // Update the distance on the user for sorting. + people[i].Distance = uint64(distance) + } + + // Sort all Planning Center people by the score computed. + sort.Slice(people, func(i, j int) bool { + return people[i].Distance < people[j].Distance + }) + + // Debug output for comparing scores. + // for _, person := range people { + // fmt.Printf("%d %s (%s) %s\n", person.Distance, u.Name, u.RealName, person.FirstName+" "+person.LastName) + // } + + // Set the planning center ID to nothing at first. + u.PCID = 0 + // If score of the first person is less than 7, + // consider them a match and assign thier ID to the slack user. + if people[0].Distance < 7 { + u.PCID = people[0].ID + } + } + + // If not already existing in the database, create them. + if u.ID == "" { + u.ID = user.ID + app.db.Create(&u) + } else { + // if already existing, update the user. + app.db.Save(&u) + } + } +} + +// Create slack channels for upcoming services. +func CreateSlackChannels() { + // For now, we're using the start time of now. I want to update this later to allow + // setting a day of the week for slack channels to be created on. + // Doing a day of the week will allow for channels to be created ahead of time, then + // if people are added to the plan later on, they can be added at the next cron run. + startDate := time.Now() + // Last date is start date plus duration of create channels ahead. + lastDate := startDate.Add(app.config.Slack.CreateChannelsAhead) + + // Get plan times that match. + var planTimes []PlanTimes + app.db.Where("time_type='service' AND starts_at > ? AND starts_at < ?", startDate, lastDate).Find(&planTimes) + // If no plan times matched, exit here. + if len(planTimes) == 0 { + log.Fatalln("No services found for this time frame.") + } + + // With each plan time found, create a slack channel. + for _, planTime := range planTimes { + // Get the plan associated with the plan time. + var plan Plans + app.db.Where("id = ?", planTime.Plan).First(&plan) + if plan.ID == 0 { + log.Println("Unable to find plan:", planTime.Plan) + continue + } + + // Get the service type associated with the plan. + var serviceType ServiceTypes + app.db.Where("id = ?", plan.ServiceType).First(&serviceType) + if serviceType.ID == 0 { + log.Println("Unable to find service type:", planTime.Plan) + continue + } + + // Find people assigned to the plan. + var peopleOnPlan []PlanPeople + app.db.Where("plan = ?", plan.ID).Find(&peopleOnPlan) + if len(peopleOnPlan) == 0 { + log.Println("No people assigned to plan:", planTime.Plan) + continue + } + + // Check if a channel was already created for this plan. + var channel SlackChannels + app.db.Where("pc_plan = ?", plan.ID).First(&channel) + + // Set the topic/description based on servie type, and title/series title. + topic := serviceType.Name + if plan.SeriesTitle == "" && plan.Title != "" { + topic = topic + " - " + plan.Title + } else if plan.SeriesTitle != "" && plan.Title != "" { + topic = topic + " - " + plan.SeriesTitle + " (" + plan.Title + ")" + } else if plan.SeriesTitle != "" { + topic = topic + " - " + plan.SeriesTitle + } + + // If the channel already exists, we do not need to create it... + // However, we should check if the description is changed + // and we should check if people were added. + if channel.ID != "" { + if channel.Description != topic { + app.slack.SetTopicOfConversation(channel.ID, topic) + app.slack.SetPurposeOfConversation(channel.ID, topic) + channel.Description = topic + app.db.Save(&channel) + } + } else { + // If the channel is being created, set the name to the starts at date. + channel.Name = planTime.StartsAt.Format("2006-01-02") + // Its possible that a duplicate channel already exists, if so we should append + // a channel number. Duplicate channels typically happen if multiple plans + // exists on the same day. + startingID := 1 + for { + var duplicateChannel SlackChannels + app.db.Where("name = ?", channel.Name).First(&duplicateChannel) + if duplicateChannel.ID == "" { + break + } + startingID++ + channel.Name = fmt.Sprintf("%s_%d", planTime.StartsAt.Format("2006-01-02"), startingID) + } + + // Create the channel. + channelInfo := slack.CreateConversationParams{ + ChannelName: channel.Name, + IsPrivate: true, + } + log.Println("Creating channel:", channel.Name) + schan, err := app.slack.CreateConversation(channelInfo) + if err != nil { + log.Fatalln("Failed to create channel:", err) + } + + // If topic is defined, set the topic and purpose. + if topic != "" { + // Sleep before, as it takes time for Slack APIs + // to recongize the channel was created. + time.Sleep(10 * time.Second) + _, err = app.slack.SetTopicOfConversation(channel.ID, topic) + if err != nil { + log.Println("Failed to set topic:", err) + } + _, err = app.slack.SetPurposeOfConversation(channel.ID, topic) + if err != nil { + log.Println("Failed to set purpose:", err) + } + } + + // Save the channel to the database. + channel.ID = schan.ID + channel.PCPlan = planTime.Plan + channel.StartsAt = planTime.StartsAt + channel.EndsAt = planTime.EndsAt + channel.Description = topic + app.db.Create(&channel) + } + + + // Get the previous users that were invited to the channel. + invited := strings.Split(channel.UsersInvited, ",") + // If nothing is previous, reset the slice to nil. + if len(invited) == 1 && invited[0] == "" { + invited = nil + } + + // Keep a list of users we need to invite as they are new. + var usersToInvite []string + + // For each person on the plan, see if we need to invite them. + for _, personOnPlan := range peopleOnPlan { + // Find the slack user for the planning center person. + var slackUser SlackUsers + app.db.Where("pc_id = ?", personOnPlan.Person).First(&slackUser) + if slackUser.ID == "" { + continue + } + + // Check if they were already invited. + alreadyInvited := false + for _, uid := range invited { + if uid == slackUser.ID { + alreadyInvited = true + break + } + } + + // Make sure they were not already added to the list of users. + // A person can be assigned to multiple teams on a plan. + for _, uid := range usersToInvite { + if uid == slackUser.ID { + alreadyInvited = true + break + } + } + + // If not already invited, add to the list of users to invite. + if !alreadyInvited { + usersToInvite = append(usersToInvite, slackUser.ID) + } + } + + // If there are users to invite, invite them. + if len(usersToInvite) != 0 { + // Update the invited users list. + invited = append(invited, usersToInvite...) + channel.UsersInvited = strings.Join(invited, ",") + // Invite the users. + _, err := app.slack.InviteUsersToConversation(channel.ID, usersToInvite...) + if err != nil { + log.Println("Failed to invite users to channel:", err) + } + // Update the channel on database with the new list of users invited. + app.db.Save(&channel) + } + } + + // Find old channels to archive. Any channel which start at date is before the start date. + var channelsToArchive []SlackChannels + app.db.Where("starts_at < ? AND archived != 1", startDate).Find(&channelsToArchive) + // Archive channels which are old. + for _, channel := range channelsToArchive { + err := app.slack.ArchiveConversation(channel.ID) + if err != nil { + log.Println("Error closing old channel:", err) + } + // Mark as archived on the database. + channel.Archived = true + app.db.Save(&channel) + } +}