diff --git a/context.go b/context.go index af73003..6b43b68 100644 --- a/context.go +++ b/context.go @@ -222,3 +222,62 @@ func (r *JobContext) WithContext(ctx context.Context) *JobContext { r.ctx = ctx return r } + +// newLinkContext creates a new bot context +func newLinkContext(ctx context.Context, logger Logger, slackClient *slack.Client, definition *LinkDefinition) *LinkContext { + writer := newWriter(ctx, logger, slackClient) + response := newWriterResponse(writer) + return &LinkContext{ + ctx: ctx, + definition: definition, + slackClient: slackClient, + response: response, + logger: logger, + } +} + +// LinkContext contains information relevant to the executed job +type LinkContext struct { + ctx context.Context + definition *LinkDefinition + slackClient *slack.Client + response *ResponseWriter + logger Logger +} + +// Context returns the context +func (r *LinkContext) Context() context.Context { + return r.ctx +} + +// Definition returns the job definition +func (r *LinkContext) Definition() *LinkDefinition { + return r.definition +} + +// Response returns the response writer +func (r *LinkContext) Response() *ResponseWriter { + return r.response +} + +// SlackClient returns the slack API client +func (r *LinkContext) SlackClient() *slack.Client { + return r.slackClient +} + +// Logger returns the logger +func (r *LinkContext) Logger() Logger { + return r.logger +} + +// WithContext returns a shallow copy of r with its context changed +// to ctx. The provided ctx must be non-nil. +func (r *LinkContext) WithContext(ctx context.Context) *LinkContext { + if ctx == nil { + panic("nil context") + } + r2 := new(LinkContext) + *r2 = *r + r.ctx = ctx + return r +} diff --git a/executors.go b/executors.go index e9899ef..5fbe98c 100644 --- a/executors.go +++ b/executors.go @@ -37,3 +37,17 @@ func executeJob(ctx *JobContext, handler JobHandler, middlewares ...JobMiddlewar handler(ctx) } } + +func executeLink(ctx *LinkContext, handler LinkHandler, middlewares ...LinkMiddlewareHandler) func() { + if handler == nil { + return func() {} + } + + for i := len(middlewares) - 1; i >= 0; i-- { + handler = middlewares[i](handler) + } + + return func() { + handler(ctx) + } +} diff --git a/handler.go b/handler.go index 0b56486..d095c42 100644 --- a/handler.go +++ b/handler.go @@ -17,3 +17,9 @@ type JobMiddlewareHandler func(JobHandler) JobHandler // JobHandler represents the job handler function type JobHandler func(*JobContext) + +// LinkMiddlewareHandler represents the link middleware handler function +type LinkMiddlewareHandler func(LinkHandler) LinkHandler + +// LinkHandler represents the link handler function +type LinkHandler func(*LinkContext) diff --git a/link.go b/link.go new file mode 100644 index 0000000..4b3b8ef --- /dev/null +++ b/link.go @@ -0,0 +1,30 @@ +package slacker + +// LinkDefinition structure contains definition of the job +type LinkDefinition struct { + Domain string + Name string + Description string + Middlewares []LinkMiddlewareHandler + Handler LinkHandler + + // HideHelp will hide this job definition from appearing in the `help` results. + HideHelp bool +} + +// newJob creates a new job object +func newLink(definition *LinkDefinition) *Link { + return &Link{ + definition: definition, + } +} + +// Link structure contains the job's spec and handler +type Link struct { + definition *LinkDefinition +} + +// Definition returns the job's definition +func (c *Link) Definition() *LinkDefinition { + return c.definition +} diff --git a/link_shared_event.go b/link_shared_event.go new file mode 100644 index 0000000..68f16fe --- /dev/null +++ b/link_shared_event.go @@ -0,0 +1,84 @@ +package slacker + +import ( + "net/url" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" +) + +// LinkSharedEvent contains details common to message based events, including the +// raw event as returned from Slack along with the corresponding event type. +// The struct should be kept minimal and only include data that is commonly +// used to prevent frequent type assertions when evaluating the event. +type LinkSharedEvent struct { + // Channel ID where the message was sent + ChannelID string + + // Channel contains information about the channel + Channel *slack.Channel + + // User ID of the sender + UserID string + + // UserProfile contains all the information details of a given user + UserProfile *slack.UserProfile + + // Text is the unalterted text of the message, as returned by Slack + Links []url.URL + + // TimeStamp is the message timestamp. For events that do not support + // threading (eg. slash commands) this will be unset. + // will be left unset. + TimeStamp string + + // ThreadTimeStamp is the message thread timestamp. For events that do not + // support threading (eg. slash commands) this will be unset. + ThreadTimeStamp string + + // Data is the raw event data returned from slack. Using Type, you can assert + // this into a slackevents *Event struct. + Data any + + // Type is the type of the event, as returned by Slack. For instance, + // `app_mention` or `message` + Type string +} + +// InThread indicates if a message event took place in a thread. +func (e *LinkSharedEvent) InThread() bool { + return isMessageInThread(e.ThreadTimeStamp, e.TimeStamp) +} + +// newMessageEvent creates a new message event structure +func newLinkSharedEvent(logger Logger, slackClient *slack.Client, event any) *LinkSharedEvent { + var messageEvent *LinkSharedEvent + + switch ev := event.(type) { + case *slackevents.LinkSharedEvent: + links := []url.URL{} + for _, link := range ev.Links { + if val, err := url.Parse(link.URL); err == nil { + // ignore malformed links + } else { + links = append(links, *val) + } + } + + messageEvent = &LinkSharedEvent{ + ChannelID: ev.Channel, + Channel: getChannel(logger, slackClient, ev.Channel), + UserID: ev.User, + UserProfile: getUserProfile(logger, slackClient, ev.User), + Links: links, + Data: event, + Type: ev.Type, + TimeStamp: ev.TimeStamp, + ThreadTimeStamp: ev.ThreadTimeStamp, + } + default: + return nil + } + + return messageEvent +} diff --git a/slacker.go b/slacker.go index f8962dd..5dd54dc 100644 --- a/slacker.go +++ b/slacker.go @@ -60,6 +60,8 @@ type Slacker struct { interactions map[slack.InteractionType][]*Interaction jobMiddlewares []JobMiddlewareHandler jobs []*Job + linkMiddlewares []LinkMiddlewareHandler + links map[string][]*Link onHello func(socketmode.Event) onConnected func(socketmode.Event) onConnecting func(socketmode.Event) @@ -215,6 +217,20 @@ func (s *Slacker) AddJobMiddleware(middleware JobMiddlewareHandler) { s.jobMiddlewares = append(s.jobMiddlewares, middleware) } +// AddLink define a new link and append it to the list of links +func (s *Slacker) AddLink(definition *LinkDefinition) { + if definition.Domain == "" { + s.logger.Error("missing `Domain`") + return + } + s.links[definition.Domain] = append(s.links[definition.Domain], newLink(definition)) +} + +// AddLinkMiddleware appends a new link middleware to the list of root level link middlewares +func (s *Slacker) AddLinkMiddleware(middleware LinkMiddlewareHandler) { + s.linkMiddlewares = append(s.linkMiddlewares, middleware) +} + // Listen receives events from Slack and each is handled as needed func (s *Slacker) Listen(ctx context.Context) error { s.prependHelpHandle() @@ -281,25 +297,27 @@ func (s *Slacker) Listen(ctx context.Context) error { // Acknowledge receiving the request s.socketModeClient.Ack(*socketEvent.Request) - if event.Type != slackevents.CallbackEvent { - if s.unsupportedEventHandler != nil { - s.unsupportedEventHandler(socketEvent) - } else { - s.logger.Debug("unsupported event", "event", socketEvent) + switch event.Type { + case slackevents.CallbackEvent: + switch event.InnerEvent.Type { + case "message", "app_mention": // message-based events + go s.handleMessageEvent(ctx, event.InnerEvent.Data) + case "link_shared": + go s.handleLinkSharedEvent(ctx, event.InnerEvent.Data) + default: + if s.unsupportedEventHandler != nil { + s.unsupportedEventHandler(socketEvent) + } else { + s.logger.Debug("unsupported event", "event", socketEvent) + } } - continue - } - - switch event.InnerEvent.Type { - case "message", "app_mention": // message-based events - go s.handleMessageEvent(ctx, event.InnerEvent.Data) - default: if s.unsupportedEventHandler != nil { s.unsupportedEventHandler(socketEvent) } else { s.logger.Debug("unsupported event", "event", socketEvent) } + continue } case socketmode.EventTypeSlashCommand: @@ -541,6 +559,31 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, event any) { } } +func (s *Slacker) handleLinkSharedEvent(ctx context.Context, event any) { + linkSharedEvent := newLinkSharedEvent(s.logger, s.slackClient, event) + if linkSharedEvent == nil { + // event doesn't appear to be a valid message type + return + } + + middlewares := make([]LinkMiddlewareHandler, 0) + middlewares = append(middlewares, s.linkMiddlewares...) + + for _, group := range s.links { + for _, link := range group { + definition := link.Definition() + if definition.Domain != linkSharedEvent.Links[0].Host { + continue + } + + ctx := newLinkContext(ctx, s.logger, s.slackClient, definition) + middlewares = append(middlewares, definition.Middlewares...) + executeLink(ctx, definition.Handler, middlewares...) + return + } + } +} + func (s *Slacker) ignoreBotMessage(messageEvent *MessageEvent) bool { switch s.botInteractionMode { case BotModeIgnoreApp: