Move appservice module from mautrix-appservice-go

This commit is contained in:
Tulir Asokan 2020-05-09 14:26:52 +03:00
commit 94679e025b
10 changed files with 1309 additions and 0 deletions

306
appservice/appservice.go Normal file
View file

@ -0,0 +1,306 @@
// Copyright (c) 2020 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"errors"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gorilla/mux"
"gopkg.in/yaml.v2"
"maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// EventChannelSize is the size for the Events channel in Appservice instances.
var EventChannelSize = 64
// Create a blank appservice instance.
func Create() *AppService {
return &AppService{
LogConfig: CreateLogConfig(),
clients: make(map[id.UserID]*mautrix.Client),
intents: make(map[id.UserID]*IntentAPI),
StateStore: NewBasicStateStore(),
Router: mux.NewRouter(),
}
}
// Load an appservice config from a file.
func Load(path string) (*AppService, error) {
data, readErr := ioutil.ReadFile(path)
if readErr != nil {
return nil, readErr
}
config := Create()
return config, yaml.Unmarshal(data, config)
}
// QueryHandler handles room alias and user ID queries from the homeserver.
type QueryHandler interface {
QueryAlias(alias string) bool
QueryUser(userID id.UserID) bool
}
type QueryHandlerStub struct{}
func (qh *QueryHandlerStub) QueryAlias(alias string) bool {
return false
}
func (qh *QueryHandlerStub) QueryUser(userID id.UserID) bool {
return false
}
// AppService is the main config for all appservices.
// It also serves as the appservice instance struct.
type AppService struct {
HomeserverDomain string `yaml:"homeserver_domain"`
HomeserverURL string `yaml:"homeserver_url"`
RegistrationPath string `yaml:"registration"`
Host HostConfig `yaml:"host"`
LogConfig LogConfig `yaml:"logging"`
Sync struct {
Enabled bool `yaml:"enabled"`
FilterID string `yaml:"filter_id"`
NextBatch string `yaml:"next_batch"`
} `yaml:"sync"`
Registration *Registration `yaml:"-"`
Log maulogger.Logger `yaml:"-"`
lastProcessedTransaction string
Events chan *event.Event `yaml:"-"`
QueryHandler QueryHandler `yaml:"-"`
StateStore StateStore `yaml:"-"`
Router *mux.Router `yaml:"-"`
server *http.Server
botClient *mautrix.Client
botIntent *IntentAPI
clients map[id.UserID]*mautrix.Client
intents map[id.UserID]*IntentAPI
}
// HostConfig contains info about how to host the appservice.
type HostConfig struct {
Hostname string `yaml:"hostname"`
Port uint16 `yaml:"port"`
TLSKey string `yaml:"tls_key,omitempty"`
TLSCert string `yaml:"tls_cert,omitempty"`
}
// Address gets the whole address of the Appservice.
func (hc *HostConfig) Address() string {
return fmt.Sprintf("%s:%d", hc.Hostname, hc.Port)
}
// Save saves this config into a file at the given path.
func (as *AppService) Save(path string) error {
data, err := yaml.Marshal(as)
if err != nil {
return err
}
return ioutil.WriteFile(path, data, 0644)
}
// YAML returns the config in YAML format.
func (as *AppService) YAML() (string, error) {
data, err := yaml.Marshal(as)
if err != nil {
return "", err
}
return string(data), nil
}
func (as *AppService) BotMXID() id.UserID {
return id.NewUserID(as.Registration.SenderLocalpart, as.HomeserverDomain)
}
func (as *AppService) Intent(userID id.UserID) *IntentAPI {
intent, ok := as.intents[userID]
if !ok {
localpart, homeserver, err := userID.Parse()
if err != nil || len(localpart) == 0 || homeserver != as.HomeserverDomain {
return nil
}
intent = as.NewIntentAPI(localpart)
as.intents[userID] = intent
}
return intent
}
func (as *AppService) BotIntent() *IntentAPI {
if as.botIntent == nil {
as.botIntent = as.NewIntentAPI(as.Registration.SenderLocalpart)
as.botIntent.Logger = as.Log.Sub(string(as.botIntent.UserID))
}
return as.botIntent
}
func (as *AppService) Client(userID id.UserID) *mautrix.Client {
client, ok := as.clients[userID]
if !ok {
var err error
client, err = mautrix.NewClient(as.HomeserverURL, userID, as.Registration.AppToken)
if err != nil {
as.Log.Fatalln("Failed to create gomatrix instance:", err)
return nil
}
client.Syncer = nil
client.Store = nil
client.AppServiceUserID = userID
client.Logger = as.Log.Sub(string(userID))
as.clients[userID] = client
}
return client
}
func (as *AppService) BotClient() *mautrix.Client {
if as.botClient == nil {
var err error
as.botClient, err = mautrix.NewClient(as.HomeserverURL, as.BotMXID(), as.Registration.AppToken)
if err != nil {
as.Log.Fatalln("Failed to create gomatrix instance:", err)
return nil
}
as.botClient.Syncer = nil
as.botClient.Store = nil
as.botClient.Logger = as.Log.Sub("Bot")
}
return as.botClient
}
// Init initializes the logger and loads the registration of this appservice.
func (as *AppService) Init() (bool, error) {
as.Events = make(chan *event.Event, EventChannelSize)
as.QueryHandler = &QueryHandlerStub{}
as.Log = maulogger.Create()
as.LogConfig.Configure(as.Log)
as.Log.Debugln("Logger initialized successfully.")
if len(as.RegistrationPath) > 0 {
var err error
as.Registration, err = LoadRegistration(as.RegistrationPath)
if err != nil {
return false, err
}
}
as.Log.Debugln("Appservice initialized successfully.")
return true, nil
}
// LogConfig contains configs for the logger.
type LogConfig struct {
Directory string `yaml:"directory"`
FileNameFormat string `yaml:"file_name_format"`
FileDateFormat string `yaml:"file_date_format"`
FileMode uint32 `yaml:"file_mode"`
TimestampFormat string `yaml:"timestamp_format"`
RawPrintLevel string `yaml:"print_level"`
PrintLevel int `yaml:"-"`
}
type umLogConfig LogConfig
func (lc *LogConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umLogConfig)(lc))
if err != nil {
return err
}
switch strings.ToUpper(lc.RawPrintLevel) {
case "DEBUG":
lc.PrintLevel = maulogger.LevelDebug.Severity
case "INFO":
lc.PrintLevel = maulogger.LevelInfo.Severity
case "WARN", "WARNING":
lc.PrintLevel = maulogger.LevelWarn.Severity
case "ERR", "ERROR":
lc.PrintLevel = maulogger.LevelError.Severity
case "FATAL":
lc.PrintLevel = maulogger.LevelFatal.Severity
default:
return errors.New("invalid print level " + lc.RawPrintLevel)
}
return err
}
func (lc *LogConfig) MarshalYAML() (interface{}, error) {
switch {
case lc.PrintLevel >= maulogger.LevelFatal.Severity:
lc.RawPrintLevel = maulogger.LevelFatal.Name
case lc.PrintLevel >= maulogger.LevelError.Severity:
lc.RawPrintLevel = maulogger.LevelError.Name
case lc.PrintLevel >= maulogger.LevelWarn.Severity:
lc.RawPrintLevel = maulogger.LevelWarn.Name
case lc.PrintLevel >= maulogger.LevelInfo.Severity:
lc.RawPrintLevel = maulogger.LevelInfo.Name
default:
lc.RawPrintLevel = maulogger.LevelDebug.Name
}
return lc, nil
}
// CreateLogConfig creates a basic LogConfig.
func CreateLogConfig() LogConfig {
return LogConfig{
Directory: "./logs",
FileNameFormat: "%[1]s-%02[2]d.log",
TimestampFormat: "Jan _2, 2006 15:04:05",
FileMode: 0600,
FileDateFormat: "2006-01-02",
PrintLevel: 10,
}
}
type FileFormatData struct {
Date string
Index int
}
// GetFileFormat returns a mauLogger-compatible logger file format based on the data in the struct.
func (lc LogConfig) GetFileFormat() maulogger.LoggerFileFormat {
if len(lc.Directory) > 0 {
_ = os.MkdirAll(lc.Directory, 0700)
}
path := filepath.Join(lc.Directory, lc.FileNameFormat)
tpl, _ := template.New("fileformat").Parse(path)
return func(now string, i int) string {
var buf strings.Builder
_ = tpl.Execute(&buf, FileFormatData{
Date: now,
Index: i,
})
return buf.String()
}
}
// Configure configures a mauLogger instance with the data in this struct.
func (lc LogConfig) Configure(log maulogger.Logger) {
basicLogger := log.(*maulogger.BasicLogger)
basicLogger.FileFormat = lc.GetFileFormat()
basicLogger.FileMode = os.FileMode(lc.FileMode)
basicLogger.FileTimeFormat = lc.FileDateFormat
basicLogger.TimeFormat = lc.TimestampFormat
basicLogger.PrintLevel = lc.PrintLevel
}

View file

@ -0,0 +1,101 @@
// Copyright (c) 2020 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"encoding/json"
"runtime/debug"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
)
type ExecMode uint8
const (
AsyncHandlers ExecMode = iota
AsyncLoop
Sync
)
type EventProcessor struct {
ExecMode ExecMode
as *AppService
log log.Logger
stop chan struct{}
handlers map[event.Type][]mautrix.OnEventListener
}
func NewEventProcessor(as *AppService) *EventProcessor {
return &EventProcessor{
ExecMode: AsyncHandlers,
as: as,
log: as.Log.Sub("Events"),
stop: make(chan struct{}, 1),
handlers: make(map[event.Type][]mautrix.OnEventListener),
}
}
func (ep *EventProcessor) On(evtType event.Type, handler mautrix.OnEventListener) {
handlers, ok := ep.handlers[evtType]
if !ok {
handlers = []mautrix.OnEventListener{handler}
} else {
handlers = append(handlers, handler)
}
ep.handlers[evtType] = handlers
}
func (ep *EventProcessor) callHandler(handler mautrix.OnEventListener, evt *event.Event) {
defer func() {
if err := recover(); err != nil {
d, _ := json.Marshal(evt)
ep.log.Errorfln("Panic in Matrix event handler: %v (event content: %s):\n%s", err, string(d), string(debug.Stack()))
}
}()
handler(evt)
}
func (ep *EventProcessor) Dispatch(evt *event.Event) {
handlers, ok := ep.handlers[evt.Type]
if !ok {
return
}
switch ep.ExecMode {
case AsyncHandlers:
for _, handler := range handlers {
go ep.callHandler(handler, evt)
}
case AsyncLoop:
go func() {
for _, handler := range handlers {
ep.callHandler(handler, evt)
}
}()
case Sync:
for _, handler := range handlers {
ep.callHandler(handler, evt)
}
}
}
func (ep *EventProcessor) Start() {
for {
select {
case evt := <-ep.as.Events:
ep.Dispatch(evt)
case <-ep.stop:
return
}
}
}
func (ep *EventProcessor) Stop() {
ep.stop <- struct{}{}
}

173
appservice/http.go Normal file
View file

@ -0,0 +1,173 @@
// Copyright (c) 2020 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"time"
"github.com/gorilla/mux"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// Listen starts the HTTP server that listens for calls from the Matrix homeserver.
func (as *AppService) Start() {
as.Router.HandleFunc("/transactions/{txnID}", as.PutTransaction).Methods(http.MethodPut)
as.Router.HandleFunc("/rooms/{roomAlias}", as.GetRoom).Methods(http.MethodGet)
as.Router.HandleFunc("/users/{userID}", as.GetUser).Methods(http.MethodGet)
as.Router.HandleFunc("/_matrix/app/v1/transactions/{txnID}", as.PutTransaction).Methods(http.MethodPut)
as.Router.HandleFunc("/_matrix/app/v1/rooms/{roomAlias}", as.GetRoom).Methods(http.MethodGet)
as.Router.HandleFunc("/_matrix/app/v1/users/{userID}", as.GetUser).Methods(http.MethodGet)
var err error
as.server = &http.Server{
Addr: as.Host.Address(),
Handler: as.Router,
}
as.Log.Infoln("Listening on", as.Host.Address())
if len(as.Host.TLSCert) == 0 || len(as.Host.TLSKey) == 0 {
err = as.server.ListenAndServe()
} else {
err = as.server.ListenAndServeTLS(as.Host.TLSCert, as.Host.TLSKey)
}
if err != nil && err.Error() != "http: Server closed" {
as.Log.Fatalln("Error while listening:", err)
} else {
as.Log.Debugln("Listener stopped.")
}
}
func (as *AppService) Stop() {
if as.server == nil {
return
}
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
_ = as.server.Shutdown(ctx)
as.server = nil
}
// CheckServerToken checks if the given request originated from the Matrix homeserver.
func (as *AppService) CheckServerToken(w http.ResponseWriter, r *http.Request) bool {
query := r.URL.Query()
val, ok := query["access_token"]
if !ok {
Error{
ErrorCode: ErrForbidden,
HTTPStatus: http.StatusForbidden,
Message: "Bad token supplied.",
}.Write(w)
return false
}
for _, str := range val {
return str == as.Registration.ServerToken
}
return false
}
// PutTransaction handles a /transactions PUT call from the homeserver.
func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) {
if !as.CheckServerToken(w, r) {
return
}
vars := mux.Vars(r)
txnID := vars["txnID"]
if len(txnID) == 0 {
Error{
ErrorCode: ErrNoTransactionID,
HTTPStatus: http.StatusBadRequest,
Message: "Missing transaction ID.",
}.Write(w)
return
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil || len(body) == 0 {
Error{
ErrorCode: ErrNoBody,
HTTPStatus: http.StatusBadRequest,
Message: "Missing request body.",
}.Write(w)
return
}
if as.lastProcessedTransaction == txnID {
// Duplicate transaction ID: no-op
WriteBlankOK(w)
return
}
eventList := EventList{}
err = json.Unmarshal(body, &eventList)
if err != nil {
as.Log.Warnfln("Failed to parse JSON of transaction %s: %v", txnID, err)
Error{
ErrorCode: ErrInvalidJSON,
HTTPStatus: http.StatusBadRequest,
Message: "Failed to parse body JSON.",
}.Write(w)
} else {
for _, evt := range eventList.Events {
if evt.StateKey != nil {
evt.Type.Class = event.StateEventType
} else {
evt.Type.Class = event.MessageEventType
}
err := evt.Content.ParseRaw(evt.Type)
if err != nil {
as.Log.Debugfln("Failed to parse content of %s: %v", evt.ID, err)
}
as.UpdateState(evt)
as.Events <- evt
}
WriteBlankOK(w)
}
as.lastProcessedTransaction = txnID
}
// GetRoom handles a /rooms GET call from the homeserver.
func (as *AppService) GetRoom(w http.ResponseWriter, r *http.Request) {
if !as.CheckServerToken(w, r) {
return
}
vars := mux.Vars(r)
roomAlias := vars["roomAlias"]
ok := as.QueryHandler.QueryAlias(roomAlias)
if ok {
WriteBlankOK(w)
} else {
Error{
ErrorCode: ErrUnknown,
HTTPStatus: http.StatusNotFound,
}.Write(w)
}
}
// GetUser handles a /users GET call from the homeserver.
func (as *AppService) GetUser(w http.ResponseWriter, r *http.Request) {
if !as.CheckServerToken(w, r) {
return
}
vars := mux.Vars(r)
userID := id.UserID(vars["userID"])
ok := as.QueryHandler.QueryUser(userID)
if ok {
WriteBlankOK(w)
} else {
Error{
ErrorCode: ErrUnknown,
HTTPStatus: http.StatusNotFound,
}.Write(w)
}
}

279
appservice/intent.go Normal file
View file

@ -0,0 +1,279 @@
// Copyright (c) 2020 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"strings"
"github.com/pkg/errors"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type IntentAPI struct {
*mautrix.Client
bot *mautrix.Client
as *AppService
Localpart string
UserID id.UserID
IsCustomPuppet bool
}
func (as *AppService) NewIntentAPI(localpart string) *IntentAPI {
userID := id.NewUserID(localpart, as.HomeserverDomain)
bot := as.BotClient()
if userID == bot.UserID {
bot = nil
}
return &IntentAPI{
Client: as.Client(userID),
bot: bot,
as: as,
Localpart: localpart,
UserID: userID,
IsCustomPuppet: false,
}
}
func (intent *IntentAPI) Register() error {
_, _, err := intent.Client.Register(&mautrix.ReqRegister{
Username: intent.Localpart,
})
if err != nil {
return err
}
return nil
}
func (intent *IntentAPI) EnsureRegistered() error {
if intent.IsCustomPuppet || intent.as.StateStore.IsRegistered(intent.UserID) {
return nil
}
err := intent.Register()
if err != nil {
httpErr, ok := err.(mautrix.HTTPError)
if !ok || httpErr.RespError == nil || httpErr.RespError.ErrCode != "M_USER_IN_USE" {
return errors.Wrap(err, "failed to ensure registered")
}
}
intent.as.StateStore.MarkRegistered(intent.UserID)
return nil
}
func (intent *IntentAPI) EnsureJoined(roomID id.RoomID) error {
if intent.as.StateStore.IsInRoom(roomID, intent.UserID) {
return nil
}
if err := intent.EnsureRegistered(); err != nil {
return errors.Wrap(err, "failed to ensure joined")
}
resp, err := intent.JoinRoomByID(roomID)
if err != nil {
httpErr, ok := err.(mautrix.HTTPError)
if !ok || httpErr.RespError == nil || httpErr.RespError.ErrCode != "M_FORBIDDEN" || intent.bot == nil {
return errors.Wrap(err, "failed to ensure joined")
}
_, inviteErr := intent.bot.InviteUser(roomID, &mautrix.ReqInviteUser{
UserID: intent.UserID,
})
if inviteErr != nil {
return errors.Wrap(err, "failed to ensure joined")
}
resp, err = intent.JoinRoomByID(roomID)
if err != nil {
return errors.Wrap(err, "failed to ensure joined")
}
}
intent.as.StateStore.SetMembership(resp.RoomID, intent.UserID, "join")
return nil
}
func (intent *IntentAPI) SendMessageEvent(roomID id.RoomID, eventType event.Type, contentJSON interface{}) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendMessageEvent(roomID, eventType, contentJSON)
}
func (intent *IntentAPI) SendMassagedMessageEvent(roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendMessageEvent(roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})
}
func (intent *IntentAPI) SendStateEvent(roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendStateEvent(roomID, eventType, stateKey, contentJSON)
}
func (intent *IntentAPI) SendMassagedStateEvent(roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendMassagedStateEvent(roomID, eventType, stateKey, contentJSON, ts)
}
func (intent *IntentAPI) StateEvent(roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) (err error) {
if err := intent.EnsureJoined(roomID); err != nil {
return err
}
return intent.Client.StateEvent(roomID, eventType, stateKey, outContent)
}
func (intent *IntentAPI) Member(roomID id.RoomID, userID id.UserID) *event.MemberEventContent {
member, ok := intent.as.StateStore.TryGetMember(roomID, userID)
if !ok {
_ = intent.StateEvent(roomID, event.StateMember, string(userID), &member)
intent.as.StateStore.SetMember(roomID, userID, member)
}
return member
}
func (intent *IntentAPI) PowerLevels(roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) {
pl = intent.as.StateStore.GetPowerLevels(roomID)
if pl == nil {
pl = &event.PowerLevelsEventContent{}
err = intent.StateEvent(roomID, event.StatePowerLevels, "", pl)
if err == nil {
intent.as.StateStore.SetPowerLevels(roomID, pl)
}
}
return
}
func (intent *IntentAPI) SetPowerLevels(roomID id.RoomID, levels *event.PowerLevelsEventContent) (resp *mautrix.RespSendEvent, err error) {
resp, err = intent.SendStateEvent(roomID, event.StatePowerLevels, "", &levels)
if err == nil {
intent.as.StateStore.SetPowerLevels(roomID, levels)
}
return
}
func (intent *IntentAPI) SetPowerLevel(roomID id.RoomID, userID id.UserID, level int) (*mautrix.RespSendEvent, error) {
pl, err := intent.PowerLevels(roomID)
if err != nil {
return nil, err
}
if pl.GetUserLevel(userID) != level {
pl.SetUserLevel(userID, level)
return intent.SendStateEvent(roomID, event.StatePowerLevels, "", &pl)
}
return nil, nil
}
func (intent *IntentAPI) UserTyping(roomID id.RoomID, typing bool, timeout int64) (resp *mautrix.RespTyping, err error) {
if intent.as.StateStore.IsTyping(roomID, intent.UserID) == typing {
return
}
resp, err = intent.Client.UserTyping(roomID, typing, timeout)
if err != nil {
return
}
if !typing {
timeout = -1
}
intent.as.StateStore.SetTyping(roomID, intent.UserID, timeout)
return
}
func (intent *IntentAPI) SendText(roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendText(roomID, text)
}
func (intent *IntentAPI) SendImage(roomID id.RoomID, body string, url id.ContentURI) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendImage(roomID, body, url)
}
func (intent *IntentAPI) SendVideo(roomID id.RoomID, body string, url id.ContentURI) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendVideo(roomID, body, url)
}
func (intent *IntentAPI) SendNotice(roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendNotice(roomID, text)
}
func (intent *IntentAPI) RedactEvent(roomID id.RoomID, eventID id.EventID, req ...mautrix.ReqRedact) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.RedactEvent(roomID, eventID, req...)
}
func (intent *IntentAPI) SetRoomName(roomID id.RoomID, roomName string) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(roomID, event.StateRoomName, "", map[string]interface{}{
"name": roomName,
})
}
func (intent *IntentAPI) SetRoomAvatar(roomID id.RoomID, avatarURL id.ContentURI) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(roomID, event.StateRoomAvatar, "", map[string]interface{}{
"url": avatarURL,
})
}
func (intent *IntentAPI) SetRoomTopic(roomID id.RoomID, topic string) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(roomID, event.StateTopic, "", map[string]interface{}{
"topic": topic,
})
}
func (intent *IntentAPI) SetDisplayName(displayName string) error {
if err := intent.EnsureRegistered(); err != nil {
return err
}
return intent.Client.SetDisplayName(displayName)
}
func (intent *IntentAPI) SetAvatarURL(avatarURL id.ContentURI) error {
if err := intent.EnsureRegistered(); err != nil {
return err
}
return intent.Client.SetAvatarURL(avatarURL)
}
func (intent *IntentAPI) Whoami() (*mautrix.RespWhoami, error) {
if err := intent.EnsureRegistered(); err != nil {
return nil, err
}
return intent.Client.Whoami()
}
func (intent *IntentAPI) EnsureInvited(roomID id.RoomID, userID id.UserID) error {
if !intent.as.StateStore.IsInvited(roomID, userID) {
_, err := intent.Client.InviteUser(roomID, &mautrix.ReqInviteUser{
UserID: userID,
})
if httpErr, ok := err.(mautrix.HTTPError); ok && strings.Contains(httpErr.RespError.Err, "is already in the room") {
return nil
}
return err
}
return nil
}

66
appservice/protocol.go Normal file
View file

@ -0,0 +1,66 @@
// Copyright (c) 2020 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"encoding/json"
"net/http"
"maunium.net/go/mautrix/event"
)
// EventList contains a list of events.
type EventList struct {
Events []*event.Event `json:"events"`
}
// EventListener is a function that receives events.
type EventListener func(evt *event.Event)
// WriteBlankOK writes a blank OK message as a reply to a HTTP request.
func WriteBlankOK(w http.ResponseWriter) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("{}"))
}
// Respond responds to a HTTP request with a JSON object.
func Respond(w http.ResponseWriter, data interface{}) error {
dataStr, err := json.Marshal(data)
if err != nil {
return err
}
_, err = w.Write(dataStr)
return err
}
// Error represents a Matrix protocol error.
type Error struct {
HTTPStatus int `json:"-"`
ErrorCode ErrorCode `json:"errcode"`
Message string `json:"message"`
}
func (err Error) Write(w http.ResponseWriter) {
w.WriteHeader(err.HTTPStatus)
_ = Respond(w, &err)
}
// ErrorCode is the machine-readable code in an Error.
type ErrorCode string
// Native ErrorCodes
const (
ErrForbidden ErrorCode = "M_FORBIDDEN"
ErrUnknown ErrorCode = "M_UNKNOWN"
)
// Custom ErrorCodes
const (
ErrNoTransactionID ErrorCode = "NET.MAUNIUM.NO_TRANSACTION_ID"
ErrNoBody ErrorCode = "NET.MAUNIUM.NO_REQUEST_BODY"
ErrInvalidJSON ErrorCode = "NET.MAUNIUM.INVALID_JSON"
)

34
appservice/random.go Normal file
View file

@ -0,0 +1,34 @@
package appservice
import (
"math/rand"
"time"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const (
letterIdxBits = 6
letterIdxMask = 1<<letterIdxBits - 1
letterIdxMax = 63 / letterIdxBits
)
var src = rand.NewSource(time.Now().UnixNano())
// RandomString generates a random string of the given length.
func RandomString(n int) string {
b := make([]byte, n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}

104
appservice/registration.go Normal file
View file

@ -0,0 +1,104 @@
// Copyright (c) 2019 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"io/ioutil"
"regexp"
"gopkg.in/yaml.v2"
)
// Registration contains the data in a Matrix appservice registration.
// See https://matrix.org/docs/spec/application_service/unstable.html#registration
type Registration struct {
ID string `yaml:"id"`
URL string `yaml:"url"`
AppToken string `yaml:"as_token"`
ServerToken string `yaml:"hs_token"`
SenderLocalpart string `yaml:"sender_localpart"`
RateLimited bool `yaml:"rate_limited"`
Namespaces Namespaces `yaml:"namespaces"`
}
// CreateRegistration creates a Registration with random appservice and homeserver tokens.
func CreateRegistration() *Registration {
return &Registration{
AppToken: RandomString(64),
ServerToken: RandomString(64),
}
}
// LoadRegistration loads a YAML file and turns it into a Registration.
func LoadRegistration(path string) (*Registration, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
reg := &Registration{}
err = yaml.Unmarshal(data, reg)
if err != nil {
return nil, err
}
return reg, nil
}
// Save saves this Registration into a file at the given path.
func (reg *Registration) Save(path string) error {
data, err := yaml.Marshal(reg)
if err != nil {
return err
}
return ioutil.WriteFile(path, data, 0600)
}
// YAML returns the registration in YAML format.
func (reg *Registration) YAML() (string, error) {
data, err := yaml.Marshal(reg)
if err != nil {
return "", err
}
return string(data), nil
}
// Namespaces contains the three areas that appservices can reserve parts of.
type Namespaces struct {
UserIDs []Namespace `yaml:"users,omitempty"`
RoomAliases []Namespace `yaml:"aliases,omitempty"`
RoomIDs []Namespace `yaml:"rooms,omitempty"`
}
// Namespace is a reserved namespace in any area.
type Namespace struct {
Regex string `yaml:"regex"`
Exclusive bool `yaml:"exclusive"`
}
// RegisterUserIDs creates an user ID namespace registration.
func (nslist *Namespaces) RegisterUserIDs(regex *regexp.Regexp, exclusive bool) {
nslist.UserIDs = append(nslist.UserIDs, Namespace{
Regex: regex.String(),
Exclusive: exclusive,
})
}
// RegisterRoomAliases creates an room alias namespace registration.
func (nslist *Namespaces) RegisterRoomAliases(regex *regexp.Regexp, exclusive bool) {
nslist.RoomAliases = append(nslist.RoomAliases, Namespace{
Regex: regex.String(),
Exclusive: exclusive,
})
}
// RegisterRoomIDs creates an room ID namespace registration.
func (nslist *Namespaces) RegisterRoomIDs(regex *regexp.Regexp, exclusive bool) {
nslist.RoomIDs = append(nslist.RoomIDs, Namespace{
Regex: regex.String(),
Exclusive: exclusive,
})
}

236
appservice/statestore.go Normal file
View file

@ -0,0 +1,236 @@
// Copyright (c) 2020 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"sync"
"time"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type StateStore interface {
IsRegistered(userID id.UserID) bool
MarkRegistered(userID id.UserID)
IsTyping(roomID id.RoomID, userID id.UserID) bool
SetTyping(roomID id.RoomID, userID id.UserID, timeout int64)
IsInRoom(roomID id.RoomID, userID id.UserID) bool
IsInvited(roomID id.RoomID, userID id.UserID) bool
IsMembership(roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool
GetMember(roomID id.RoomID, userID id.UserID) *event.MemberEventContent
TryGetMember(roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, bool)
SetMembership(roomID id.RoomID, userID id.UserID, membership event.Membership)
SetMember(roomID id.RoomID, userID id.UserID, member *event.MemberEventContent)
SetPowerLevels(roomID id.RoomID, levels *event.PowerLevelsEventContent)
GetPowerLevels(roomID id.RoomID) *event.PowerLevelsEventContent
GetPowerLevel(roomID id.RoomID, userID id.UserID) int
GetPowerLevelRequirement(roomID id.RoomID, eventType event.Type) int
HasPowerLevel(roomID id.RoomID, userID id.UserID, eventType event.Type) bool
}
func (as *AppService) UpdateState(evt *event.Event) {
switch content := evt.Content.Parsed.(type) {
case *event.MemberEventContent:
as.StateStore.SetMember(evt.RoomID, id.UserID(evt.GetStateKey()), content)
case *event.PowerLevelsEventContent:
as.StateStore.SetPowerLevels(evt.RoomID, content)
}
}
type TypingStateStore struct {
typing map[id.RoomID]map[id.UserID]int64
typingLock sync.RWMutex
}
func NewTypingStateStore() *TypingStateStore {
return &TypingStateStore{
typing: make(map[id.RoomID]map[id.UserID]int64),
}
}
func (store *TypingStateStore) IsTyping(roomID id.RoomID, userID id.UserID) bool {
store.typingLock.RLock()
defer store.typingLock.RUnlock()
roomTyping, ok := store.typing[roomID]
if !ok {
return false
}
typingEndsAt, _ := roomTyping[userID]
return typingEndsAt >= time.Now().Unix()
}
func (store *TypingStateStore) SetTyping(roomID id.RoomID, userID id.UserID, timeout int64) {
store.typingLock.Lock()
defer store.typingLock.Unlock()
roomTyping, ok := store.typing[roomID]
if !ok {
if timeout >= 0 {
roomTyping = map[id.UserID]int64{
userID: time.Now().Unix() + timeout,
}
} else {
return
}
} else {
if timeout >= 0 {
roomTyping[userID] = time.Now().Unix() + timeout
} else {
delete(roomTyping, userID)
}
}
store.typing[roomID] = roomTyping
}
type BasicStateStore struct {
registrationsLock sync.RWMutex `json:"-"`
Registrations map[id.UserID]bool `json:"registrations"`
membersLock sync.RWMutex `json:"-"`
Members map[id.RoomID]map[id.UserID]*event.MemberEventContent `json:"memberships"`
powerLevelsLock sync.RWMutex `json:"-"`
PowerLevels map[id.RoomID]*event.PowerLevelsEventContent `json:"power_levels"`
*TypingStateStore
}
func NewBasicStateStore() StateStore {
return &BasicStateStore{
Registrations: make(map[id.UserID]bool),
Members: make(map[id.RoomID]map[id.UserID]*event.MemberEventContent),
PowerLevels: make(map[id.RoomID]*event.PowerLevelsEventContent),
TypingStateStore: NewTypingStateStore(),
}
}
func (store *BasicStateStore) IsRegistered(userID id.UserID) bool {
store.registrationsLock.RLock()
defer store.registrationsLock.RUnlock()
registered, ok := store.Registrations[userID]
return ok && registered
}
func (store *BasicStateStore) MarkRegistered(userID id.UserID) {
store.registrationsLock.Lock()
defer store.registrationsLock.Unlock()
store.Registrations[userID] = true
}
func (store *BasicStateStore) GetRoomMembers(roomID id.RoomID) map[id.UserID]*event.MemberEventContent {
store.membersLock.RLock()
members, ok := store.Members[roomID]
store.membersLock.RUnlock()
if !ok {
members = make(map[id.UserID]*event.MemberEventContent)
store.membersLock.Lock()
store.Members[roomID] = members
store.membersLock.Unlock()
}
return members
}
func (store *BasicStateStore) GetMembership(roomID id.RoomID, userID id.UserID) event.Membership {
return store.GetMember(roomID, userID).Membership
}
func (store *BasicStateStore) GetMember(roomID id.RoomID, userID id.UserID) *event.MemberEventContent {
member, ok := store.TryGetMember(roomID, userID)
if !ok {
member = &event.MemberEventContent{Membership: event.MembershipLeave}
}
return member
}
func (store *BasicStateStore) TryGetMember(roomID id.RoomID, userID id.UserID) (member *event.MemberEventContent, ok bool) {
store.membersLock.RLock()
defer store.membersLock.RUnlock()
members, membersOk := store.Members[roomID]
if !membersOk {
return
}
member, ok = members[userID]
return
}
func (store *BasicStateStore) IsInRoom(roomID id.RoomID, userID id.UserID) bool {
return store.IsMembership(roomID, userID, "join")
}
func (store *BasicStateStore) IsInvited(roomID id.RoomID, userID id.UserID) bool {
return store.IsMembership(roomID, userID, "join", "invite")
}
func (store *BasicStateStore) IsMembership(roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool {
membership := store.GetMembership(roomID, userID)
for _, allowedMembership := range allowedMemberships {
if allowedMembership == membership {
return true
}
}
return false
}
func (store *BasicStateStore) SetMembership(roomID id.RoomID, userID id.UserID, membership event.Membership) {
store.membersLock.Lock()
members, ok := store.Members[roomID]
if !ok {
members = map[id.UserID]*event.MemberEventContent{
userID: {Membership: membership},
}
} else {
member, ok := members[userID]
if !ok {
members[userID] = &event.MemberEventContent{Membership: membership}
} else {
member.Membership = membership
members[userID] = member
}
}
store.Members[roomID] = members
store.membersLock.Unlock()
}
func (store *BasicStateStore) SetMember(roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) {
store.membersLock.Lock()
members, ok := store.Members[roomID]
if !ok {
members = map[id.UserID]*event.MemberEventContent{
userID: member,
}
} else {
members[userID] = member
}
store.Members[roomID] = members
store.membersLock.Unlock()
}
func (store *BasicStateStore) SetPowerLevels(roomID id.RoomID, levels *event.PowerLevelsEventContent) {
store.powerLevelsLock.Lock()
store.PowerLevels[roomID] = levels
store.powerLevelsLock.Unlock()
}
func (store *BasicStateStore) GetPowerLevels(roomID id.RoomID) (levels *event.PowerLevelsEventContent) {
store.powerLevelsLock.RLock()
levels, _ = store.PowerLevels[roomID]
store.powerLevelsLock.RUnlock()
return
}
func (store *BasicStateStore) GetPowerLevel(roomID id.RoomID, userID id.UserID) int {
return store.GetPowerLevels(roomID).GetUserLevel(userID)
}
func (store *BasicStateStore) GetPowerLevelRequirement(roomID id.RoomID, eventType event.Type) int {
return store.GetPowerLevels(roomID).GetEventLevel(eventType)
}
func (store *BasicStateStore) HasPowerLevel(roomID id.RoomID, userID id.UserID, eventType event.Type) bool {
return store.GetPowerLevel(roomID, userID) >= store.GetPowerLevelRequirement(roomID, eventType)
}

3
go.mod
View file

@ -3,10 +3,13 @@ module maunium.net/go/mautrix
go 1.14
require (
github.com/gorilla/mux v1.7.4
github.com/pkg/errors v0.9.1
github.com/russross/blackfriday/v2 v2.0.1
github.com/stretchr/testify v1.5.1
github.com/tidwall/gjson v1.6.0
github.com/tidwall/sjson v1.1.1
golang.org/x/net v0.0.0-20200301022130-244492dfa37a
gopkg.in/yaml.v2 v2.2.8
maunium.net/go/maulogger/v2 v2.1.1
)

7
go.sum
View file

@ -1,4 +1,7 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -20,3 +23,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
maunium.net/go/maulogger/v2 v2.1.1 h1:NAZNc6XUFJzgzfewCzVoGkxNAsblLCSSEdtDuIjP0XA=
maunium.net/go/maulogger/v2 v2.1.1/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=