Browse Source

Add docs for all the remaining services and convert them to use NewDefaultService

My hands hurt.
kegan/service-docs
Kegan Dougal 8 years ago
parent
commit
3fec1c6fa5
  1. 23
      src/github.com/matrix-org/go-neb/services/echo/echo.go
  2. 8
      src/github.com/matrix-org/go-neb/services/giphy/giphy.go
  3. 53
      src/github.com/matrix-org/go-neb/services/github/github.go
  4. 81
      src/github.com/matrix-org/go-neb/services/github/github_webhook.go
  5. 30
      src/github.com/matrix-org/go-neb/services/guggy/guggy.go
  6. 71
      src/github.com/matrix-org/go-neb/services/jira/jira.go
  7. 81
      src/github.com/matrix-org/go-neb/services/rssbot/rssbot.go
  8. 11
      src/github.com/matrix-org/go-neb/services/rssbot/rssbot_test.go
  9. 10
      src/github.com/matrix-org/go-neb/types/service.go

23
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 ( import (
"strings" "strings"
@ -7,16 +8,18 @@ import (
"github.com/matrix-org/go-neb/types" "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 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{ return []types.Command{
types.Command{ types.Command{
Path: []string{"echo"}, Path: []string{"echo"},
@ -29,6 +32,8 @@ func (e *echoService) Commands(cli *matrix.Client, roomID string) []types.Comman
func init() { func init() {
types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service {
return &echoService{id: serviceID, serviceUserID: serviceUserID}
return &Service{
DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
}
}) })
} }

8
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. // Package giphy implements a Service which adds !commands for Giphy.
//
// Commands are of the form: "!giphy some search query".
package giphy package giphy
import ( import (
@ -16,7 +14,7 @@ import (
"github.com/matrix-org/go-neb/types" "github.com/matrix-org/go-neb/types"
) )
// ServiceType of the Giphy service
// ServiceType of the Giphy service.
const ServiceType = "giphy" const ServiceType = "giphy"
type result struct { type result struct {
@ -39,8 +37,8 @@ type giphySearch struct {
// Service contains the Config fields for this service. // Service contains the Config fields for this service.
type Service struct { type Service struct {
types.DefaultService 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"` APIKey string `json:"api_key"`
} }

53
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 ( import (
"database/sql" "database/sql"
@ -16,22 +20,23 @@ import (
"github.com/matrix-org/go-neb/types" "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. // 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 // 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 ownerRepoIssueRegex = regexp.MustCompile(`(([A-z0-9-_]+)/([A-z0-9-_]+))?#([0-9]+)`)
var ownerRepoRegex = regexp.MustCompile(`^([A-z0-9-_]+)/([A-z0-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 types.DefaultService
id string
serviceUserID 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 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) cli := s.githubClientFor(userID, false)
if cli == nil { if cli == nil {
r, err := database.GetServiceDB().LoadAuthRealm(s.RealmID) 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 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) cli := s.githubClientFor(userID, true)
i, _, err := cli.Issues.Get(owner, repo, issueNum) 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{ return []types.Command{
types.Command{ types.Command{
Path: []string{"github", "create"}, 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{ return []types.Expansion{
types.Expansion{ types.Expansion{
Regexp: ownerRepoIssueRegex, 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 // 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. // 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 == "" { if s.RealmID == "" {
return fmt.Errorf("RealmID is required") 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. // 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{ logger := log.WithFields(log.Fields{
"room_id": roomID, "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 != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
logger.WithError(err).Error("Failed to load bot options") logger.WithError(err).Error("Failed to load bot options")
@ -243,7 +259,7 @@ func (s *githubService) defaultRepo(roomID string) string {
return defaultRepo 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) token, err := getTokenForUser(s.RealmID, userID)
if err != nil { if err != nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@ -287,9 +303,8 @@ func getTokenForUser(realmID, userID string) (string, error) {
func init() { func init() {
types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service {
return &githubService{
id: serviceID,
serviceUserID: serviceUserID,
return &Service{
DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
} }
}) })
} }

81
src/github.com/matrix-org/go-neb/services/github/github_webhook.go

@ -1,7 +1,11 @@
package services
package github
import ( import (
"fmt" "fmt"
"net/http"
"sort"
"strings"
log "github.com/Sirupsen/logrus" log "github.com/Sirupsen/logrus"
"github.com/google/go-github/github" "github.com/google/go-github/github"
"github.com/matrix-org/go-neb/database" "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/services/github/webhook"
"github.com/matrix-org/go-neb/types" "github.com/matrix-org/go-neb/types"
"github.com/matrix-org/go-neb/util" "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 types.DefaultService
id string
serviceUserID string
webhookEndpointURL string webhookEndpointURL string
// The user ID to create/delete webhooks as.
ClientUserID string ClientUserID string
// The ID of an existing "github" realm. This realm will be used to obtain
// the Github credentials of the ClientUserID.
RealmID string RealmID string
SecretToken string
Rooms map[string]struct { // room_id => {}
// 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"] } 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 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) evType, repo, msg, err := webhook.OnReceiveRequest(req, s.SecretToken)
if err != nil { if err != nil {
w.WriteHeader(err.Code) 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 // 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. // 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 == "" { if s.RealmID == "" || s.ClientUserID == "" {
return fmt.Errorf("RealmID and ClientUserID is required") 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. // Fetch the old service list and work out the difference between the two services.
var oldRepos []string var oldRepos []string
if oldService != nil { if oldService != nil {
old, ok := oldService.(*githubWebhookService)
old, ok := oldService.(*WebhookService)
if !ok { if !ok {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"service_id": oldService.ServiceID(), "service_id": oldService.ServiceID(),
"service_type": oldService.ServiceType(), "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 // non-fatal though, we'll just make the hooks
} else { } else {
oldRepos = old.repoList() oldRepos = old.repoList()
@ -158,19 +175,18 @@ func (s *githubWebhookService) Register(oldService types.Service, client *matrix
return nil 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 // Fetch the old service list
var oldRepos []string var oldRepos []string
if oldService != nil { if oldService != nil {
old, ok := oldService.(*githubWebhookService)
old, ok := oldService.(*WebhookService)
if !ok { if !ok {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"service_id": oldService.ServiceID(), "service_id": oldService.ServiceID(),
"service_type": oldService.ServiceType(), "service_type": oldService.ServiceType(),
}).Print("Cannot cast old github service to GithubWebhookService")
}).Print("Cannot cast old github service to WebhookService")
return return
} }
oldRepos = old.repoList() 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 { for roomID := range s.Rooms {
if _, err := client.JoinRoom(roomID, "", ""); err != nil { if _, err := client.JoinRoom(roomID, "", ""); err != nil {
// TODO: Leave the rooms we successfully joined? // 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" // Returns a list of "owner/repos"
func (s *githubWebhookService) repoList() []string {
func (s *WebhookService) repoList() []string {
var repos []string var repos []string
if s.Rooms == nil { if s.Rooms == nil {
return repos return repos
@ -242,7 +258,7 @@ func (s *githubWebhookService) repoList() []string {
return repos 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, "/") o := strings.Split(ownerRepo, "/")
owner := o[0] owner := o[0]
repo := o[1] repo := o[1]
@ -279,7 +295,7 @@ func (s *githubWebhookService) createHook(cli *github.Client, ownerRepo string)
return err return err
} }
func (s *githubWebhookService) deleteHook(owner, repo string) error {
func (s *WebhookService) deleteHook(owner, repo string) error {
logger := log.WithFields(log.Fields{ logger := log.WithFields(log.Fields{
"endpoint": s.webhookEndpointURL, "endpoint": s.webhookEndpointURL,
"repo": owner + "/" + repo, "repo": owner + "/" + repo,
@ -322,8 +338,8 @@ func (s *githubWebhookService) deleteHook(owner, repo string) error {
return err 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) r := make(map[string]bool)
for _, roomConfig := range s.Rooms { for _, roomConfig := range s.Rooms {
for ownerRepo := range roomConfig.Repos { for ownerRepo := range roomConfig.Repos {
@ -353,7 +369,7 @@ func sameRepos(a *githubWebhookService, b *githubWebhookService) bool {
return true 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) token, err := getTokenForUser(s.RealmID, userID)
if err != nil { if err != nil {
log.WithFields(log.Fields{ 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 == "" { if s.RealmID == "" {
return nil, fmt.Errorf("Missing RealmID") return nil, fmt.Errorf("Missing RealmID")
} }
@ -389,9 +405,8 @@ func (s *githubWebhookService) loadRealm() (types.AuthRealm, error) {
func init() { func init() {
types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { 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, webhookEndpointURL: webhookEndpointURL,
} }
}) })

30
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 ( import (
"bytes" "bytes"
@ -14,6 +15,9 @@ import (
"github.com/matrix-org/go-neb/types" "github.com/matrix-org/go-neb/types"
) )
// ServiceType of the Guggy service
const ServiceType = "guggy"
type guggyQuery struct { type guggyQuery struct {
// "mp4" or "gif" // "mp4" or "gif"
Format string `json:"format"` Format string `json:"format"`
@ -28,18 +32,17 @@ type guggyGifResult struct {
Height float64 `json:"height"` Height float64 `json:"height"`
} }
type guggyService struct {
// Service contains the Config fields for this service.
type Service struct {
types.DefaultService types.DefaultService
id string
serviceUserID string
// The Guggy API key to use when making HTTP requests to Guggy.
APIKey string `json:"api_key"` 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{ return []types.Command{
types.Command{ types.Command{
Path: []string{"guggy"}, 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. // only 1 arg which is the text to search for.
querySentence := strings.Join(args, " ") querySentence := strings.Join(args, " ")
gifResult, err := s.text2gifGuggy(querySentence) 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 // 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) log.Info("Transforming to GIF query ", querySentence)
client := &http.Client{} client := &http.Client{}
@ -135,9 +138,8 @@ func (s *guggyService) text2gifGuggy(querySentence string) (*guggyGifResult, err
func init() { func init() {
types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service {
return &guggyService{
id: serviceID,
serviceUserID: serviceUserID,
return &Service{
DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
} }
}) })
} }

71
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 ( import (
"database/sql" "database/sql"
@ -19,30 +22,39 @@ import (
"github.com/matrix-org/go-neb/types" "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" // Matches alphas then a -, then a number. E.g "FOO-123"
var issueKeyRegex = regexp.MustCompile("([A-z]+)-([0-9]+)") var issueKeyRegex = regexp.MustCompile("([A-z]+)-([0-9]+)")
var projectKeyRegex = regexp.MustCompile("^[A-z]+$") var projectKeyRegex = regexp.MustCompile("^[A-z]+$")
type jiraService struct {
// Service contains the Config fields for this service.
type Service struct {
types.DefaultService types.DefaultService
id string
serviceUserID string
webhookEndpointURL string webhookEndpointURL string
// 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 ClientUserID string
Rooms map[string]struct { // room_id => {}
Realms map[string]struct { // realm_id => {} Determines the JIRA endpoint
Projects map[string]struct { // SYN => {}
// 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 Expand bool
// True to add a webhook to this project and send updates into the room.
Track bool 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 // 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 // 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. // need to do this for each unique realm.
@ -63,7 +75,7 @@ func (s *jiraService) Register(oldService types.Service, client *matrix.Client)
return nil 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" // E.g jira create PROJ "Issue title" "Issue desc"
if len(args) <= 1 { if len(args) <= 1 {
return nil, errors.New("Missing project key (e.g 'ABC') and/or title") 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 }, 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"] // issueKeyGroups => ["SYN-123", "SYN", "123"]
if len(issueKeyGroups) != 3 { if len(issueKeyGroups) != 3 {
log.WithField("groups", issueKeyGroups).Error("Bad number of groups") 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{ return []types.Command{
types.Command{ types.Command{
Path: []string{"jira", "create"}, 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{ return []types.Expansion{
types.Expansion{ types.Expansion{
Regexp: issueKeyRegex, 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) eventProjectKey, event, httpErr := webhook.OnReceiveRequest(req)
if httpErr != nil { if httpErr != nil {
log.WithError(httpErr).Print("Failed to handle JIRA webhook") 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) 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. // TODO: Multiple realms with the same pkey will be randomly chosen.
for r, realmConfig := range s.Rooms[roomID].Realms { for r, realmConfig := range s.Rooms[roomID].Realms {
for pkey, projectConfig := range realmConfig.Projects { for pkey, projectConfig := range realmConfig.Projects {
@ -277,7 +305,7 @@ func (s *jiraService) realmIDForProject(roomID, projectKey string) string {
return "" 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: // 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 // - 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 // 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] // 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) ridsToProjects := make(map[string][]string)
for _, roomConfig := range s.Rooms { for _, roomConfig := range s.Rooms {
for realmID, realmConfig := range roomConfig.Realms { for realmID, realmConfig := range roomConfig.Realms {
@ -401,9 +429,8 @@ func htmlForEvent(whe *webhook.Event, jiraBaseURL string) string {
func init() { func init() {
types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { 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, webhookEndpointURL: webhookEndpointURL,
} }
}) })

81
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 ( import (
"errors" "errors"
"fmt" "fmt"
"html"
"net/http"
"net/url"
"strconv"
"time"
log "github.com/Sirupsen/logrus" log "github.com/Sirupsen/logrus"
"github.com/die-net/lrucache" "github.com/die-net/lrucache"
"github.com/gregjones/httpcache" "github.com/gregjones/httpcache"
@ -12,13 +19,11 @@ import (
"github.com/matrix-org/go-neb/types" "github.com/matrix-org/go-neb/types"
"github.com/mmcdole/gofeed" "github.com/mmcdole/gofeed"
"github.com/prometheus/client_golang/prometheus" "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 cachingClient *http.Client
var ( var (
@ -30,32 +35,36 @@ var (
const minPollingIntervalSeconds = 60 * 5 // 5 min (News feeds can be genuinely spammy) 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 types.DefaultService
id string
serviceUserID string
Feeds map[string]struct { // feed_url => { }
// 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"` PollIntervalMins int `json:"poll_interval_mins"`
// The list of rooms to send feed updates into. This cannot be empty.
Rooms []string `json:"rooms"` 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.
// 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"` } `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. // 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 { if len(s.Feeds) == 0 {
// this is an error UNLESS the old service had some feeds in which case they are deleting us :( // this is an error UNLESS the old service had some feeds in which case they are deleting us :(
var numOldFeeds int var numOldFeeds int
oldFeedService, ok := oldService.(*rssBotService)
oldFeedService, ok := oldService.(*Service)
if !ok { 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 { } else {
numOldFeeds = len(oldFeedService.Feeds) numOldFeeds = len(oldFeedService.Feeds)
} }
@ -80,7 +89,7 @@ func (s *rssBotService) Register(oldService types.Service, client *matrix.Client
return nil return nil
} }
func (s *rssBotService) joinRooms(client *matrix.Client) {
func (s *Service) joinRooms(client *matrix.Client) {
roomSet := make(map[string]bool) roomSet := make(map[string]bool)
for _, feedInfo := range s.Feeds { for _, feedInfo := range s.Feeds {
for _, roomID := range feedInfo.Rooms { 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 :( if len(s.Feeds) == 0 { // bye-bye :(
logger := log.WithFields(log.Fields{ logger := log.WithFields(log.Fields{
"service_id": s.ServiceID(), "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{ logger := log.WithFields(log.Fields{
"service_id": s.ServiceID(), "service_id": s.ServiceID(),
"service_type": s.ServiceType(), "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 // return the earliest next poll ts
var earliestNextTs int64 var earliestNextTs int64
for _, feedInfo := range s.Feeds { 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 // 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") log.WithField("feed_url", feedURL).Info("Querying feed")
var items []gofeed.Item var items []gofeed.Item
fp := gofeed.NewParser() fp := gofeed.NewParser()
@ -262,7 +282,7 @@ func (s *rssBotService) queryFeed(feedURL string) (*gofeed.Feed, []gofeed.Item,
return feed, items, nil 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 { for _, i := range allItems {
if i == nil { if i == nil {
continue continue
@ -293,7 +313,7 @@ func (s *rssBotService) newItems(feedURL string, allItems []*gofeed.Item) (items
return 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 := log.WithField("feed_url", feedURL).WithField("title", item.Title)
logger.Info("New feed item") logger.Info("New feed item")
for _, roomID := range s.Feeds[feedURL].Rooms { for _, roomID := range s.Feeds[feedURL].Rooms {
@ -327,9 +347,8 @@ func init() {
Transport: userAgentRoundTripper{httpcache.NewTransport(lruCache)}, Transport: userAgentRoundTripper{httpcache.NewTransport(lruCache)},
} }
types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service { 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 return r
}) })

11
src/github.com/matrix-org/go-neb/services/rssbot/rssbot_test.go

@ -1,12 +1,9 @@
package services
package rssbot
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/matrix-org/go-neb/database"
"github.com/matrix-org/go-neb/matrix"
"github.com/matrix-org/go-neb/types"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
@ -14,6 +11,10 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/matrix-org/go-neb/database"
"github.com/matrix-org/go-neb/matrix"
"github.com/matrix-org/go-neb/types"
) )
const rssFeedXML = ` const rssFeedXML = `
@ -66,7 +67,7 @@ func TestHTMLEntities(t *testing.T) {
if err != nil { if err != nil {
t.Fatal("Failed to create RSS bot: ", err) 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 // Configure the service to force OnPoll to query the RSS feed and attempt to send results
// to the right room. // to the right room.

10
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} 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 { func (s *DefaultService) ServiceID() string {
return s.id 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 { func (s *DefaultService) ServiceUserID() string {
return s.serviceUserID return s.serviceUserID
} }
// ServiceType returns the type of service. See each individual service package for the ServiceType constant // 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 { func (s *DefaultService) ServiceType() string {
return s.serviceType return s.serviceType
} }

Loading…
Cancel
Save