~bsprague/slackcal

cb88a8054b0b12426436d8ccd90745dd6493906b — Brandon Sprague 6 months ago 670896d
Add persistence and better concurrency story
3 files changed, 271 insertions(+), 107 deletions(-)

M go.mod
M go.sum
M main.go
M go.mod => go.mod +2 -0
@@ 12,5 12,7 @@ require (

require (
	github.com/gorilla/websocket v1.5.1 // indirect
	go.etcd.io/bbolt v1.3.8 // indirect
	golang.org/x/net v0.19.0 // indirect
	golang.org/x/sys v0.15.0 // indirect
)

M go.sum => go.sum +5 -0
@@ 23,8 23,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

M main.go => main.go +264 -107
@@ 1,21 1,35 @@
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"sync"
	"time"

	ics "github.com/arran4/golang-ical"
	"github.com/slack-go/slack"
	"github.com/slack-go/slack/slackevents"
	"github.com/slack-go/slack/socketmode"
	bolt "go.etcd.io/bbolt"
)

type CalendarEvent struct {
	ID          string
	Title       string
	Start       time.Time
	End         time.Time
	LastUpdated time.Time
}

func main() {
	if err := run(os.Args); err != nil {
		log.Fatal(err)


@@ 31,16 45,115 @@ func run(args []string) error {
		// I don't know what the distinction between these two tokens is yet, comes from https://github.com/slack-go/slack/blob/master/examples/socketmode/socketmode.go
		slackAppToken = fs.String("slack_app_token", "", "API token for communicating with Slack.")
		slackBotToken = fs.String("slack_bot_token", "", "API token for communicating with Slack.")
		calURL        = fs.String("cal_url", "", "URL where the calender can be downloaded")

		calURL = fs.String("cal_url", "", "URL where the calender can be downloaded")

		dbPath = fs.String("db_path", "slackcal.boltdb", "Path to the BoltDB database file")
	)
	if err := fs.Parse(args[1:]); err != nil {
		return fmt.Errorf("failed to parse flags: %w", err)
	}

	if err := testCalendar(*calURL); err != nil {
		return fmt.Errorf("failed to test calendar: %w", err)
	if *dbPath == "" {
		return errors.New("no --db_path specified")
	}
	db, err := bolt.Open(*dbPath, 0600, nil)
	if err != nil {
		return err
	}
	defer db.Close()

	if err := db.Update(func(tx *bolt.Tx) error {
		_, err := tx.CreateBucketIfNotExists([]byte("events"))
		return err
	}); err != nil {
		return fmt.Errorf("failed to create bbolt bucket: %w", err)
	}

	if *calURL == "" {
		return errors.New("no --cal_url specified")
	}

	var wg sync.WaitGroup
	ctx, cancel := context.WithCancel(context.Background())
	defer func() {
		log.Println("sending shutdown signal")
		cancel()
		log.Println("waiting for services to shutdown")
		wg.Wait()
	}()

	errC := make(chan error)

	// Poll for new calendar events
	eventC := make(chan CalendarEvent)
	go func() {
		wg.Add(1)
		defer wg.Done()

		t := time.NewTimer(time.Minute * 5)
		defer t.Stop()

		var buf bytes.Buffer
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			log.Printf("loading calendar events from %q", *calURL)
			events, err := loadEvents(*calURL)
			if err != nil {
				errC <- fmt.Errorf("failed to test calendar: %w", err)
				return
			}
			log.Printf("loaded %d calendar events", len(events))

			if err := db.View(func(tx *bolt.Tx) error {
				// TODO: Refactor this monstrosity.
				bkt := tx.Bucket([]byte("events"))
				for _, evt := range events {
					k := []byte(evt.ID)
					val := bkt.Get(k)
					if val == nil {
						// We haven't seen this, record it and send it.
						dat, err := serializeEvent(evt, &buf)
						if err != nil {
							return fmt.Errorf("serializeEvent: %w", err)
						}
						if err := bkt.Put(k, dat); err != nil {
							return fmt.Errorf("failed to store new event %q: %w", evt.ID, err)
						}
						buf.Reset()
						eventC <- evt
						continue
					}
					// If we're here, the event exists, see if it has changed.
					var existingEvt CalendarEvent
					if err := json.Unmarshal(val, &existingEvt); err != nil {
						return fmt.Errorf("failed to deserialize existing event %q: %w", evt.ID, err)
					}
					if !existingEvt.LastUpdated.Equal(evt.LastUpdated) {
						// Event has been updated. Record it and send it.
						// TODO: Figure out what changed, and have distinction between new and updated.
						dat, err := serializeEvent(evt, &buf)
						if err != nil {
							return fmt.Errorf("serializeEvent: %w", err)
						}
						buf.Reset()
						if err := bkt.Put(k, dat); err != nil {
							return fmt.Errorf("failed to store new event %q: %w", evt.ID, err)
						}
						eventC <- evt
						continue
					}
				}
				return nil
			}); err != nil {
				errC <- err
				return
			}
		}
	}()

	if *slackAppToken == "" {
		return errors.New("no --slack_app_token specified")
	}


@@ 56,165 169,209 @@ func run(args []string) error {
	}

	api := slack.New(*slackBotToken, slack.OptionAppLevelToken(*slackAppToken))

	client := socketmode.New(api)

	go listenForEvents(client)
	// Listen for both calendar events and Slack events
	go func() {
		wg.Add(1)
		defer wg.Done()

		listenForEvents(ctx, client, eventC)
	}()

	// Start listening for Slack events
	go func() {
		wg.Add(1)
		defer wg.Done()

		if err := client.RunContext(ctx); err != nil {
			errC <- fmt.Errorf("error running socket mode client: %w", err)
		}
	}()

	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt)

	if err := client.Run(); err != nil {
		return fmt.Errorf("error running socket mode client: %w", err)
	select {
	case s := <-c:
		log.Printf("Received signal: %q", s)
	case err := <-errC:
		return fmt.Errorf("an error occurred in a background task: %w", err)
	}

	return errors.New("should never happen?")
	return nil
}

func listenForEvents(client *socketmode.Client) {
	for evt := range client.Events {
		switch evt.Type {
		case socketmode.EventTypeConnecting:
			log.Println("Connecting to Slack with Socket Mode...")
		case socketmode.EventTypeConnectionError:
			log.Println("Connection failed. Retrying later...")
		case socketmode.EventTypeConnected:
			log.Println("Connected to Slack with Socket Mode.")
		case socketmode.EventTypeEventsAPI:
			eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
			if !ok {
				log.Printf("Event with type %q had unexpected data type %T", evt.Type, evt.Data)
				continue
func serializeEvent(evt CalendarEvent, buf *bytes.Buffer) ([]byte, error) {
	if err := json.NewEncoder(buf).Encode(evt); err != nil {
		return nil, fmt.Errorf("failed to serialize event: %w", err)
	}
	return buf.Bytes(), nil
}

func listenForEvents(ctx context.Context, client *socketmode.Client, eventC <-chan CalendarEvent) {
	for {
		select {
		case <-ctx.Done():
			return
		case evt := <-client.Events:
			if err := handleEvent(client, evt); err != nil {
				log.Printf("failed to handle event: %v", err)
			}
		case evt := <-eventC:
			_, _, err := client.PostMessage("TODO: Channel Name", slack.MsgOptionText(fmt.Sprintf("New event: %s", evt.Title), true))
			if err != nil {
				log.Printf("failed to post message about event: %v", err)
			}
		}
	}
}

			log.Printf("Event received: %+v", eventsAPIEvent)
func handleEvent(client *socketmode.Client, evt socketmode.Event) error {
	switch evt.Type {
	case socketmode.EventTypeConnecting:
		log.Println("Connecting to Slack with Socket Mode...")
	case socketmode.EventTypeConnectionError:
		log.Println("Connection failed. Retrying later...")
	case socketmode.EventTypeConnected:
		log.Println("Connected to Slack with Socket Mode.")
	case socketmode.EventTypeEventsAPI:
		eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
		if !ok {
			return fmt.Errorf("Event with type %q had unexpected data type %T", evt.Type, evt.Data)
		}

			client.Ack(*evt.Request)
		log.Printf("Event received: %+v", eventsAPIEvent)

			switch eventsAPIEvent.Type {
			case slackevents.CallbackEvent:
				innerEvent := eventsAPIEvent.InnerEvent
				switch ev := innerEvent.Data.(type) {
				case *slackevents.AppMentionEvent:
					_, _, err := client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false))
					if err != nil {
						log.Printf("failed posting message: %v", err)
					}
				case *slackevents.MemberJoinedChannelEvent:
					log.Printf("user %q joined to channel %q", ev.User, ev.Channel)
		client.Ack(*evt.Request)

		switch eventsAPIEvent.Type {
		case slackevents.CallbackEvent:
			innerEvent := eventsAPIEvent.InnerEvent
			switch ev := innerEvent.Data.(type) {
			case *slackevents.AppMentionEvent:
				_, _, err := client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false))
				if err != nil {
					return fmt.Errorf("failed posting message: %w", err)
				}
			default:
				log.Printf("Unhandled Events API event %q received", eventsAPIEvent.Type)
			}
		case socketmode.EventTypeInteractive:
			callback, ok := evt.Data.(slack.InteractionCallback)
			if !ok {
				log.Printf("Event with type %q had unexpected data type %T", evt.Type, evt.Data)
				continue
			case *slackevents.MemberJoinedChannelEvent:
				log.Printf("user %q joined to channel %q", ev.User, ev.Channel)
			}
		default:
			log.Printf("Unhandled Events API event %q received", eventsAPIEvent.Type)
		}
	case socketmode.EventTypeInteractive:
		callback, ok := evt.Data.(slack.InteractionCallback)
		if !ok {
			return fmt.Errorf("Event with type %q had unexpected data type %T", evt.Type, evt.Data)
		}

			log.Printf("Interaction received: %+v", callback)
		log.Printf("Interaction received: %+v", callback)

			var payload interface{}
		var payload interface{}

			switch callback.Type {
			case slack.InteractionTypeBlockActions:
				// See https://api.slack.com/apis/connections/socket-implement#button
				log.Printf("button clicked!")
			case slack.InteractionTypeShortcut:
			case slack.InteractionTypeViewSubmission:
				// See https://api.slack.com/apis/connections/socket-implement#modal
			case slack.InteractionTypeDialogSubmission:
			default:
		switch callback.Type {
		case slack.InteractionTypeBlockActions:
			// See https://api.slack.com/apis/connections/socket-implement#button
			log.Printf("button clicked!")
		case slack.InteractionTypeShortcut:
		case slack.InteractionTypeViewSubmission:
			// See https://api.slack.com/apis/connections/socket-implement#modal
		case slack.InteractionTypeDialogSubmission:
		default:

			}
		}

			client.Ack(*evt.Request, payload)
		case socketmode.EventTypeSlashCommand:
			cmd, ok := evt.Data.(slack.SlashCommand)
			if !ok {
				log.Printf("Event with type %q had unexpected data type %T", evt.Type, evt.Data)
				continue
			}
		client.Ack(*evt.Request, payload)
	case socketmode.EventTypeSlashCommand:
		cmd, ok := evt.Data.(slack.SlashCommand)
		if !ok {
			return fmt.Errorf("Event with type %q had unexpected data type %T", evt.Type, evt.Data)
		}

			log.Printf("Slash command received: %+v", cmd)

			payload := map[string]interface{}{
				"blocks": []slack.Block{
					slack.NewSectionBlock(
						&slack.TextBlockObject{
							Type: slack.MarkdownType,
							Text: "foo",
						},
						nil,
						slack.NewAccessory(
							slack.NewButtonBlockElement(
								"",
								"somevalue",
								&slack.TextBlockObject{
									Type: slack.PlainTextType,
									Text: "bar",
								},
							),
		log.Printf("Slash command received: %+v", cmd)

		payload := map[string]interface{}{
			"blocks": []slack.Block{
				slack.NewSectionBlock(
					&slack.TextBlockObject{
						Type: slack.MarkdownType,
						Text: "foo",
					},
					nil,
					slack.NewAccessory(
						slack.NewButtonBlockElement(
							"",
							"somevalue",
							&slack.TextBlockObject{
								Type: slack.PlainTextType,
								Text: "bar",
							},
						),
					),
				},
			}

			client.Ack(*evt.Request, payload)
		default:
			log.Printf("Unhandled event type received: %s", evt.Type)
				),
			},
		}

		client.Ack(*evt.Request, payload)
	default:
		log.Printf("Unhandled event type received: %s", evt.Type)
	}

	return nil
}

func testCalendar(url string) error {
func loadEvents(url string) ([]CalendarEvent, error) {
	resp, err := http.Get(url)
	if err != nil {
		return fmt.Errorf("failed to load test calendar: %w", err)
		return nil, fmt.Errorf("failed to load test calendar: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("unexpected response status %d", resp.StatusCode)
		return nil, fmt.Errorf("unexpected response status %d", resp.StatusCode)
	}
	cal, err := ics.ParseCalendar(resp.Body)
	if err != nil {
		return fmt.Errorf("failed to parse calendar: %w", err)
		return nil, fmt.Errorf("failed to parse calendar: %w", err)
	}

	loc, err := time.LoadLocation("America/Los_Angeles")
	if err != nil {
		return fmt.Errorf("failed to load tz: %w", err)
		return nil, fmt.Errorf("failed to load tz: %w", err)
	}
	for i, evt := range cal.Events() {
	var out []CalendarEvent
	for _, evt := range cal.Events() {
		summary := evt.GetProperty(ics.ComponentPropertySummary)
		if summary == nil {
			return errors.New("event had no summary")
			return nil, errors.New("event had no summary")
		}
		id := evt.GetProperty(ics.ComponentPropertyUniqueId)
		if id == nil {
			return errors.New("event had no id")
			return nil, errors.New("event had no id")
		}
		lastUpdated, err := evt.GetLastModifiedAt()
		if err != nil {
			return fmt.Errorf("failed to get last modified time: %w", err)
			return nil, fmt.Errorf("failed to get last modified time: %w", err)
		}
		start, err := evt.GetStartAt()
		if err != nil {
			return fmt.Errorf("failed to get start time: %w", err)
			return nil, fmt.Errorf("failed to get start time: %w", err)
		}
		end, err := evt.GetEndAt()
		if err != nil {
			return fmt.Errorf("failed to get end time: %w", err)
			return nil, fmt.Errorf("failed to get end time: %w", err)
		}
		fmt.Printf("EVENT %d, %s: %q %q-%q %q\n",
			i, id.Value,
			summary.Value,
			start.In(loc),
			end.In(loc),
			lastUpdated.In(loc),
		)
		out = append(out, CalendarEvent{
			ID:          id.Value,
			Title:       summary.Value,
			Start:       start.In(loc),
			End:         end.In(loc),
			LastUpdated: lastUpdated.In(loc),
		})
	}

	// TODO: Remove this once we're ready to test Slack stuff.
	return errors.New("just here to avoid initializing Slack code")
	// return nil
	return nil, errors.New("just here to avoid initializing Slack code")
	// return out, nil
}