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..c6b3d9c 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" @@ -22,6 +23,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 issueRegex = regexp.MustCompile("#([0-9]+)") type githubService struct { id string @@ -62,9 +64,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 ( @@ -102,9 +114,6 @@ func (s *githubService) cmdGithubCreate(roomID, userID string, args []string) (i } 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") @@ -150,9 +159,44 @@ func (s *githubService) Plugin(roomID string) plugin.Plugin { plugin.Expansion{ Regexp: ownerRepoIssueRegex, Expand: func(roomID, userID string, matchingGroups []string) interface{} { + if !s.HandleExpansions { + return nil + } return s.expandIssue(roomID, userID, matchingGroups) }, }, + plugin.Expansion{ + Regexp: issueRegex, + Expand: func(roomID, userID string, matchingGroups []string) interface{} { + if !s.HandleExpansions { + return nil + } + + // This expansion only works if there is a default repo. If there is a default, + // convert the matchingGroups of [ "#11", "11" ] into: + // ["foo/bar#11", "foo", "bar", "11"] + // and then use the same code path as the longer form. + 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 + } + // convert ["#11", "11"] into ["foo/bar#11", "foo", "bar", "11"] + return s.expandIssue(roomID, userID, []string{ + defaultRepo + matchingGroups[0], + segs[0], + segs[1], + matchingGroups[1], + }) + }, + }, }, } } @@ -285,6 +329,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