diff --git a/src/github.com/matrix-org/go-neb/services/echo/echo.go b/src/github.com/matrix-org/go-neb/services/echo/echo.go index 7c90b0c..939441f 100644 --- a/src/github.com/matrix-org/go-neb/services/echo/echo.go +++ b/src/github.com/matrix-org/go-neb/services/echo/echo.go @@ -1,4 +1,5 @@ -package services +// Package echo implements a Service which echoes back !commands. +package echo import ( "strings" @@ -7,16 +8,18 @@ import ( "github.com/matrix-org/go-neb/types" ) -type echoService struct { +// ServiceType of the Echo service +const ServiceType = "echo" + +// Service represents the Echo service. It has no Config fields. +type Service struct { types.DefaultService - id string - serviceUserID string } -func (e *echoService) ServiceUserID() string { return e.serviceUserID } -func (e *echoService) ServiceID() string { return e.id } -func (e *echoService) ServiceType() string { return "echo" } -func (e *echoService) Commands(cli *matrix.Client, roomID string) []types.Command { +// Commands supported: +// !echo some message +// Responds with a notice of "some message". +func (e *Service) Commands(cli *matrix.Client, roomID string) []types.Command { return []types.Command{ types.Command{ Path: []string{"echo"}, @@ -29,6 +32,8 @@ func (e *echoService) Commands(cli *matrix.Client, roomID string) []types.Comman func init() { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { - return &echoService{id: serviceID, serviceUserID: serviceUserID} + return &Service{ + DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), + } }) } diff --git a/src/github.com/matrix-org/go-neb/services/giphy/giphy.go b/src/github.com/matrix-org/go-neb/services/giphy/giphy.go index 538557b..53a4697 100644 --- a/src/github.com/matrix-org/go-neb/services/giphy/giphy.go +++ b/src/github.com/matrix-org/go-neb/services/giphy/giphy.go @@ -1,6 +1,4 @@ // Package giphy implements a Service which adds !commands for Giphy. -// -// Commands are of the form: "!giphy some search query". package giphy import ( @@ -16,7 +14,7 @@ import ( "github.com/matrix-org/go-neb/types" ) -// ServiceType of the Giphy service +// ServiceType of the Giphy service. const ServiceType = "giphy" type result struct { @@ -39,8 +37,8 @@ type giphySearch struct { // Service contains the Config fields for this service. type Service struct { types.DefaultService - // The Giphy API key to use when making HTTP requests to Giphy. The public beta - // API key is "dc6zaTOxFJmzC". + // The Giphy API key to use when making HTTP requests to Giphy. + // The public beta API key is "dc6zaTOxFJmzC". APIKey string `json:"api_key"` } diff --git a/src/github.com/matrix-org/go-neb/services/github/github.go b/src/github.com/matrix-org/go-neb/services/github/github.go index 4190711..e10ac14 100644 --- a/src/github.com/matrix-org/go-neb/services/github/github.go +++ b/src/github.com/matrix-org/go-neb/services/github/github.go @@ -1,4 +1,8 @@ -package services +// Package github implements a command service and a webhook service for interacting with Github. +// +// The command service is a service which adds !commands and issue expansions for Github. The +// webhook service adds Github webhook support. +package github import ( "database/sql" @@ -16,22 +20,23 @@ import ( "github.com/matrix-org/go-neb/types" ) +// ServiceType of the Github service +const ServiceType = "github" + // Matches alphanumeric then a /, then more alphanumeric then a #, then a number. // E.g. owner/repo#11 (issue/PR numbers) - Captured groups for owner/repo/number var ownerRepoIssueRegex = regexp.MustCompile(`(([A-z0-9-_]+)/([A-z0-9-_]+))?#([0-9]+)`) var ownerRepoRegex = regexp.MustCompile(`^([A-z0-9-_]+)/([A-z0-9-_]+)$`) -type githubService struct { +// Service contains the Config fields for this service. +type Service struct { types.DefaultService - id string - serviceUserID string - RealmID string + // The ID of an existing "github" realm. This realm will be used to obtain + // credentials of users when they create issues on Github. + RealmID string } -func (s *githubService) ServiceUserID() string { return s.serviceUserID } -func (s *githubService) ServiceID() string { return s.id } -func (s *githubService) ServiceType() string { return "github" } -func (s *githubService) cmdGithubCreate(roomID, userID string, args []string) (interface{}, error) { +func (s *Service) cmdGithubCreate(roomID, userID string, args []string) (interface{}, error) { cli := s.githubClientFor(userID, false) if cli == nil { r, err := database.GetServiceDB().LoadAuthRealm(s.RealmID) @@ -107,7 +112,7 @@ func (s *githubService) cmdGithubCreate(roomID, userID string, args []string) (i return matrix.TextMessage{"m.notice", fmt.Sprintf("Created issue: %s", *issue.HTMLURL)}, nil } -func (s *githubService) expandIssue(roomID, userID, owner, repo string, issueNum int) interface{} { +func (s *Service) expandIssue(roomID, userID, owner, repo string, issueNum int) interface{} { cli := s.githubClientFor(userID, true) i, _, err := cli.Issues.Get(owner, repo, issueNum) @@ -126,7 +131,12 @@ func (s *githubService) expandIssue(roomID, userID, owner, repo string, issueNum } } -func (s *githubService) Commands(cli *matrix.Client, roomID string) []types.Command { +// Commands supported: +// !github create owner/repo "issue title" "optional issue description" +// Responds with the outcome of the issue creation request. This command requires +// a Github account to be linked to the Matrix user ID issuing the command. If there +// is no link, it will return a Starter Link instead. +func (s *Service) Commands(cli *matrix.Client, roomID string) []types.Command { return []types.Command{ types.Command{ Path: []string{"github", "create"}, @@ -137,7 +147,13 @@ func (s *githubService) Commands(cli *matrix.Client, roomID string) []types.Comm } } -func (s *githubService) Expansions(cli *matrix.Client, roomID string) []types.Expansion { +// Expansions expands strings of the form: +// owner/repo#12 +// Where #12 is an issue number or pull request. If there is a default repository set on the room, +// it will also expand strings of the form: +// #12 +// using the default repository. +func (s *Service) Expansions(cli *matrix.Client, roomID string) []types.Expansion { return []types.Expansion{ types.Expansion{ Regexp: ownerRepoIssueRegex, @@ -195,7 +211,7 @@ func (s *githubService) Expansions(cli *matrix.Client, roomID string) []types.Ex // // Hooks can get out of sync if a user manually deletes a hook in the Github UI. In this case, toggling the repo configuration will // force NEB to recreate the hook. -func (s *githubService) Register(oldService types.Service, client *matrix.Client) error { +func (s *Service) Register(oldService types.Service, client *matrix.Client) error { if s.RealmID == "" { return fmt.Errorf("RealmID is required") } @@ -214,12 +230,12 @@ func (s *githubService) Register(oldService types.Service, client *matrix.Client } // defaultRepo returns the default repo for the given room, or an empty string. -func (s *githubService) defaultRepo(roomID string) string { +func (s *Service) defaultRepo(roomID string) string { logger := log.WithFields(log.Fields{ "room_id": roomID, - "bot_user_id": s.serviceUserID, + "bot_user_id": s.ServiceUserID(), }) - opts, err := database.GetServiceDB().LoadBotOptions(s.serviceUserID, roomID) + opts, err := database.GetServiceDB().LoadBotOptions(s.ServiceUserID(), roomID) if err != nil { if err != sql.ErrNoRows { logger.WithError(err).Error("Failed to load bot options") @@ -243,7 +259,7 @@ func (s *githubService) defaultRepo(roomID string) string { return defaultRepo } -func (s *githubService) githubClientFor(userID string, allowUnauth bool) *github.Client { +func (s *Service) githubClientFor(userID string, allowUnauth bool) *github.Client { token, err := getTokenForUser(s.RealmID, userID) if err != nil { log.WithFields(log.Fields{ @@ -287,9 +303,8 @@ func getTokenForUser(realmID, userID string) (string, error) { func init() { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { - return &githubService{ - id: serviceID, - serviceUserID: serviceUserID, + return &Service{ + DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), } }) } diff --git a/src/github.com/matrix-org/go-neb/services/github/github_webhook.go b/src/github.com/matrix-org/go-neb/services/github/github_webhook.go index a87304b..b2393b0 100644 --- a/src/github.com/matrix-org/go-neb/services/github/github_webhook.go +++ b/src/github.com/matrix-org/go-neb/services/github/github_webhook.go @@ -1,7 +1,11 @@ -package services +package github import ( "fmt" + "net/http" + "sort" + "strings" + log "github.com/Sirupsen/logrus" "github.com/google/go-github/github" "github.com/matrix-org/go-neb/database" @@ -10,30 +14,43 @@ import ( "github.com/matrix-org/go-neb/services/github/webhook" "github.com/matrix-org/go-neb/types" "github.com/matrix-org/go-neb/util" - "net/http" - "sort" - "strings" ) -type githubWebhookService struct { +// WebhookServiceType of the Github Webhook service. +const WebhookServiceType = "github-webhook" + +// WebhookService contains the Config fields for this service. +type WebhookService struct { types.DefaultService - id string - serviceUserID string webhookEndpointURL string - ClientUserID string - RealmID string - SecretToken string - Rooms map[string]struct { // room_id => {} + // The user ID to create/delete webhooks as. + ClientUserID string + // The ID of an existing "github" realm. This realm will be used to obtain + // the Github credentials of the ClientUserID. + RealmID string + // A map from Matrix room ID to Github "owner/repo"-style repositories. + Rooms map[string]struct { + // A map of "owner/repo"-style repositories to the events to listen for. Repos map[string]struct { // owner/repo => { events: ["push","issue","pull_request"] } + // The webhook events to listen for. Currently supported: + // push, pull_request, issues, issue_comment, pull_request_review_comment + // Full list: https://developer.github.com/webhooks/#events Events []string } } + // Optional. The secret token to supply when creating the webhook. + SecretToken string } -func (s *githubWebhookService) ServiceUserID() string { return s.serviceUserID } -func (s *githubWebhookService) ServiceID() string { return s.id } -func (s *githubWebhookService) ServiceType() string { return "github-webhook" } -func (s *githubWebhookService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) { +// OnReceiveWebhook receives requests from Github and possibly sends requests to Matrix as a result. +// +// If the "owner/repo" string in the webhook request case-insensitively matches a repo in this Service +// config AND the event type matches an event type registered for that repo, then a message will be sent +// into Matrix. +// +// If the "owner/repo" string doesn't exist in this Service config, then the webhook will be deleted from +// Github. +func (s *WebhookService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) { evType, repo, msg, err := webhook.OnReceiveRequest(req, s.SecretToken) if err != nil { w.WriteHeader(err.Code) @@ -98,7 +115,7 @@ func (s *githubWebhookService) OnReceiveWebhook(w http.ResponseWriter, req *http // // Hooks can get out of sync if a user manually deletes a hook in the Github UI. In this case, toggling the repo configuration will // force NEB to recreate the hook. -func (s *githubWebhookService) Register(oldService types.Service, client *matrix.Client) error { +func (s *WebhookService) Register(oldService types.Service, client *matrix.Client) error { if s.RealmID == "" || s.ClientUserID == "" { return fmt.Errorf("RealmID and ClientUserID is required") } @@ -117,12 +134,12 @@ func (s *githubWebhookService) Register(oldService types.Service, client *matrix // Fetch the old service list and work out the difference between the two services. var oldRepos []string if oldService != nil { - old, ok := oldService.(*githubWebhookService) + old, ok := oldService.(*WebhookService) if !ok { log.WithFields(log.Fields{ "service_id": oldService.ServiceID(), "service_type": oldService.ServiceType(), - }).Print("Cannot cast old github service to GithubWebhookService") + }).Print("Cannot cast old github service to WebhookService") // non-fatal though, we'll just make the hooks } else { oldRepos = old.repoList() @@ -158,19 +175,18 @@ func (s *githubWebhookService) Register(oldService types.Service, client *matrix return nil } -func (s *githubWebhookService) PostRegister(oldService types.Service) { - // Clean up removed repositories from the old service by working out the delta between - // the old and new hooks. - +// PostRegister cleans up removed repositories from the old service by +// working out the delta between the old and new hooks. +func (s *WebhookService) PostRegister(oldService types.Service) { // Fetch the old service list var oldRepos []string if oldService != nil { - old, ok := oldService.(*githubWebhookService) + old, ok := oldService.(*WebhookService) if !ok { log.WithFields(log.Fields{ "service_id": oldService.ServiceID(), "service_type": oldService.ServiceType(), - }).Print("Cannot cast old github service to GithubWebhookService") + }).Print("Cannot cast old github service to WebhookService") return } oldRepos = old.repoList() @@ -205,7 +221,7 @@ func (s *githubWebhookService) PostRegister(oldService types.Service) { } } -func (s *githubWebhookService) joinWebhookRooms(client *matrix.Client) error { +func (s *WebhookService) joinWebhookRooms(client *matrix.Client) error { for roomID := range s.Rooms { if _, err := client.JoinRoom(roomID, "", ""); err != nil { // TODO: Leave the rooms we successfully joined? @@ -216,7 +232,7 @@ func (s *githubWebhookService) joinWebhookRooms(client *matrix.Client) error { } // Returns a list of "owner/repos" -func (s *githubWebhookService) repoList() []string { +func (s *WebhookService) repoList() []string { var repos []string if s.Rooms == nil { return repos @@ -242,7 +258,7 @@ func (s *githubWebhookService) repoList() []string { return repos } -func (s *githubWebhookService) createHook(cli *github.Client, ownerRepo string) error { +func (s *WebhookService) createHook(cli *github.Client, ownerRepo string) error { o := strings.Split(ownerRepo, "/") owner := o[0] repo := o[1] @@ -279,7 +295,7 @@ func (s *githubWebhookService) createHook(cli *github.Client, ownerRepo string) return err } -func (s *githubWebhookService) deleteHook(owner, repo string) error { +func (s *WebhookService) deleteHook(owner, repo string) error { logger := log.WithFields(log.Fields{ "endpoint": s.webhookEndpointURL, "repo": owner + "/" + repo, @@ -322,8 +338,8 @@ func (s *githubWebhookService) deleteHook(owner, repo string) error { return err } -func sameRepos(a *githubWebhookService, b *githubWebhookService) bool { - getRepos := func(s *githubWebhookService) []string { +func sameRepos(a *WebhookService, b *WebhookService) bool { + getRepos := func(s *WebhookService) []string { r := make(map[string]bool) for _, roomConfig := range s.Rooms { for ownerRepo := range roomConfig.Repos { @@ -353,7 +369,7 @@ func sameRepos(a *githubWebhookService, b *githubWebhookService) bool { return true } -func (s *githubWebhookService) githubClientFor(userID string, allowUnauth bool) *github.Client { +func (s *WebhookService) githubClientFor(userID string, allowUnauth bool) *github.Client { token, err := getTokenForUser(s.RealmID, userID) if err != nil { log.WithFields(log.Fields{ @@ -371,7 +387,7 @@ func (s *githubWebhookService) githubClientFor(userID string, allowUnauth bool) } } -func (s *githubWebhookService) loadRealm() (types.AuthRealm, error) { +func (s *WebhookService) loadRealm() (types.AuthRealm, error) { if s.RealmID == "" { return nil, fmt.Errorf("Missing RealmID") } @@ -389,9 +405,8 @@ func (s *githubWebhookService) loadRealm() (types.AuthRealm, error) { func init() { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { - return &githubWebhookService{ - id: serviceID, - serviceUserID: serviceUserID, + return &WebhookService{ + DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), webhookEndpointURL: webhookEndpointURL, } }) diff --git a/src/github.com/matrix-org/go-neb/services/guggy/guggy.go b/src/github.com/matrix-org/go-neb/services/guggy/guggy.go index 71ab92a..d4b50dd 100644 --- a/src/github.com/matrix-org/go-neb/services/guggy/guggy.go +++ b/src/github.com/matrix-org/go-neb/services/guggy/guggy.go @@ -1,4 +1,5 @@ -package services +// Package guggy implements a Service which adds !commands for Guggy. +package guggy import ( "bytes" @@ -14,6 +15,9 @@ import ( "github.com/matrix-org/go-neb/types" ) +// ServiceType of the Guggy service +const ServiceType = "guggy" + type guggyQuery struct { // "mp4" or "gif" Format string `json:"format"` @@ -28,18 +32,17 @@ type guggyGifResult struct { Height float64 `json:"height"` } -type guggyService struct { +// Service contains the Config fields for this service. +type Service struct { types.DefaultService - id string - serviceUserID string - APIKey string `json:"api_key"` + // The Guggy API key to use when making HTTP requests to Guggy. + APIKey string `json:"api_key"` } -func (s *guggyService) ServiceUserID() string { return s.serviceUserID } -func (s *guggyService) ServiceID() string { return s.id } -func (s *guggyService) ServiceType() string { return "guggy" } - -func (s *guggyService) Commands(client *matrix.Client, roomID string) []types.Command { +// Commands supported: +// !guggy some search query without quotes +// Responds with a suitable GIF into the same room as the command. +func (s *Service) Commands(client *matrix.Client, roomID string) []types.Command { return []types.Command{ types.Command{ Path: []string{"guggy"}, @@ -49,7 +52,7 @@ func (s *guggyService) Commands(client *matrix.Client, roomID string) []types.Co }, } } -func (s *guggyService) cmdGuggy(client *matrix.Client, roomID, userID string, args []string) (interface{}, error) { +func (s *Service) cmdGuggy(client *matrix.Client, roomID, userID string, args []string) (interface{}, error) { // only 1 arg which is the text to search for. querySentence := strings.Join(args, " ") gifResult, err := s.text2gifGuggy(querySentence) @@ -82,7 +85,7 @@ func (s *guggyService) cmdGuggy(client *matrix.Client, roomID, userID string, ar } // text2gifGuggy returns info about a gif -func (s *guggyService) text2gifGuggy(querySentence string) (*guggyGifResult, error) { +func (s *Service) text2gifGuggy(querySentence string) (*guggyGifResult, error) { log.Info("Transforming to GIF query ", querySentence) client := &http.Client{} @@ -135,9 +138,8 @@ func (s *guggyService) text2gifGuggy(querySentence string) (*guggyGifResult, err func init() { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { - return &guggyService{ - id: serviceID, - serviceUserID: serviceUserID, + return &Service{ + DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), } }) } diff --git a/src/github.com/matrix-org/go-neb/services/jira/jira.go b/src/github.com/matrix-org/go-neb/services/jira/jira.go index b62ed4f..718455a 100644 --- a/src/github.com/matrix-org/go-neb/services/jira/jira.go +++ b/src/github.com/matrix-org/go-neb/services/jira/jira.go @@ -1,4 +1,7 @@ -package services +// Package jira implements a command and webhook service for interacting with JIRA. +// +// The service adds !commands and issue expansions, in addition to JIRA webhook support. +package jira import ( "database/sql" @@ -19,30 +22,39 @@ import ( "github.com/matrix-org/go-neb/types" ) +// ServiceType of the JIRA Service +const ServiceType = "jira" + // Matches alphas then a -, then a number. E.g "FOO-123" var issueKeyRegex = regexp.MustCompile("([A-z]+)-([0-9]+)") var projectKeyRegex = regexp.MustCompile("^[A-z]+$") -type jiraService struct { +// Service contains the Config fields for this service. +type Service struct { types.DefaultService - id string - serviceUserID string webhookEndpointURL string - ClientUserID string - Rooms map[string]struct { // room_id => {} - Realms map[string]struct { // realm_id => {} Determines the JIRA endpoint - Projects map[string]struct { // SYN => {} + // The user ID to create issues as, or to create/delete webhooks as. This user + // is also used to look up issues for expansions. + ClientUserID string + // A map from Matrix room ID to JIRA realms and project keys. + Rooms map[string]struct { + // A map of realm IDs to project keys. The realm IDs determine the JIRA + // endpoint used. + Realms map[string]struct { + // A map of project keys e.g. "SYN" to config options. + Projects map[string]struct { + // True to expand issues with this key e.g "SYN-123" will be expanded. Expand bool - Track bool + // True to add a webhook to this project and send updates into the room. + Track bool } } } } -func (s *jiraService) ServiceUserID() string { return s.serviceUserID } -func (s *jiraService) ServiceID() string { return s.id } -func (s *jiraService) ServiceType() string { return "jira" } -func (s *jiraService) Register(oldService types.Service, client *matrix.Client) error { +// Register ensures that the given realm IDs are valid JIRA realms and registers webhooks +// with those JIRA endpoints. +func (s *Service) Register(oldService types.Service, client *matrix.Client) error { // We only ever make 1 JIRA webhook which listens for all projects and then filter // on receive. So we simply need to know if we need to make a webhook or not. We // need to do this for each unique realm. @@ -63,7 +75,7 @@ func (s *jiraService) Register(oldService types.Service, client *matrix.Client) return nil } -func (s *jiraService) cmdJiraCreate(roomID, userID string, args []string) (interface{}, error) { +func (s *Service) cmdJiraCreate(roomID, userID string, args []string) (interface{}, error) { // E.g jira create PROJ "Issue title" "Issue desc" if len(args) <= 1 { return nil, errors.New("Missing project key (e.g 'ABC') and/or title") @@ -139,7 +151,7 @@ func (s *jiraService) cmdJiraCreate(roomID, userID string, args []string) (inter }, nil } -func (s *jiraService) expandIssue(roomID, userID string, issueKeyGroups []string) interface{} { +func (s *Service) expandIssue(roomID, userID string, issueKeyGroups []string) interface{} { // issueKeyGroups => ["SYN-123", "SYN", "123"] if len(issueKeyGroups) != 3 { log.WithField("groups", issueKeyGroups).Error("Bad number of groups") @@ -199,7 +211,16 @@ func (s *jiraService) expandIssue(roomID, userID string, issueKeyGroups []string ) } -func (s *jiraService) Commands(cli *matrix.Client, roomID string) []types.Command { +// Commands supported: +// !jira create KEY "issue title" "optional issue description" +// Responds with the outcome of the issue creation request. This command requires +// a JIRA account to be linked to the Matrix user ID issuing the command. It also +// requires there to be a project with the given project key (e.g. "KEY") to exist +// on the linked JIRA account. If there are multiple JIRA accounts which contain the +// same project key, which project is chosen is undefined. If there +// is no JIRA account linked to the Matrix user ID, it will return a Starter Link +// if there is a known public project with that project key. +func (s *Service) Commands(cli *matrix.Client, roomID string) []types.Command { return []types.Command{ types.Command{ Path: []string{"jira", "create"}, @@ -210,7 +231,13 @@ func (s *jiraService) Commands(cli *matrix.Client, roomID string) []types.Comman } } -func (s *jiraService) Expansions(cli *matrix.Client, roomID string) []types.Expansion { +// Expansions expands JIRA issues represented as: +// KEY-12 +// Where "KEY" is the project key and 12" is an issue number. The Service Config will be used +// to map the project key to a realm, and subsequently the JIRA endpoint to hit. +// If there are multiple projects with the same project key in the Service Config, one will +// be chosen arbitrarily. +func (s *Service) Expansions(cli *matrix.Client, roomID string) []types.Expansion { return []types.Expansion{ types.Expansion{ Regexp: issueKeyRegex, @@ -221,7 +248,8 @@ func (s *jiraService) Expansions(cli *matrix.Client, roomID string) []types.Expa } } -func (s *jiraService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) { +// OnReceiveWebhook receives requests from JIRA and possibly sends requests to Matrix as a result. +func (s *Service) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) { eventProjectKey, event, httpErr := webhook.OnReceiveRequest(req) if httpErr != nil { log.WithError(httpErr).Print("Failed to handle JIRA webhook") @@ -265,7 +293,7 @@ func (s *jiraService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, w.WriteHeader(200) } -func (s *jiraService) realmIDForProject(roomID, projectKey string) string { +func (s *Service) realmIDForProject(roomID, projectKey string) string { // TODO: Multiple realms with the same pkey will be randomly chosen. for r, realmConfig := range s.Rooms[roomID].Realms { for pkey, projectConfig := range realmConfig.Projects { @@ -277,7 +305,7 @@ func (s *jiraService) realmIDForProject(roomID, projectKey string) string { return "" } -func (s *jiraService) projectToRealm(userID, pkey string) (*realms.JIRARealm, error) { +func (s *Service) projectToRealm(userID, pkey string) (*realms.JIRARealm, error) { // We don't know which JIRA installation this project maps to, so: // - Get all known JIRA realms and f.e query their endpoints with the // given user ID's credentials (so if it is a private project they @@ -340,7 +368,7 @@ func (s *jiraService) projectToRealm(userID, pkey string) (*realms.JIRARealm, er } // Returns realm_id => [PROJ, ECT, KEYS] -func projectsAndRealmsToTrack(s *jiraService) map[string][]string { +func projectsAndRealmsToTrack(s *Service) map[string][]string { ridsToProjects := make(map[string][]string) for _, roomConfig := range s.Rooms { for realmID, realmConfig := range roomConfig.Realms { @@ -401,9 +429,8 @@ func htmlForEvent(whe *webhook.Event, jiraBaseURL string) string { func init() { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { - return &jiraService{ - id: serviceID, - serviceUserID: serviceUserID, + return &Service{ + DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), webhookEndpointURL: webhookEndpointURL, } }) diff --git a/src/github.com/matrix-org/go-neb/services/rssbot/rssbot.go b/src/github.com/matrix-org/go-neb/services/rssbot/rssbot.go index 613e65e..f3833ab 100644 --- a/src/github.com/matrix-org/go-neb/services/rssbot/rssbot.go +++ b/src/github.com/matrix-org/go-neb/services/rssbot/rssbot.go @@ -1,8 +1,15 @@ -package services +// Package rssbot implements a Service capable of reading Atom/RSS feeds. +package rssbot import ( "errors" "fmt" + "html" + "net/http" + "net/url" + "strconv" + "time" + log "github.com/Sirupsen/logrus" "github.com/die-net/lrucache" "github.com/gregjones/httpcache" @@ -12,13 +19,11 @@ import ( "github.com/matrix-org/go-neb/types" "github.com/mmcdole/gofeed" "github.com/prometheus/client_golang/prometheus" - "html" - "net/http" - "net/url" - "strconv" - "time" ) +// ServiceType of the RSS Bot service +const ServiceType = "rssbot" + var cachingClient *http.Client var ( @@ -30,32 +35,36 @@ var ( const minPollingIntervalSeconds = 60 * 5 // 5 min (News feeds can be genuinely spammy) -type rssBotService struct { +// Service contains the Config fields for this service. +type Service struct { types.DefaultService - id string - serviceUserID string - Feeds map[string]struct { // feed_url => { } - PollIntervalMins int `json:"poll_interval_mins"` - Rooms []string `json:"rooms"` - IsFailing bool `json:"is_failing"` // True if rss bot is unable to poll this feed - FeedUpdatedTimestampSecs int64 `json:"last_updated_ts_secs"` // The time of the last successful poll - NextPollTimestampSecs int64 // Internal: When we should poll again - RecentGUIDs []string // Internal: The most recently seen GUIDs. Sized to the number of items in the feed. + // Feeds is a map of feed URL to configuration options for this feed. + Feeds map[string]struct { + // The time to wait between polls. If this is less than minPollingIntervalSeconds, it is ignored. + PollIntervalMins int `json:"poll_interval_mins"` + // The list of rooms to send feed updates into. This cannot be empty. + Rooms []string `json:"rooms"` + // True if rss bot is unable to poll this feed. This is populated by Go-NEB. Use /getService to + // retrieve this value. + IsFailing bool `json:"is_failing"` + // The time of the last successful poll. This is populated by Go-NEB. Use /getService to retrieve + // this value. + FeedUpdatedTimestampSecs int64 `json:"last_updated_ts_secs"` + // Internal field. When we should poll again. + NextPollTimestampSecs int64 + // Internal field. The most recently seen GUIDs. Sized to the number of items in the feed. + RecentGUIDs []string } `json:"feeds"` } -func (s *rssBotService) ServiceUserID() string { return s.serviceUserID } -func (s *rssBotService) ServiceID() string { return s.id } -func (s *rssBotService) ServiceType() string { return "rssbot" } - // Register will check the liveness of each RSS feed given. If all feeds check out okay, no error is returned. -func (s *rssBotService) Register(oldService types.Service, client *matrix.Client) error { +func (s *Service) Register(oldService types.Service, client *matrix.Client) error { if len(s.Feeds) == 0 { // this is an error UNLESS the old service had some feeds in which case they are deleting us :( var numOldFeeds int - oldFeedService, ok := oldService.(*rssBotService) + oldFeedService, ok := oldService.(*Service) if !ok { - log.WithField("service", oldService).Error("Old service isn't a rssBotService") + log.WithField("service", oldService).Error("Old service isn't an rssbot.Service") } else { numOldFeeds = len(oldFeedService.Feeds) } @@ -80,7 +89,7 @@ func (s *rssBotService) Register(oldService types.Service, client *matrix.Client return nil } -func (s *rssBotService) joinRooms(client *matrix.Client) { +func (s *Service) joinRooms(client *matrix.Client) { roomSet := make(map[string]bool) for _, feedInfo := range s.Feeds { for _, roomID := range feedInfo.Rooms { @@ -99,7 +108,8 @@ func (s *rssBotService) joinRooms(client *matrix.Client) { } } -func (s *rssBotService) PostRegister(oldService types.Service) { +// PostRegister deletes this service if there are no feeds remaining. +func (s *Service) PostRegister(oldService types.Service) { if len(s.Feeds) == 0 { // bye-bye :( logger := log.WithFields(log.Fields{ "service_id": s.ServiceID(), @@ -113,7 +123,17 @@ func (s *rssBotService) PostRegister(oldService types.Service) { } } -func (s *rssBotService) OnPoll(cli *matrix.Client) time.Time { +// OnPoll rechecks RSS feeds which are due to be polled. +// +// In order for a feed to be polled, the current time must be greater than NextPollTimestampSecs. +// In order for an item on a feed to be sent to Matrix, the item's GUID must not exist in RecentGUIDs. +// The GUID for an item is created according to the following rules: +// - If there is a GUID field, use it. +// - Else if there is a Link field, use it as the GUID. +// - Else if there is a Title field, use it as the GUID. +// +// Returns a timestamp representing when this Service should have OnPoll called again. +func (s *Service) OnPoll(cli *matrix.Client) time.Time { logger := log.WithFields(log.Fields{ "service_id": s.ServiceID(), "service_type": s.ServiceType(), @@ -182,7 +202,7 @@ func incrementMetrics(urlStr string, err error) { } } -func (s *rssBotService) nextTimestamp() time.Time { +func (s *Service) nextTimestamp() time.Time { // return the earliest next poll ts var earliestNextTs int64 for _, feedInfo := range s.Feeds { @@ -202,7 +222,7 @@ func (s *rssBotService) nextTimestamp() time.Time { } // Query the given feed, update relevant timestamps and return NEW items -func (s *rssBotService) queryFeed(feedURL string) (*gofeed.Feed, []gofeed.Item, error) { +func (s *Service) queryFeed(feedURL string) (*gofeed.Feed, []gofeed.Item, error) { log.WithField("feed_url", feedURL).Info("Querying feed") var items []gofeed.Item fp := gofeed.NewParser() @@ -262,7 +282,7 @@ func (s *rssBotService) queryFeed(feedURL string) (*gofeed.Feed, []gofeed.Item, return feed, items, nil } -func (s *rssBotService) newItems(feedURL string, allItems []*gofeed.Item) (items []gofeed.Item) { +func (s *Service) newItems(feedURL string, allItems []*gofeed.Item) (items []gofeed.Item) { for _, i := range allItems { if i == nil { continue @@ -293,7 +313,7 @@ func (s *rssBotService) newItems(feedURL string, allItems []*gofeed.Item) (items return } -func (s *rssBotService) sendToRooms(cli *matrix.Client, feedURL string, feed *gofeed.Feed, item gofeed.Item) error { +func (s *Service) sendToRooms(cli *matrix.Client, feedURL string, feed *gofeed.Feed, item gofeed.Item) error { logger := log.WithField("feed_url", feedURL).WithField("title", item.Title) logger.Info("New feed item") for _, roomID := range s.Feeds[feedURL].Rooms { @@ -327,9 +347,8 @@ func init() { Transport: userAgentRoundTripper{httpcache.NewTransport(lruCache)}, } types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { - r := &rssBotService{ - id: serviceID, - serviceUserID: serviceUserID, + r := &Service{ + DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), } return r }) diff --git a/src/github.com/matrix-org/go-neb/services/rssbot/rssbot_test.go b/src/github.com/matrix-org/go-neb/services/rssbot/rssbot_test.go index 8137025..cc6b260 100644 --- a/src/github.com/matrix-org/go-neb/services/rssbot/rssbot_test.go +++ b/src/github.com/matrix-org/go-neb/services/rssbot/rssbot_test.go @@ -1,12 +1,9 @@ -package services +package rssbot import ( "bytes" "encoding/json" "errors" - "github.com/matrix-org/go-neb/database" - "github.com/matrix-org/go-neb/matrix" - "github.com/matrix-org/go-neb/types" "io/ioutil" "net/http" "net/url" @@ -14,6 +11,10 @@ import ( "sync" "testing" "time" + + "github.com/matrix-org/go-neb/database" + "github.com/matrix-org/go-neb/matrix" + "github.com/matrix-org/go-neb/types" ) const rssFeedXML = ` @@ -66,7 +67,7 @@ func TestHTMLEntities(t *testing.T) { if err != nil { t.Fatal("Failed to create RSS bot: ", err) } - rssbot := srv.(*rssBotService) + rssbot := srv.(*Service) // Configure the service to force OnPoll to query the RSS feed and attempt to send results // to the right room. diff --git a/src/github.com/matrix-org/go-neb/types/service.go b/src/github.com/matrix-org/go-neb/types/service.go index f67c9f3..e6e6611 100644 --- a/src/github.com/matrix-org/go-neb/types/service.go +++ b/src/github.com/matrix-org/go-neb/types/service.go @@ -61,18 +61,22 @@ func NewDefaultService(serviceID, serviceUserID, serviceType string) DefaultServ return DefaultService{serviceID, serviceUserID, serviceType} } -// ServiceID returns the service's ID. +// ServiceID returns the service's ID. In order for this to return the ID, DefaultService MUST have been +// initialised by NewDefaultService, the zero-initialiser is NOT enough. func (s *DefaultService) ServiceID() string { return s.id } -// ServiceUserID returns the user ID that the service sends events as. +// ServiceUserID returns the user ID that the service sends events as. In order for this to return the +// service user ID, DefaultService MUST have been initialised by NewDefaultService, the zero-initialiser +// is NOT enough. func (s *DefaultService) ServiceUserID() string { return s.serviceUserID } // ServiceType returns the type of service. See each individual service package for the ServiceType constant -// to find out what this value actually is. +// to find out what this value actually is. In order for this to return the Type, DefaultService MUST have been +// initialised by NewDefaultService, the zero-initialiser is NOT enough. func (s *DefaultService) ServiceType() string { return s.serviceType }