diff --git a/src/github.com/matrix-org/go-neb/clients/clients.go b/src/github.com/matrix-org/go-neb/clients/clients.go index aca2fa3..0207a5f 100644 --- a/src/github.com/matrix-org/go-neb/clients/clients.go +++ b/src/github.com/matrix-org/go-neb/clients/clients.go @@ -7,6 +7,7 @@ import ( "github.com/matrix-org/go-neb/plugin" "github.com/matrix-org/go-neb/types" "net/url" + "strings" "sync" ) @@ -155,6 +156,30 @@ func (c *Clients) newClient(config types.ClientConfig) (*matrix.Client, error) { plugin.OnMessage(plugins, client, event) }) + client.Worker.OnEventType("m.room.bot.options", func(event *matrix.Event) { + // see if these options are for us. The state key is the user ID with a leading _ + // to get around restrictions in the HS about having user IDs as state keys. + targetUserID := strings.TrimPrefix(event.StateKey, "_") + if targetUserID != client.UserID { + return + } + // these options fully clobber what was there previously. + opts := types.BotOptions{ + UserID: client.UserID, + RoomID: event.RoomID, + SetByUserID: event.Sender, + Options: event.Content, + } + if _, err := c.db.StoreBotOptions(opts); err != nil { + log.WithFields(log.Fields{ + log.ErrorKey: err, + "room_id": event.RoomID, + "bot_user_id": client.UserID, + "set_by_user_id": event.Sender, + }).Error("Failed to persist bot options") + } + }) + if config.AutoJoinRooms { client.Worker.OnEventType("m.room.member", func(event *matrix.Event) { if event.StateKey != config.UserID { diff --git a/src/github.com/matrix-org/go-neb/database/db.go b/src/github.com/matrix-org/go-neb/database/db.go index 07a29b8..e650471 100644 --- a/src/github.com/matrix-org/go-neb/database/db.go +++ b/src/github.com/matrix-org/go-neb/database/db.go @@ -194,6 +194,33 @@ func (d *ServiceDB) LoadAuthSessionByID(realmID, sessionID string) (session type return } +// LoadBotOptions loads bot options from the database. +// Returns sql.ErrNoRows if the bot options isn't in the database. +func (d *ServiceDB) LoadBotOptions(userID, roomID string) (opts types.BotOptions, err error) { + err = runTransaction(d.db, func(txn *sql.Tx) error { + opts, err = selectBotOptionsTxn(txn, userID, roomID) + return err + }) + return +} + +// StoreBotOptions stores a BotOptions into the database either by inserting a new +// bot options or updating an existing bot options. Returns the old bot options if there +// was one. +func (d *ServiceDB) StoreBotOptions(opts types.BotOptions) (oldOpts types.BotOptions, err error) { + err = runTransaction(d.db, func(txn *sql.Tx) error { + oldOpts, err = selectBotOptionsTxn(txn, opts.UserID, opts.RoomID) + if err == sql.ErrNoRows { + return insertBotOptionsTxn(txn, time.Now(), opts) + } else if err != nil { + return err + } else { + return updateBotOptionsTxn(txn, time.Now(), opts) + } + }) + return +} + func runTransaction(db *sql.DB, fn func(txn *sql.Tx) error) (err error) { txn, err := db.Begin() if err != nil { diff --git a/src/github.com/matrix-org/go-neb/database/schema.go b/src/github.com/matrix-org/go-neb/database/schema.go index 6b48eb0..f17db68 100644 --- a/src/github.com/matrix-org/go-neb/database/schema.go +++ b/src/github.com/matrix-org/go-neb/database/schema.go @@ -48,6 +48,16 @@ CREATE TABLE IF NOT EXISTS auth_sessions ( UNIQUE(realm_id, user_id), UNIQUE(realm_id, session_id) ); + +CREATE TABLE IF NOT EXISTS bot_options ( + user_id TEXT NOT NULL, + room_id TEXT NOT NULL, + set_by_user_id TEXT NOT NULL, + bot_options_json TEXT NOT NULL, + time_added_ms BIGINT NOT NULL, + time_updated_ms BIGINT NOT NULL, + UNIQUE(user_id, room_id) +); ` const selectMatrixClientConfigSQL = ` @@ -370,3 +380,48 @@ func updateAuthSessionTxn(txn *sql.Tx, now time.Time, session types.AuthSession) ) return err } + +const selectBotOptionsSQL = ` +SELECT bot_options_json, set_by_user_id FROM bot_options WHERE user_id = $1 AND room_id = $2 +` + +func selectBotOptionsTxn(txn *sql.Tx, userID, roomID string) (opts types.BotOptions, err error) { + var optionsJSON []byte + err = txn.QueryRow(selectBotOptionsSQL, userID, roomID).Scan(&optionsJSON, &opts.SetByUserID) + if err != nil { + return + } + err = json.Unmarshal(optionsJSON, &opts.Options) + return +} + +const insertBotOptionsSQL = ` +INSERT INTO bot_options( + user_id, room_id, bot_options_json, set_by_user_id, time_added_ms, time_updated_ms +) VALUES ($1, $2, $3, $4, $5, $6) +` + +func insertBotOptionsTxn(txn *sql.Tx, now time.Time, opts types.BotOptions) error { + t := now.UnixNano() / 1000000 + optsJSON, err := json.Marshal(&opts.Options) + if err != nil { + return err + } + _, err = txn.Exec(insertBotOptionsSQL, opts.UserID, opts.RoomID, optsJSON, opts.SetByUserID, t, t) + return err +} + +const updateBotOptionsSQL = ` +UPDATE bot_options SET bot_options_json = $1, set_by_user_id = $2, time_updated_ms = $3 + WHERE user_id = $4 AND room_id = $5 +` + +func updateBotOptionsTxn(txn *sql.Tx, now time.Time, opts types.BotOptions) error { + t := now.UnixNano() / 1000000 + optsJSON, err := json.Marshal(&opts.Options) + if err != nil { + return err + } + _, err = txn.Exec(updateBotOptionsSQL, optsJSON, opts.SetByUserID, t, opts.UserID, opts.RoomID) + return err +} 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 6bbac29..b038174 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,6 +1,7 @@ package services import ( + "database/sql" "fmt" log "github.com/Sirupsen/logrus" "github.com/google/go-github/github" @@ -21,7 +22,7 @@ import ( // 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 ownerRepoIssueRegex = regexp.MustCompile(`(([A-z0-9-_]+)/([A-z0-9-_]+))?#([0-9]+)`) type githubService struct { id string @@ -62,9 +63,19 @@ func (s *githubService) cmdGithubCreate(roomID, userID string, args []string) (i }, nil } - if len(args) < 2 { - return &matrix.TextMessage{"m.notice", - `Usage: !github create owner/repo "issue title" "description"`}, nil + // We expect the args to look like: + // [ "owner/repo", "title text", "desc text" ] + // They can omit the owner/repo if there is a default one set. + + if len(args) < 2 || strings.Count(args[0], "/") != 1 { + // look for a default repo + defaultRepo := s.defaultRepo(roomID) + if defaultRepo == "" { + return &matrix.TextMessage{"m.notice", + `Usage: !github create owner/repo "issue title" "description"`}, nil + } + // insert the default as the first arg to reuse the same code path + args = append([]string{defaultRepo}, args...) } var ( @@ -101,31 +112,15 @@ 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 string, matchingGroups []string) interface{} { - if !s.HandleExpansions { - return nil - } - // matchingGroups => ["foo/bar#11", "foo", "bar", "11"] - if len(matchingGroups) != 4 { - log.WithField("groups", matchingGroups).Print("Unexpected number of groups") - return nil - } - num, err := strconv.Atoi(matchingGroups[3]) - if err != nil { - log.WithField("issue_number", matchingGroups[3]).Print("Bad issue number") - return nil - } - owner := matchingGroups[1] - repo := matchingGroups[2] - +func (s *githubService) expandIssue(roomID, userID, owner, repo string, issueNum int) interface{} { cli := s.githubClientFor(userID, true) - i, _, err := cli.Issues.Get(owner, repo, num) + i, _, err := cli.Issues.Get(owner, repo, issueNum) if err != nil { log.WithError(err).WithFields(log.Fields{ "owner": owner, "repo": repo, - "number": num, + "number": issueNum, }).Print("Failed to fetch issue") return nil } @@ -150,7 +145,47 @@ func (s *githubService) Plugin(roomID string) plugin.Plugin { plugin.Expansion{ Regexp: ownerRepoIssueRegex, Expand: func(roomID, userID string, matchingGroups []string) interface{} { - return s.expandIssue(roomID, userID, matchingGroups) + if !s.HandleExpansions { + return nil + } + // There's an optional group in the regex so matchingGroups can look like: + // [foo/bar#55 foo/bar foo bar 55] + // [#55 55] + if len(matchingGroups) != 5 { + log.WithField("groups", matchingGroups).WithField("len", len(matchingGroups)).Print( + "Unexpected number of groups", + ) + return nil + } + if matchingGroups[1] == "" && matchingGroups[2] == "" && matchingGroups[3] == "" { + // issue only match, this only works if there is a default repo + defaultRepo := s.defaultRepo(roomID) + if defaultRepo == "" { + return nil + } + segs := strings.Split(defaultRepo, "/") + if len(segs) != 2 { + log.WithFields(log.Fields{ + "room_id": roomID, + "default_repo": defaultRepo, + }).Error("Default repo is malformed") + return nil + } + // Fill in the missing fields in matching groups and fall through into ["foo/bar#11", "foo", "bar", "11"] + matchingGroups = []string{ + defaultRepo + matchingGroups[0], + defaultRepo, + segs[0], + segs[1], + matchingGroups[4], + } + } + num, err := strconv.Atoi(matchingGroups[4]) + if err != nil { + log.WithField("issue_number", matchingGroups[4]).Print("Bad issue number") + return nil + } + return s.expandIssue(roomID, userID, matchingGroups[2], matchingGroups[3], num) }, }, }, @@ -285,6 +320,36 @@ func (s *githubService) Register(oldService types.Service, client *matrix.Client return nil } +// defaultRepo returns the default repo for the given room, or an empty string. +func (s *githubService) defaultRepo(roomID string) string { + logger := log.WithFields(log.Fields{ + "room_id": roomID, + "bot_user_id": s.serviceUserID, + }) + opts, err := database.GetServiceDB().LoadBotOptions(s.serviceUserID, roomID) + if err != nil { + if err != sql.ErrNoRows { + logger.WithError(err).Error("Failed to load bot options") + } + return "" + } + // Expect opts to look like: + // { github: { default_repo: $OWNER_REPO } } + ghOpts, ok := opts.Options["github"].(map[string]interface{}) + if !ok { + logger.WithField("options", opts.Options).Error("Failed to cast bot options as github options") + return "" + } + defaultRepo, ok := ghOpts["default_repo"].(string) + if !ok { + logger.WithField("default_repo", ghOpts["default_repo"]).Error( + "Failed to cast default repo as a string", + ) + return "" + } + return defaultRepo +} + func (s *githubService) joinWebhookRooms(client *matrix.Client) error { for roomID := range s.Rooms { if _, err := client.JoinRoom(roomID, "", ""); err != nil { diff --git a/src/github.com/matrix-org/go-neb/types/types.go b/src/github.com/matrix-org/go-neb/types/types.go index 2f36ce6..3640543 100644 --- a/src/github.com/matrix-org/go-neb/types/types.go +++ b/src/github.com/matrix-org/go-neb/types/types.go @@ -31,6 +31,14 @@ func (c *ClientConfig) Check() error { return nil } +// BotOptions for a given bot user in a given room +type BotOptions struct { + RoomID string + UserID string + SetByUserID string + Options map[string]interface{} +} + // A Service is the configuration for a bot service. type Service interface { ServiceUserID() string