First commit

This commit is contained in:
James Coleman 2023-09-09 22:01:33 -05:00
commit e6ef66194e
13 changed files with 1556 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
config.yaml
service-notifications
service-notifications.db

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2023 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

127
README.md Normal file
View File

@ -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/service-notifications.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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.mrgeckosmedia.service-notifications</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/bin/service-notifications</string>
<string>-c</string>
<string>/path/to/config.yaml</string>
</array>
<key>KeepAlive</key>
<dict>
<key>Crashed</key>
<true/>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>RunAtLoad</key>
<true/>
<key>OnDemand</key>
<false/>
</dict>
</plist>
```
Start with:
```bash
launchctl load ~/Library/LaunchAgents/com.mrgeckosmedia.service-notifications.plist
```
Check status with:
```bash
launchctl list com.mrgeckosmedia.service-notifications
```
Stop with:
```bash
launchctl unload ~/Library/LaunchAgents/com.mrgeckosmedia.service-notifications.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
```

137
api.go Normal file
View File

@ -0,0 +1,137 @@
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 == "" {
log.Println("No message provided")
s.APISendGeneralResp(w, APIERR, "No message provided")
return
}
// Get current time and default conversation.
now := time.Now().UTC()
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 == "" {
log.Println("No conversation found")
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)
})
}

114
config.go Normal file
View File

@ -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
}

150
database.go Normal file
View File

@ -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{})
}

52
flags.go Normal file
View File

@ -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)
}
}

34
go.mod Normal file
View File

@ -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
)

77
go.sum Normal file
View File

@ -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=

84
http.go Normal file
View File

@ -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)
}
}

59
main.go Normal file
View File

@ -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()
}

215
planningcenter.go Normal file
View File

@ -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
}

485
update.go Normal file
View File

@ -0,0 +1,485 @@
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().UTC()
// 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)
}
}