diff --git a/app/plugins.go b/app/plugins.go index d53e09b..e4fe50c 100644 --- a/app/plugins.go +++ b/app/plugins.go @@ -88,7 +88,7 @@ func (bot *Bot) createPlugins() { log.Debugf("Created plugin %s (type %s v%s)\n", plugin.ID, creator.Name, creator.Version) bot.Plugins[plugin.ID] = &PluginWrapper{ - Plugin: creator.Create(client), + Plugin: creator.Create(client.Proxy(plugin.ID)), Creator: creator, DB: plugin, } diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..4c8e683 --- /dev/null +++ b/commands.go @@ -0,0 +1,105 @@ +// maubot - A plugin-based Matrix bot system written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package maubot + +type CommandHandler func(*Event) + +type CommandSpec struct { + Commands []Command `json:"commands"` + PassiveCommands []PassiveCommand `json:"passive_commands"` +} + +func (spec *CommandSpec) Equals(otherSpec *CommandSpec) bool { + if len(spec.Commands) != len(otherSpec.Commands) || len(spec.PassiveCommands) != len(otherSpec.PassiveCommands) { + return false + } + + for index, cmd := range spec.Commands { + otherCmd := otherSpec.Commands[index] + if !cmd.Equals(otherCmd) { + return false + } + } + + for index, cmd := range spec.PassiveCommands { + otherCmd := otherSpec.PassiveCommands[index] + if !cmd.Equals(otherCmd) { + return false + } + } + + return true +} + +type Command struct { + Syntax string `json:"syntax"` + Description string `json:"description,omitempty"` + Arguments ArgumentMap `json:"arguments"` +} + +func (cmd Command) Equals(otherCmd Command) bool { + return cmd.Syntax == otherCmd.Syntax && + cmd.Description == otherCmd.Description && + cmd.Arguments.Equals(otherCmd.Arguments) +} + +type ArgumentMap map[string]Argument + +func (argMap ArgumentMap) Equals(otherMap ArgumentMap) bool { + if len(argMap) != len(otherMap) { + return false + } + + for name, argument := range argMap { + otherArgument, ok := otherMap[name] + if !ok || !argument.Equals(otherArgument) { + return false + } + } + return true +} + +type Argument struct { + Matches string `json:"matches"` + Required bool `json:"required"` + Description string `json:"description,omitempty"` +} + +func (arg Argument) Equals(otherArg Argument) bool { + return arg.Matches == otherArg.Matches && + arg.Required == otherArg.Required && + arg.Description == otherArg.Description +} + +// Common PassiveCommand MatchAgainst targets. +const ( + MatchAgainstBody = "body" +) + +type PassiveCommand struct { + Name string `json:"name"` + Matches string `json:"matches"` + MatchAgainst string `json:"match_against"` + MatchEvent *Event `json:"match_event"` +} + +func (cmd PassiveCommand) Equals(otherCmd PassiveCommand) bool { + return cmd.Name == otherCmd.Name && + cmd.Matches == otherCmd.Matches && + cmd.MatchAgainst == otherCmd.MatchAgainst && + ((cmd.MatchEvent != nil && cmd.MatchEvent.Equals(otherCmd.MatchEvent)) || otherCmd.MatchEvent == nil) +} diff --git a/database/clients.go b/database/clients.go index 3ee5c50..660bd10 100644 --- a/database/clients.go +++ b/database/clients.go @@ -1,4 +1,4 @@ -// jesaribot - A simple maubot plugin. +// maubot - A plugin-based Matrix bot system written in Go. // Copyright (C) 2018 Tulir Asokan // // This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ package database import ( + "maubot.xyz" log "maunium.net/go/maulogger" "database/sql" ) @@ -35,6 +36,8 @@ type MatrixClient struct { AutoJoinRooms bool `json:"auto_join_rooms"` DisplayName string `json:"display_name"` AvatarURL string `json:"avatar_url"` + + Commands map[string]*CommandSpec `json:"commandspecs"` } type MatrixClientStatic struct { @@ -85,15 +88,32 @@ func (mcs *MatrixClientStatic) New() *MatrixClient { } } -type Scannable interface { - Scan(...interface{}) error -} - func (mxc *MatrixClient) Scan(row Scannable) *MatrixClient { err := row.Scan(&mxc.UserID, &mxc.Homeserver, &mxc.AccessToken, &mxc.NextBatch, &mxc.FilterID, &mxc.Sync, &mxc.AutoJoinRooms, &mxc.DisplayName, &mxc.AvatarURL) if err != nil { log.Fatalln("Database scan failed:", err) } + mxc.LoadCommandSpecs() + return mxc +} + +func (mxc *MatrixClient) SetCommandSpec(owner string, newSpec *maubot.CommandSpec) bool { + spec := mxc.db.CommandSpec.GetOrCreate(owner, mxc.UserID) + if newSpec.Equals(spec.CommandSpec) { + return false + } + spec.CommandSpec = newSpec + spec.Update() + mxc.Commands[owner] = spec + return true +} + +func (mxc *MatrixClient) LoadCommandSpecs() *MatrixClient { + specs := mxc.db.CommandSpec.GetAllByClient(mxc.UserID) + mxc.Commands = make(map[string]*CommandSpec) + for _, spec := range specs { + mxc.Commands[spec.Owner] = spec + } return mxc } diff --git a/database/commands.go b/database/commands.go new file mode 100644 index 0000000..ccf5e37 --- /dev/null +++ b/database/commands.go @@ -0,0 +1,130 @@ +// maubot - A plugin-based Matrix bot system written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "database/sql" + "encoding/json" + + "maubot.xyz" + log "maunium.net/go/maulogger" +) + +type CommandSpec struct { + db *Database + sql *sql.DB + + *maubot.CommandSpec + Owner string `json:"owner"` + Client string `json:"client"` +} + +type CommandSpecStatic struct { + db *Database + sql *sql.DB +} + +func (css *CommandSpecStatic) CreateTable() error { + _, err := css.sql.Exec(`CREATE TABLE IF NOT EXISTS command_spec ( + owner VARCHAR(255), + client VARCHAR(255), + spec TEXT, + + PRIMARY KEY (owner, client), + FOREIGN KEY (owner) REFERENCES plugin(id) + ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (client) REFERENCES matrix_client(user_id) + ON DELETE CASCADE ON UPDATE CASCADE + )`) + return err +} + +func (css *CommandSpecStatic) Get(owner, client string) *CommandSpec { + row := css.sql.QueryRow("SELECT * FROM command_spec WHERE owner=? AND client=?", owner, client) + if row != nil { + return css.New().Scan(row) + } + return nil +} + +func (css *CommandSpecStatic) GetOrCreate(owner, client string) (spec *CommandSpec) { + spec = css.Get(owner, client) + if spec == nil { + spec = css.New() + spec.Owner = owner + spec.Client = client + spec.Insert() + } + return +} + +func (css *CommandSpecStatic) getAllByQuery(query string, args ...interface{}) (specs []*CommandSpec) { + rows, err := css.sql.Query(query, args...) + if err != nil || rows == nil { + return nil + } + defer rows.Close() + for rows.Next() { + specs = append(specs, css.New().Scan(rows)) + } + return +} + +func (css *CommandSpecStatic) GetAllByOwner(owner string) []*CommandSpec { + return css.getAllByQuery("SELECT * FROM command_spec WHERE owner=?", owner) +} + +func (css *CommandSpecStatic) GetAllByClient(client string) []*CommandSpec { + return css.getAllByQuery("SELECT * FROM command_spec WHERE client=?", client) +} + +func (css *CommandSpecStatic) New() *CommandSpec { + return &CommandSpec{ + db: css.db, + sql: css.sql, + } +} + +func (cs *CommandSpec) Scan(row Scannable) *CommandSpec { + var spec string + err := row.Scan(&cs.Owner, &cs.Client, &spec) + if err != nil { + log.Fatalln("Database scan failed:", err) + } + json.Unmarshal([]byte(spec), &cs.CommandSpec) + return cs +} + +func (cs *CommandSpec) Insert() error { + data, err := json.Marshal(cs.CommandSpec) + if err != nil { + return err + } + _, err = cs.sql.Exec("INSERT INTO command_spec (owner, client, spec) VALUES (?, ?, ?)", + cs.Owner, cs.Client, string(data)) + return err +} + +func (cs *CommandSpec) Update() error { + data, err := json.Marshal(cs.CommandSpec) + if err != nil { + return err + } + _, err = cs.sql.Exec("UPDATE command_spec SET spec=? WHERE owner=? AND client=?", + string(data), cs.Owner, cs.Client) + return err +} diff --git a/database/database.go b/database/database.go index 26121ae..b49b063 100644 --- a/database/database.go +++ b/database/database.go @@ -1,4 +1,4 @@ -// jesaribot - A simple maubot plugin. +// maubot - A plugin-based Matrix bot system written in Go. // Copyright (C) 2018 Tulir Asokan // // This program is free software: you can redistribute it and/or modify @@ -22,12 +22,17 @@ import ( log "maunium.net/go/maulogger" ) +type Scannable interface { + Scan(...interface{}) error +} + type Database struct { Type string `yaml:"type"` Name string `yaml:"name"` MatrixClient *MatrixClientStatic `yaml:"-"` Plugin *PluginStatic `yaml:"-"` + CommandSpec *CommandSpecStatic `yaml:"-"` sql *sql.DB } @@ -40,6 +45,7 @@ func (db *Database) Connect() (err error) { db.MatrixClient = &MatrixClientStatic{db: db, sql: db.sql} db.Plugin = &PluginStatic{db: db, sql: db.sql} + db.CommandSpec = &CommandSpecStatic{db: db, sql: db.sql} return nil } diff --git a/database/plugins.go b/database/plugins.go index 4224e67..3b4ef91 100644 --- a/database/plugins.go +++ b/database/plugins.go @@ -1,4 +1,4 @@ -// jesaribot - A simple maubot plugin. +// maubot - A plugin-based Matrix bot system written in Go. // Copyright (C) 2018 Tulir Asokan // // This program is free software: you can redistribute it and/or modify diff --git a/matrix.go b/matrix.go index 5d68e7d..89b16a3 100644 --- a/matrix.go +++ b/matrix.go @@ -63,6 +63,8 @@ const ( type MatrixClient interface { AddEventHandler(EventType, EventHandler) + AddCommandHandler(string, CommandHandler) + SetCommandSpec(*CommandSpec) GetEvent(string, string) *Event } @@ -89,11 +91,30 @@ type Event struct { Unsigned Unsigned `json:"unsigned,omitempty"` // Unsigned content set by own homeserver. } +func (evt *Event) Equals(otherEvt *Event) bool { + return evt.StateKey == otherEvt.StateKey && + evt.Sender == otherEvt.Sender && + evt.Type == otherEvt.Type && + evt.Timestamp == otherEvt.Timestamp && + evt.ID == otherEvt.ID && + evt.RoomID == otherEvt.RoomID && + evt.Content.Equals(&otherEvt.Content) && + evt.Redacts == otherEvt.Redacts && + evt.Unsigned.Equals(&otherEvt.Unsigned) +} + type Unsigned struct { - PrevContent Content `json:"prev_content,omitempty"` - PrevSender string `json:"prev_sender,omitempty"` - ReplacesState string `json:"replaces_state,omitempty"` - Age int64 `json:"age"` + PrevContent *Content `json:"prev_content,omitempty"` + PrevSender string `json:"prev_sender,omitempty"` + ReplacesState string `json:"replaces_state,omitempty"` + Age int64 `json:"age"` +} + +func (unsigned Unsigned) Equals(otherUnsigned *Unsigned) bool { + return unsigned.PrevContent.Equals(otherUnsigned.PrevContent) && + unsigned.PrevSender == otherUnsigned.PrevSender && + unsigned.ReplacesState == otherUnsigned.ReplacesState && + unsigned.Age == otherUnsigned.Age } type Content struct { @@ -104,14 +125,25 @@ type Content struct { Format string `json:"format,omitempty"` FormattedBody string `json:"formatted_body,omitempty"` - Info FileInfo `json:"info,omitempty"` - URL string `json:"url,omitempty"` + Info *FileInfo `json:"info,omitempty"` + URL string `json:"url,omitempty"` Membership string `json:"membership,omitempty"` RelatesTo RelatesTo `json:"m.relates_to,omitempty"` } +func (content Content) Equals(otherContent *Content) bool { + return content.MsgType == otherContent.MsgType && + content.Body == otherContent.Body && + content.Format == otherContent.Format && + content.FormattedBody == otherContent.FormattedBody && + ((content.Info != nil && content.Info.Equals(otherContent.Info)) || otherContent.Info == nil) && + content.URL == otherContent.URL && + content.Membership == otherContent.Membership && + content.RelatesTo == otherContent.RelatesTo +} + type FileInfo struct { MimeType string `json:"mimetype,omitempty"` ThumbnailInfo *FileInfo `json:"thumbnail_info,omitempty"` @@ -121,6 +153,15 @@ type FileInfo struct { Size int `json:"size,omitempty"` } +func (fi *FileInfo) Equals(otherFI *FileInfo) bool { + return fi.MimeType == otherFI.MimeType && + fi.ThumbnailURL == otherFI.ThumbnailURL && + fi.Height == otherFI.Height && + fi.Width == otherFI.Width && + fi.Size == otherFI.Size && + ((fi.ThumbnailInfo != nil && fi.ThumbnailInfo.Equals(otherFI.ThumbnailInfo)) || otherFI.ThumbnailInfo == nil) +} + type RelatesTo struct { InReplyTo InReplyTo `json:"m.in_reply_to,omitempty"` } diff --git a/matrix/event.go b/matrix/event.go index 3abb801..9e808d9 100644 --- a/matrix/event.go +++ b/matrix/event.go @@ -28,7 +28,8 @@ type Event struct { Client *Client } -func roundtripContent(rawContent map[string]interface{}) (content maubot.Content) { +func roundtripContent(rawContent map[string]interface{}) (content *maubot.Content) { + content = &maubot.Content{} if len(rawContent) == 0 { content.Raw = rawContent return @@ -55,7 +56,7 @@ func (client *Client) ParseEvent(mxEvent *gomatrix.Event) *Event { Timestamp: mxEvent.Timestamp, ID: mxEvent.ID, RoomID: mxEvent.RoomID, - Content: roundtripContent(mxEvent.Content), + Content: *roundtripContent(mxEvent.Content), Redacts: mxEvent.Redacts, Unsigned: maubot.Unsigned{ PrevContent: roundtripContent(mxEvent.Unsigned.PrevContent), diff --git a/matrix/matrix.go b/matrix/matrix.go index df1e9e4..55762a4 100644 --- a/matrix/matrix.go +++ b/matrix/matrix.go @@ -45,10 +45,18 @@ func NewClient(db *database.MatrixClient) (*Client, error) { client.Client.Syncer = client.syncer client.AddEventHandler(maubot.StateMember, client.onJoin) + client.AddEventHandler(maubot.EventMessage, client.onMessage) return client, nil } +func (client *Client) Proxy(owner string) *ClientProxy { + return &ClientProxy{ + hiddenClient: client, + owner: owner, + } +} + func (client *Client) AddEventHandler(evt maubot.EventType, handler maubot.EventHandler) { client.syncer.OnEventType(evt, func(evt *maubot.Event) maubot.EventHandlerResult { if evt.Sender == client.UserID { @@ -58,6 +66,18 @@ func (client *Client) AddEventHandler(evt maubot.EventType, handler maubot.Event }) } +func (client *Client) AddCommandHandler(evt string, handler maubot.CommandHandler) { + // TODO add command handler +} + +func (client *Client) SetCommandSpec(owner string, spec *maubot.CommandSpec) { + changed := client.DB.SetCommandSpec(owner, spec) + if changed { + log.Debugln("Command spec of", owner, "on", client.UserID, "updated.") + // TODO + } +} + func (client *Client) GetEvent(roomID, eventID string) *maubot.Event { evt, err := client.Client.GetEvent(roomID, eventID) if err != nil { @@ -67,6 +87,11 @@ func (client *Client) GetEvent(roomID, eventID string) *maubot.Event { return client.ParseEvent(evt).Event } +func (client *Client) onMessage(evt *maubot.Event) maubot.EventHandlerResult { + // TODO call command handlers + return maubot.Continue +} + func (client *Client) onJoin(evt *maubot.Event) maubot.EventHandlerResult { if client.DB.AutoJoinRooms && evt.StateKey == client.DB.UserID && evt.Content.Membership == "invite" { client.JoinRoom(evt.RoomID) @@ -87,3 +112,14 @@ func (client *Client) Sync() { } }() } + +type hiddenClient = Client + +type ClientProxy struct { + *hiddenClient + owner string +} + +func (cp *ClientProxy) SetCommandSpec(spec *maubot.CommandSpec) { + cp.hiddenClient.SetCommandSpec(cp.owner, spec) +}