From ef8fffaff83a5d20f92a11cc5309c002814159dc Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 14 Jun 2018 10:29:37 +0300 Subject: [PATCH] Separate plugin interface to reduce file size --- cmd/maubot/main.go | 1 + database/database.go | 1 - interfaces/interfaces.go | 48 +++++++++++++ matrix/event.go | 25 +++++++ matrix/matrix.go | 22 ++++-- matrix/sync.go | 152 +++++++++++++++++++++++++++++++++++++++ maubot.go | 5 +- plugin.go | 28 ++------ plugins.go | 3 +- 9 files changed, 254 insertions(+), 31 deletions(-) create mode 100644 interfaces/interfaces.go create mode 100644 matrix/sync.go diff --git a/cmd/maubot/main.go b/cmd/maubot/main.go index 7f82b61..3d8e739 100644 --- a/cmd/maubot/main.go +++ b/cmd/maubot/main.go @@ -22,6 +22,7 @@ import ( "os/signal" "syscall" + _ "github.com/mattn/go-sqlite3" "maubot.xyz" "maubot.xyz/config" flag "maunium.net/go/mauflag" diff --git a/database/database.go b/database/database.go index 80d1616..26121ae 100644 --- a/database/database.go +++ b/database/database.go @@ -19,7 +19,6 @@ package database import ( "database/sql" - _ "github.com/mattn/go-sqlite3" log "maunium.net/go/maulogger" ) diff --git a/interfaces/interfaces.go b/interfaces/interfaces.go new file mode 100644 index 0000000..c4ccea8 --- /dev/null +++ b/interfaces/interfaces.go @@ -0,0 +1,48 @@ +package interfaces + +type Plugin interface { + Start() + Stop() +} + +type EventHandler func(*Event) bool + +type MatrixClient interface { + AddEventHandler(string, EventHandler) +} + +type EventFuncs interface { + Reply(text string) (string, error) + SendMessage(text string) (string, error) + SendEvent(content map[string]interface{}) (string, error) +} + +type Event struct { + EventFuncs + + StateKey string `json:"state_key,omitempty"` // The state key for the event. Only present on State Events. + Sender string `json:"sender"` // The user ID of the sender of the event + Type string `json:"type"` // The event type + Timestamp int64 `json:"origin_server_ts"` // The unix timestamp when this message was sent by the origin server + ID string `json:"event_id"` // The unique ID of this event + RoomID string `json:"room_id"` // The room the event was sent to. May be nil (e.g. for presence) + Content map[string]interface{} `json:"content"` // The JSON content of the event. + Redacts string `json:"redacts,omitempty"` // The event ID that was redacted if a m.room.redaction event + Unsigned Unsigned `json:"unsigned,omitempty"` // Unsigned content set by own homeserver. +} + +type Unsigned struct { + PrevContent map[string]interface{} `json:"prev_content,omitempty"` + PrevSender string `json:"prev_sender,omitempty"` + ReplacesState string `json:"replaces_state,omitempty"` + Age int64 `json:"age"` +} + +type PluginCreatorFunc func(client MatrixClient) Plugin + +type PluginCreator struct { + Create PluginCreatorFunc + Name string + Version string + Path string +} diff --git a/matrix/event.go b/matrix/event.go index 3d46301..f86b602 100644 --- a/matrix/event.go +++ b/matrix/event.go @@ -17,6 +17,7 @@ package matrix import ( + "maubot.xyz/interfaces" "maunium.net/go/gomatrix" ) @@ -25,6 +26,30 @@ type Event struct { Client *Client } +func (evt *Event) Interface() *interfaces.Event { + var stateKey string + if evt.StateKey != nil { + stateKey = *evt.StateKey + } + return &interfaces.Event{ + EventFuncs: evt, + StateKey: stateKey, + Sender: evt.Sender, + Type: evt.Type, + Timestamp: evt.Timestamp, + ID: evt.ID, + RoomID: evt.RoomID, + Content: evt.Content, + Redacts: evt.Redacts, + Unsigned: interfaces.Unsigned{ + PrevContent: evt.Unsigned.PrevContent, + PrevSender: evt.Unsigned.PrevSender, + ReplacesState: evt.Unsigned.ReplacesState, + Age: evt.Unsigned.Age, + }, + } +} + func (evt *Event) Reply(text string) (string, error) { return evt.SendEvent( SetReply( diff --git a/matrix/matrix.go b/matrix/matrix.go index a3eeab8..790f8c0 100644 --- a/matrix/matrix.go +++ b/matrix/matrix.go @@ -18,6 +18,7 @@ package matrix import ( "maubot.xyz/database" + "maubot.xyz/interfaces" "maunium.net/go/gomatrix" log "maunium.net/go/maulogger" ) @@ -39,22 +40,33 @@ func NewClient(db *database.MatrixClient) (*Client, error) { DB: db, } + client.Syncer = NewMaubotSyncer(client, client.Store) + client.AddEventHandler(gomatrix.StateMember, client.onJoin) return client, nil } -func (client *Client) AddEventHandler(evt string, handler gomatrix.OnEventListener) { - client.Syncer.(*gomatrix.DefaultSyncer).OnEventType(evt, handler) +func (client *Client) ParseEvent(evt *gomatrix.Event) *Event { + return &Event{ + Client: client, + Event: evt, + } } -func (client *Client) onJoin(evt *gomatrix.Event) { - if !client.DB.AutoJoinRooms || evt.StateKey == nil || *evt.StateKey != client.DB.UserID { - return +func (client *Client) AddEventHandler(evt string, handler interfaces.EventHandler) { + client.Syncer.(*MaubotSyncer).OnEventType(evt, handler) +} + +func (client *Client) onJoin(evt *interfaces.Event) bool { + if !client.DB.AutoJoinRooms || evt.StateKey != client.DB.UserID { + return true } if membership, _ := evt.Content["membership"].(string); membership == "invite" { client.JoinRoom(evt.RoomID) + return false } + return true } func (client *Client) JoinRoom(roomID string) { diff --git a/matrix/sync.go b/matrix/sync.go new file mode 100644 index 0000000..44c28f7 --- /dev/null +++ b/matrix/sync.go @@ -0,0 +1,152 @@ +package matrix + +import ( + "encoding/json" + "fmt" + "runtime/debug" + "time" + + "maubot.xyz/interfaces" + "maunium.net/go/gomatrix" +) + +type MaubotSyncer struct { + Client *Client + Store gomatrix.Storer + listeners map[string][]interfaces.EventHandler +} + +// NewDefaultSyncer returns an instantiated DefaultSyncer +func NewMaubotSyncer(client *Client, store gomatrix.Storer) *MaubotSyncer { + return &MaubotSyncer{ + Client: client, + Store: store, + listeners: make(map[string][]interfaces.EventHandler), + } +} + +// ProcessResponse processes the /sync response in a way suitable for bots. "Suitable for bots" means a stream of +// unrepeating events. Returns a fatal error if a listener panics. +func (s *MaubotSyncer) ProcessResponse(res *gomatrix.RespSync, since string) (err error) { + if !s.shouldProcessResponse(res, since) { + return + } + + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("ProcessResponse panicked! userID=%s since=%s panic=%s\n%s", s.Client.UserID, since, r, debug.Stack()) + } + }() + + for roomID, roomData := range res.Rooms.Join { + room := s.getOrCreateRoom(roomID) + for _, event := range roomData.State.Events { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + for _, event := range roomData.Timeline.Events { + event.RoomID = roomID + s.notifyListeners(event) + } + } + for roomID, roomData := range res.Rooms.Invite { + room := s.getOrCreateRoom(roomID) + for _, event := range roomData.State.Events { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + } + for roomID, roomData := range res.Rooms.Leave { + room := s.getOrCreateRoom(roomID) + for _, event := range roomData.Timeline.Events { + if event.StateKey != nil { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + } + } + return +} + +// OnEventType allows callers to be notified when there are new events for the given event type. +// There are no duplicate checks. +func (s *MaubotSyncer) OnEventType(eventType string, callback interfaces.EventHandler) { + _, exists := s.listeners[eventType] + if !exists { + s.listeners[eventType] = []interfaces.EventHandler{} + } + s.listeners[eventType] = append(s.listeners[eventType], callback) +} + +// shouldProcessResponse returns true if the response should be processed. May modify the response to remove +// stuff that shouldn't be processed. +func (s *MaubotSyncer) shouldProcessResponse(resp *gomatrix.RespSync, since string) bool { + if since == "" { + return false + } + // This is a horrible hack because /sync will return the most recent messages for a room + // as soon as you /join it. We do NOT want to process those events in that particular room + // because they may have already been processed (if you toggle the bot in/out of the room). + // + // Work around this by inspecting each room's timeline and seeing if an m.room.member event for us + // exists and is "join" and then discard processing that room entirely if so. + // TODO: We probably want to process messages from after the last join event in the timeline. + for roomID, roomData := range resp.Rooms.Join { + for i := len(roomData.Timeline.Events) - 1; i >= 0; i-- { + e := roomData.Timeline.Events[i] + if e.Type == "m.room.member" && e.StateKey != nil && *e.StateKey == s.Client.UserID { + m := e.Content["membership"] + mship, ok := m.(string) + if !ok { + continue + } + if mship == "join" { + _, ok := resp.Rooms.Join[roomID] + if !ok { + continue + } + delete(resp.Rooms.Join, roomID) // don't re-process messages + delete(resp.Rooms.Invite, roomID) // don't re-process invites + break + } + } + } + } + return true +} + +// getOrCreateRoom must only be called by the Sync() goroutine which calls ProcessResponse() +func (s *MaubotSyncer) getOrCreateRoom(roomID string) *gomatrix.Room { + room := s.Store.LoadRoom(roomID) + if room == nil { + room = gomatrix.NewRoom(roomID) + s.Store.SaveRoom(room) + } + return room +} + +func (s *MaubotSyncer) notifyListeners(mxEvent *gomatrix.Event) { + event := s.Client.ParseEvent(mxEvent) + listeners, exists := s.listeners[event.Type] + if !exists { + return + } + for _, fn := range listeners { + if !fn(event.Interface()) { + break + } + } +} + +// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. +func (s *MaubotSyncer) OnFailedSync(res *gomatrix.RespSync, err error) (time.Duration, error) { + return 10 * time.Second, nil +} + +// GetFilterJSON returns a filter with a timeline limit of 50. +func (s *MaubotSyncer) GetFilterJSON(userID string) json.RawMessage { + return json.RawMessage(`{"room":{"timeline":{"limit":50}}}`) +} diff --git a/maubot.go b/maubot.go index 4731897..f59f45b 100644 --- a/maubot.go +++ b/maubot.go @@ -22,6 +22,7 @@ import ( "maubot.xyz/config" "maubot.xyz/database" + "maubot.xyz/interfaces" "maubot.xyz/matrix" log "maunium.net/go/maulogger" ) @@ -30,7 +31,7 @@ type Bot struct { Config *config.MainConfig Database *database.Database Clients map[string]*matrix.Client - PluginCreators map[string]*PluginCreator + PluginCreators map[string]*interfaces.PluginCreator Plugins map[string]*PluginWrapper Server *http.Server } @@ -40,7 +41,7 @@ func New(config *config.MainConfig) *Bot { Config: config, Clients: make(map[string]*matrix.Client), Plugins: make(map[string]*PluginWrapper), - PluginCreators: make(map[string]*PluginCreator), + PluginCreators: make(map[string]*interfaces.PluginCreator), } } diff --git a/plugin.go b/plugin.go index c138a32..e6af4c8 100644 --- a/plugin.go +++ b/plugin.go @@ -21,30 +21,16 @@ import ( "plugin" "maubot.xyz/database" - "maubot.xyz/matrix" + "maubot.xyz/interfaces" ) -type Plugin interface { - Start() - Stop() -} - type PluginWrapper struct { - Plugin - Creator *PluginCreator + interfaces.Plugin + Creator *interfaces.PluginCreator DB *database.Plugin } -type PluginCreatorFunc func(bot *Bot, info *database.Plugin, client *matrix.Client) Plugin - -type PluginCreator struct { - Create PluginCreatorFunc - Name string - Version string - Path string -} - -func LoadPlugin(path string) (*PluginCreator, error) { +func LoadPlugin(path string) (*interfaces.PluginCreator, error) { rawPlugin, err := plugin.Open(path) if err != nil { return nil, fmt.Errorf("failed to open: %v", err) @@ -52,7 +38,7 @@ func LoadPlugin(path string) (*PluginCreator, error) { pluginCreatorSymbol, err := rawPlugin.Lookup("Plugin") if err == nil { - pluginCreator, ok := pluginCreatorSymbol.(*PluginCreator) + pluginCreator, ok := pluginCreatorSymbol.(*interfaces.PluginCreator) if ok { pluginCreator.Path = path return pluginCreator, nil @@ -63,7 +49,7 @@ func LoadPlugin(path string) (*PluginCreator, error) { if err != nil { return nil, fmt.Errorf("symbol \"Create\" not found: %v", err) } - pluginCreatorFunc, ok := pluginCreatorFuncSymbol.(PluginCreatorFunc) + pluginCreatorFunc, ok := pluginCreatorFuncSymbol.(interfaces.PluginCreatorFunc) if !ok { return nil, fmt.Errorf("symbol \"Create\" does not implement maubot.PluginCreator") } @@ -86,7 +72,7 @@ func LoadPlugin(path string) (*PluginCreator, error) { return nil, fmt.Errorf("symbol \"Version\" is not a string") } - return &PluginCreator{ + return &interfaces.PluginCreator{ Create: pluginCreatorFunc, Name: name, Version: version, diff --git a/plugins.go b/plugins.go index 4d90e53..42f6f82 100644 --- a/plugins.go +++ b/plugins.go @@ -86,10 +86,9 @@ func (bot *Bot) createPlugins() { continue } - log.Debugf("Created plugin %s (type %s v%s)\n", plugin.ID, creator.Name, creator.Version) bot.Plugins[plugin.ID] = &PluginWrapper{ - Plugin: creator.Create(bot, plugin, client), + Plugin: creator.Create(client), Creator: creator, DB: plugin, }