فهرست منبع

Initial bot functionality

* The bot now properly joins the management room
* The management room is persisted in the database
* Welcome/help messages are sent in the management room
Gary Kramlich 3 سال پیش
والد
کامیت
456a15ba56

+ 24 - 0
bridge/bridge.go

@@ -3,11 +3,13 @@ package bridge
 import (
 	"errors"
 	"fmt"
+	"sync"
 	"time"
 
 	log "maunium.net/go/maulogger/v2"
 	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/appservice"
+	"maunium.net/go/mautrix/id"
 
 	"gitlab.com/beeper/discord/config"
 	"gitlab.com/beeper/discord/database"
@@ -28,6 +30,20 @@ type Bridge struct {
 	eventProcessor *appservice.EventProcessor
 	matrixHandler  *matrixHandler
 	bot            *appservice.IntentAPI
+
+	usersByMXID map[id.UserID]*User
+	usersByID   map[string]*User
+	usersLock   sync.Mutex
+
+	managementRooms     map[id.RoomID]*User
+	managementRoomsLock sync.Mutex
+
+	portalsByMXID map[id.RoomID]*Portal
+	portalsByID   map[database.PortalKey]*Portal
+	portalsLock   sync.Mutex
+
+	puppets     map[string]*Puppet
+	puppetsLock sync.Mutex
 }
 
 func New(cfg *config.Config) (*Bridge, error) {
@@ -64,6 +80,14 @@ func New(cfg *config.Config) (*Bridge, error) {
 		bot:    bot,
 		config: cfg,
 		log:    logger,
+
+		usersByMXID: make(map[id.UserID]*User),
+		usersByID:   make(map[string]*User),
+
+		managementRooms: make(map[id.RoomID]*User),
+
+		portalsByMXID: make(map[id.RoomID]*Portal),
+		portalsByID:   make(map[database.PortalKey]*Portal),
 	}
 
 	// Setup the event processors

+ 107 - 5
bridge/matrix.go

@@ -5,6 +5,7 @@ import (
 	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/appservice"
 	"maunium.net/go/mautrix/event"
+	"maunium.net/go/mautrix/format"
 	"maunium.net/go/mautrix/id"
 )
 
@@ -60,16 +61,92 @@ func (mh *matrixHandler) ignoreEvent(evt *event.Event) bool {
 }
 
 func (mh *matrixHandler) handleMessage(evt *event.Event) {
+	mh.log.Debugfln("received message from %q: %q", evt.Sender, evt.Content.AsMessage())
 	if mh.ignoreEvent(evt) {
 		return
 	}
 
-	mh.log.Debugfln("received message from %q: %q", evt.Sender, evt.Content.AsMessage())
 }
 
-func (mh *matrixHandler) handleMembership(evt *event.Event) {
-	mh.log.Debugfln("recevied invite %#v\n", evt)
+func (mh *matrixHandler) joinAndCheckMembers(evt *event.Event, intent *appservice.IntentAPI) *mautrix.RespJoinedMembers {
+	resp, err := intent.JoinRoomByID(evt.RoomID)
+	if err != nil {
+		mh.log.Debugfln("Failed to join room %q as %q with invite from %q: %v", evt.RoomID, intent.UserID, evt.Sender, err)
+
+		return nil
+	}
+
+	members, err := intent.JoinedMembers(resp.RoomID)
+	if err != nil {
+		mh.log.Debugfln("Failed to get members in room %q with invite from %q as %q: %v", resp.RoomID, evt.Sender, intent.UserID, err)
+
+		return nil
+	}
+
+	if len(members.Joined) < 2 {
+		mh.log.Debugfln("Leaving empty room %q with invite from %q as %q", resp.RoomID, evt.Sender, intent.UserID)
+
+		intent.LeaveRoom(resp.RoomID)
+
+		return nil
+	}
+
+	return members
+}
+
+func (mh *matrixHandler) sendNoticeWithmarkdown(roomID id.RoomID, message string) (*mautrix.RespSendEvent, error) {
+	intent := mh.as.BotIntent()
+	content := format.RenderMarkdown(message, true, false)
+	content.MsgType = event.MsgNotice
+
+	return intent.SendMessageEvent(roomID, event.EventMessage, content)
+}
+
+func (mh *matrixHandler) handleBotInvite(evt *event.Event) {
+	intent := mh.as.BotIntent()
 
+	user := mh.bridge.GetUserByMXID(evt.Sender)
+	if user == nil {
+		return
+	}
+
+	members := mh.joinAndCheckMembers(evt, intent)
+	if members == nil {
+		return
+	}
+
+	// If this is a DM and the user doesn't have a management room, make this
+	// the management room.
+	if len(members.Joined) == 2 && (user.ManagementRoom == "" || evt.Content.AsMember().IsDirect) {
+		user.SetManagementRoom(evt.RoomID)
+
+		intent.SendNotice(user.ManagementRoom, "This room has been registered as your bridge management/status room")
+		mh.log.Debugfln("%q registered as management room with %q", evt.RoomID, evt.Sender)
+	}
+
+	// Wait to send the welcome message until we're sure we're not in an empty
+	// room.
+	mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.config.Bridge.ManagementRoomText.Welcome)
+
+	if evt.RoomID == user.ManagementRoom {
+		if user.HasSession() {
+			mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.config.Bridge.ManagementRoomText.Connected)
+		} else {
+			mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.config.Bridge.ManagementRoomText.NotConnected)
+		}
+
+		additionalHelp := mh.bridge.config.Bridge.ManagementRoomText.AdditionalHelp
+		if additionalHelp != "" {
+			mh.sendNoticeWithmarkdown(evt.RoomID, additionalHelp)
+		}
+	}
+}
+
+func (mh *matrixHandler) handlePuppetInvite(evt *event.Event, inviter *User, puppet *Puppet) {
+	mh.log.Warnln("handling puppet invite!")
+}
+
+func (mh *matrixHandler) handleMembership(evt *event.Event) {
 	// Return early if we're supposed to ignore the event.
 	if mh.ignoreEvent(evt) {
 		return
@@ -78,12 +155,37 @@ func (mh *matrixHandler) handleMembership(evt *event.Event) {
 	// Grab the content of the event.
 	content := evt.Content.AsMember()
 
-	// TODO: handle invites from ourselfs?
+	// Check if this is a new conversation from a matrix user to the bot
+	if content.Membership == event.MembershipInvite && id.UserID(evt.GetStateKey()) == mh.as.BotMXID() {
+		mh.handleBotInvite(evt)
+
+		return
+	}
+
+	// Load or create a new user.
+	user := mh.bridge.GetUserByMXID(evt.Sender)
+	if user == nil {
+		return
+	}
+
+	// Load or create a new portal.
+	portal := mh.bridge.GetPortalByMXID(evt.RoomID)
+	if portal == nil {
+		puppet := mh.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey()))
+
+		if content.Membership == event.MembershipInvite && puppet != nil {
+			mh.handlePuppetInvite(evt, user, puppet)
+		}
+
+		mh.log.Warnln("no existing portal for", evt.RoomID)
+
+		return
+	}
 
 	isSelf := id.UserID(evt.GetStateKey()) == evt.Sender
 
 	// Handle matrix invites.
 	if content.Membership == event.MembershipInvite && !isSelf {
-		//
+		portal.HandleMatrixInvite(user, evt)
 	}
 }

+ 95 - 0
bridge/portal.go

@@ -0,0 +1,95 @@
+package bridge
+
+import (
+	"fmt"
+
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/event"
+	"maunium.net/go/mautrix/id"
+
+	"gitlab.com/beeper/discord/database"
+)
+
+type PortalMatrixMessage struct {
+	evt  *event.Event
+	user *User
+}
+
+type Portal struct {
+	*database.Portal
+
+	bridge *Bridge
+	log    log.Logger
+
+	matrixMessages chan PortalMatrixMessage
+}
+
+func (b *Bridge) loadPortal(dbPortal *database.Portal, key *database.PortalKey) *Portal {
+	// If we weren't given a portal we'll attempt to create it if a key was
+	// provided.
+	if dbPortal == nil {
+		if key == nil {
+			return nil
+		}
+
+		dbPortal = b.db.Portal.New()
+		dbPortal.Key = *key
+		dbPortal.Insert()
+	}
+
+	portal := b.NewPortal(dbPortal)
+
+	// No need to lock, it is assumed that our callers have already acquired
+	// the lock.
+	b.portalsByID[portal.Key] = portal
+	if portal.MXID != "" {
+		b.portalsByMXID[portal.MXID] = portal
+	}
+
+	return portal
+}
+
+func (b *Bridge) GetPortalByMXID(mxid id.RoomID) *Portal {
+	b.portalsLock.Lock()
+	defer b.portalsLock.Unlock()
+
+	portal, ok := b.portalsByMXID[mxid]
+	if !ok {
+		return b.loadPortal(b.db.Portal.GetByMXID(mxid), nil)
+	}
+
+	return portal
+}
+
+func (b *Bridge) NewPortal(dbPortal *database.Portal) *Portal {
+	portal := &Portal{
+		Portal: dbPortal,
+		bridge: b,
+		log:    b.log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)),
+
+		matrixMessages: make(chan PortalMatrixMessage, b.config.Bridge.PortalMessageBuffer),
+	}
+
+	go portal.messageLoop()
+
+	return portal
+}
+
+func (p *Portal) HandleMatrixInvite(sender *User, evt *event.Event) {
+	// Look up an existing puppet or create a new one.
+	puppet := p.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey()))
+	if puppet != nil {
+		p.log.Infoln("no puppet for %v", sender)
+		// Open a conversation on the discord side?
+	}
+	p.log.Infoln("puppet:", puppet)
+}
+
+func (p *Portal) messageLoop() {
+	for {
+		select {
+		case msg := <-p.matrixMessages:
+			p.log.Infoln("got message", msg)
+		}
+	}
+}

+ 87 - 0
bridge/puppet.go

@@ -0,0 +1,87 @@
+package bridge
+
+import (
+	"fmt"
+	"regexp"
+
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/id"
+
+	"gitlab.com/beeper/discord/database"
+)
+
+type Puppet struct {
+	*database.Puppet
+
+	bridge *Bridge
+	log    log.Logger
+
+	MXID id.UserID
+}
+
+var userIDRegex *regexp.Regexp
+
+func (b *Bridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
+	return &Puppet{
+		Puppet: dbPuppet,
+		bridge: b,
+		log:    b.log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)),
+
+		MXID: b.FormatPuppetMXID(dbPuppet.ID),
+	}
+}
+
+func (b *Bridge) ParsePuppetMXID(mxid id.UserID) (string, bool) {
+	if userIDRegex == nil {
+		pattern := fmt.Sprintf(
+			"^@%s:%s$",
+			b.config.Bridge.FormatUsername("([0-9]+)"),
+			b.config.Homeserver.Domain,
+		)
+
+		userIDRegex = regexp.MustCompile(pattern)
+	}
+
+	match := userIDRegex.FindStringSubmatch(string(mxid))
+	if len(match) == 2 {
+		return match[1], true
+	}
+
+	return "", false
+}
+
+func (b *Bridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
+	id, ok := b.ParsePuppetMXID(mxid)
+	if !ok {
+		return nil
+	}
+
+	return b.GetPuppetByID(id)
+}
+
+func (b *Bridge) GetPuppetByID(id string) *Puppet {
+	b.puppetsLock.Lock()
+	defer b.puppetsLock.Unlock()
+
+	puppet, ok := b.puppets[id]
+	if !ok {
+		dbPuppet := b.db.Puppet.Get(id)
+		if dbPuppet == nil {
+			dbPuppet = b.db.Puppet.New()
+			dbPuppet.ID = id
+			dbPuppet.Insert()
+		}
+
+		puppet = b.NewPuppet(dbPuppet)
+		b.puppets[puppet.ID] = puppet
+	}
+
+	return puppet
+}
+
+func (b *Bridge) FormatPuppetMXID(did string) id.UserID {
+	return id.NewUserID(
+		b.config.Bridge.FormatUsername(did),
+		b.config.Homeserver.Domain,
+	)
+}

+ 93 - 0
bridge/user.go

@@ -0,0 +1,93 @@
+package bridge
+
+import (
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/id"
+
+	"gitlab.com/beeper/discord/database"
+)
+
+type User struct {
+	*database.User
+
+	bridge *Bridge
+	log    log.Logger
+}
+
+func (b *Bridge) loadUser(dbUser *database.User, mxid *id.UserID) *User {
+	// If we weren't passed in a user we attempt to create one if we were given
+	// a matrix id.
+	if dbUser == nil {
+		if mxid == nil {
+			return nil
+		}
+
+		dbUser = b.db.User.New()
+		dbUser.MXID = *mxid
+		dbUser.Insert()
+	}
+
+	user := b.NewUser(dbUser)
+
+	// We assume the usersLock was acquired by our caller.
+	b.usersByMXID[user.MXID] = user
+	if user.ID != "" {
+		b.usersByID[user.ID] = user
+	}
+
+	if user.ManagementRoom != "" {
+		// Lock the management rooms for our update
+		b.managementRoomsLock.Lock()
+		b.managementRooms[user.ManagementRoom] = user
+		b.managementRoomsLock.Unlock()
+	}
+
+	return user
+}
+
+func (b *Bridge) GetUserByMXID(userID id.UserID) *User {
+	// TODO: check if puppet
+
+	b.usersLock.Lock()
+	defer b.usersLock.Unlock()
+
+	user, ok := b.usersByMXID[userID]
+	if !ok {
+		return b.loadUser(b.db.User.GetByMXID(userID), &userID)
+	}
+
+	return user
+}
+
+func (b *Bridge) NewUser(dbUser *database.User) *User {
+	user := &User{
+		User:   dbUser,
+		bridge: b,
+		log:    b.log.Sub("User").Sub(string(dbUser.MXID)),
+	}
+
+	return user
+}
+
+func (u *User) SetManagementRoom(roomID id.RoomID) {
+	u.bridge.managementRoomsLock.Lock()
+	defer u.bridge.managementRoomsLock.Unlock()
+
+	existing, ok := u.bridge.managementRooms[roomID]
+	if ok {
+		// If there's a user already assigned to this management room, clear it
+		// out.
+		// I think this is due a name change or something? I dunno, leaving it
+		// for now.
+		existing.ManagementRoom = ""
+		existing.Update()
+	}
+
+	u.ManagementRoom = roomID
+	u.bridge.managementRooms[u.ManagementRoom] = u
+	u.Update()
+}
+
+func (u *User) HasSession() bool {
+	return u.User.Session != nil
+}

+ 12 - 0
config/bridge.go

@@ -8,6 +8,10 @@ import (
 type bridge struct {
 	UsernameTemplate string `yaml:"username_template"`
 
+	ManagementRoomText managementRoomText `yaml:"management_root_text"`
+
+	PortalMessageBuffer int `yaml:"portal_message_buffer"`
+
 	usernameTemplate *template.Template `yaml:"-"`
 }
 
@@ -18,11 +22,19 @@ func (b *bridge) validate() error {
 		b.UsernameTemplate = "Discord_{{.}}"
 	}
 
+	if b.PortalMessageBuffer <= 0 {
+		b.PortalMessageBuffer = 128
+	}
+
 	b.usernameTemplate, err = template.New("username").Parse(b.UsernameTemplate)
 	if err != nil {
 		return err
 	}
 
+	if err := b.ManagementRoomText.validate(); err != nil {
+		return err
+	}
+
 	return nil
 }
 

+ 38 - 0
config/managementroomtext.go

@@ -0,0 +1,38 @@
+package config
+
+type managementRoomText struct {
+	Welcome        string `yaml:"welcome"`
+	Connected      string `yaml:"welcome_connected"`
+	NotConnected   string `yaml:"welcome_unconnected"`
+	AdditionalHelp string `yaml:"additional_help"`
+}
+
+func (m *managementRoomText) validate() error {
+	if m.Welcome == "" {
+		m.Welcome = "Greetings, I am a Discord bridge bot!"
+	}
+
+	if m.Connected == "" {
+		m.Connected = "Use `help` to get started."
+	}
+
+	if m.NotConnected == "" {
+		m.NotConnected = "Use `help` to get started, or `login` to login."
+	}
+
+	return nil
+}
+
+func (m *managementRoomText) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type rawManagementRoomText managementRoomText
+
+	raw := rawManagementRoomText{}
+
+	if err := unmarshal(&raw); err != nil {
+		return err
+	}
+
+	*m = managementRoomText(raw)
+
+	return m.validate()
+}

+ 19 - 0
database/database.go

@@ -15,6 +15,10 @@ type Database struct {
 	*sql.DB
 	log     log.Logger
 	dialect string
+
+	User   *UserQuery
+	Portal *PortalQuery
+	Puppet *PuppetQuery
 }
 
 func New(dbType, uri string, maxOpenConns, maxIdleConns int, baseLog log.Logger) (*Database, error) {
@@ -42,5 +46,20 @@ func New(dbType, uri string, maxOpenConns, maxIdleConns int, baseLog log.Logger)
 		dialect: dbType,
 	}
 
+	db.User = &UserQuery{
+		db:  db,
+		log: db.log.Sub("User"),
+	}
+
+	db.Portal = &PortalQuery{
+		db:  db,
+		log: db.log.Sub("Portal"),
+	}
+
+	db.Puppet = &PuppetQuery{
+		db:  db,
+		log: db.log.Sub("Puppet"),
+	}
+
 	return db, nil
 }

+ 28 - 7
database/migrations/01-initial.sql

@@ -1,11 +1,32 @@
 CREATE TABLE IF NOT EXISTS portal (
-	did      text,
-	receiver text,
-	mxid     text UNIQUE,
+	id       TEXT,
+	receiver TEXT,
+	mxid     TEXT UNIQUE,
 
-	name   text NOT NULL,
-	topic  text NOT NULL,
-	avatar text NOT NULL,
+	name   TEXT NOT NULL,
+	topic  TEXT NOT NULL,
 
-	PRIMARY KEY (did, receiver)
+	avatar     TEXT NOT NULL,
+	avatar_url TEXT NOT NULL,
+
+	PRIMARY KEY (id, receiver)
+);
+
+CREATE TABLE IF NOT EXISTS puppet (
+	id          TEXT PRIMARY KEY,
+	displayname TEXT,
+
+	avatar     TEXT,
+	avatar_url TEXT,
+
+	enable_presence BOOLEAN NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS user (
+	mxid TEXT PRIMARY KEY,
+	id   TEXT UNIQUE,
+
+	management_room TEXT,
+
+	token TEXT
 );

+ 53 - 0
database/portal.go

@@ -0,0 +1,53 @@
+package database
+
+import (
+	"database/sql"
+
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/id"
+)
+
+type Portal struct {
+	db  *Database
+	log log.Logger
+
+	Key  PortalKey
+	MXID id.RoomID
+
+	Name  string
+	Topic string
+
+	Avatar    string
+	AvatarURL id.ContentURI
+}
+
+func (p *Portal) Scan(row Scannable) *Portal {
+	var mxid, avatarURL sql.NullString
+
+	err := row.Scan(&p.Key.ID, &p.Key.Receiver, &mxid, &p.Name, &p.Topic, &p.Avatar, &avatarURL)
+	if err != nil {
+		if err != sql.ErrNoRows {
+			p.log.Errorln("Database scan failed:", err)
+		}
+
+		return nil
+	}
+
+	p.MXID = id.RoomID(mxid.String)
+	p.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
+
+	return p
+}
+
+func (p *Portal) Insert() {
+	query := "INSERT INTO portal" +
+		" (id, receiver, mxid, name, topic, avatar, avatar_url)" +
+		" VALUES ($1, $2, $3, $4, $5, $6, $7)"
+
+	_, err := p.db.Exec(query, p.Key.ID, p.Key.Receiver, p.MXID,
+		p.Name, p.Topic, p.Avatar, p.AvatarURL.String())
+
+	if err != nil {
+		p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
+	}
+}

+ 13 - 0
database/portalkey.go

@@ -0,0 +1,13 @@
+package database
+
+type PortalKey struct {
+	ID       string
+	Receiver string
+}
+
+func (key PortalKey) String() string {
+	if key.Receiver == key.ID {
+		return key.ID
+	}
+	return key.ID + "-" + key.Receiver
+}

+ 58 - 0
database/portalquery.go

@@ -0,0 +1,58 @@
+package database
+
+import (
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/id"
+)
+
+type PortalQuery struct {
+	db  *Database
+	log log.Logger
+}
+
+func (pq *PortalQuery) New() *Portal {
+	return &Portal{
+		db:  pq.db,
+		log: pq.log,
+	}
+}
+
+func (pq *PortalQuery) GetAll() []*Portal {
+	return pq.getAll("SELECT * FROM portal")
+}
+
+func (pq *PortalQuery) GetByDID(key PortalKey) *Portal {
+	return pq.get("SELECT * FROM portal WHERE did=$1 AND receiver=$2", key.ID, key.Receiver)
+}
+
+func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
+	return pq.get("SELECT * FROM portal WHERE mxid=$1", mxid)
+}
+
+func (pq *PortalQuery) GetAllByDID(did string) []*Portal {
+	return pq.getAll("SELECT * FROM portal WHERE did=$1", did)
+}
+
+func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal {
+	rows, err := pq.db.Query(query, args...)
+	if err != nil || rows == nil {
+		return nil
+	}
+	defer rows.Close()
+
+	portals := []*Portal{}
+	for rows.Next() {
+		portals = append(portals, pq.New().Scan(rows))
+	}
+
+	return portals
+}
+
+func (pq *PortalQuery) get(query string, args ...interface{}) *Portal {
+	row := pq.db.QueryRow(query, args...)
+	if row == nil {
+		return nil
+	}
+
+	return pq.New().Scan(row)
+}

+ 56 - 0
database/puppet.go

@@ -0,0 +1,56 @@
+package database
+
+import (
+	"database/sql"
+
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/id"
+)
+
+type Puppet struct {
+	db  *Database
+	log log.Logger
+
+	ID          string
+	DisplayName string
+
+	Avatar    string
+	AvatarURL id.ContentURI
+
+	EnablePresence bool
+}
+
+func (p *Puppet) Scan(row Scannable) *Puppet {
+	var did, displayName, avatar, avatarURL sql.NullString
+	var enablePresence sql.NullBool
+
+	err := row.Scan(&did, &displayName, &avatar, &avatarURL, &enablePresence)
+	if err != nil {
+		if err != sql.ErrNoRows {
+			p.log.Errorln("Database scan failed:", err)
+		}
+
+		return nil
+	}
+
+	p.ID = did.String
+	p.DisplayName = displayName.String
+	p.Avatar = avatar.String
+	p.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
+	p.EnablePresence = enablePresence.Bool
+
+	return p
+}
+
+func (p *Puppet) Insert() {
+	query := "INSERT INTO puppet" +
+		" (id, display_name, avatar, avatar_url, enable_presence)" +
+		" VALUES ($1, $2, $3, $4, $5)"
+
+	_, err := p.db.Exec(query, p.ID, p.DisplayName, p.Avatar,
+		p.AvatarURL.String(), p.EnablePresence)
+
+	if err != nil {
+		p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
+	}
+}

+ 28 - 0
database/puppetquery.go

@@ -0,0 +1,28 @@
+package database
+
+import (
+	log "maunium.net/go/maulogger/v2"
+)
+
+type PuppetQuery struct {
+	db  *Database
+	log log.Logger
+}
+
+func (pq *PuppetQuery) New() *Puppet {
+	return &Puppet{
+		db:  pq.db,
+		log: pq.log,
+
+		EnablePresence: true,
+	}
+}
+
+func (pq *PuppetQuery) Get(id string) *Puppet {
+	row := pq.db.QueryRow("SELECT id, displayname, avatar, avatar_url, enable_presence FROM puppet WHERE id=$1", id)
+	if row == nil {
+		return nil
+	}
+
+	return pq.New().Scan(row)
+}

+ 5 - 0
database/scannable.go

@@ -0,0 +1,5 @@
+package database
+
+type Scannable interface {
+	Scan(...interface{}) error
+}

+ 83 - 0
database/user.go

@@ -0,0 +1,83 @@
+package database
+
+import (
+	"database/sql"
+
+	"github.com/bwmarrin/discordgo"
+
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/id"
+)
+
+type User struct {
+	db  *Database
+	log log.Logger
+
+	MXID id.UserID
+	ID   string
+
+	ManagementRoom id.RoomID
+
+	Session *discordgo.Session
+}
+
+func (u *User) Scan(row Scannable) *User {
+	var token sql.NullString
+
+	err := row.Scan(&u.MXID, &u.ID, &u.ManagementRoom, &token)
+	if err != nil {
+		if err != sql.ErrNoRows {
+			u.log.Errorln("Database scan failed:", err)
+		}
+
+		return nil
+	}
+
+	if token.Valid {
+		session, err := discordgo.New("Bearer " + token.String)
+		if err != nil {
+			u.log.Errorln("Failed to create discord session:", err)
+		} else {
+			u.Session = session
+		}
+	}
+
+	return u
+}
+
+func (u *User) sessionNonptr() discordgo.Session {
+	if u.Session != nil {
+		return *u.Session
+	}
+
+	return discordgo.Session{}
+}
+
+func (u *User) Insert() {
+	session := u.sessionNonptr()
+
+	query := "INSERT INTO user" +
+		" (mxid, id, management_room, token)" +
+		" VALUES ($1, $2, $3, $4);"
+
+	_, err := u.db.Exec(query, u.MXID, u.ID, u.ManagementRoom,
+		session.Identify.Token)
+
+	if err != nil {
+		u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
+	}
+}
+
+func (u *User) Update() {
+	session := u.sessionNonptr()
+
+	query := "UPDATE user SET" +
+		" id=$1, management_room=$2, token=$3" +
+		" WHERE mxid=$4;"
+
+	_, err := u.db.Exec(query, u.ID, u.ManagementRoom, session.Identify.Token, u.MXID)
+
+	if err != nil {
+		u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
+	}
+}

+ 27 - 0
database/userquery.go

@@ -0,0 +1,27 @@
+package database
+
+import (
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix/id"
+)
+
+type UserQuery struct {
+	db  *Database
+	log log.Logger
+}
+
+func (uq *UserQuery) New() *User {
+	return &User{
+		db:  uq.db,
+		log: uq.log,
+	}
+}
+
+func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
+	row := uq.db.QueryRow("SELECT mxid, id, management_room, token FROM user where mxid=$1", userID)
+	if row == nil {
+		return nil
+	}
+
+	return uq.New().Scan(row)
+}

+ 7 - 4
go.mod

@@ -6,17 +6,20 @@ require (
 	github.com/alecthomas/kong v0.2.18
 	github.com/lib/pq v1.9.0
 	github.com/lopezator/migrator v0.3.0
-	github.com/mattn/go-sqlite3 v1.14.6
+	github.com/mattn/go-sqlite3 v1.14.9
 	gopkg.in/yaml.v2 v2.4.0
 	maunium.net/go/maulogger/v2 v2.3.1
-	maunium.net/go/mautrix v0.9.27
+	maunium.net/go/mautrix v0.10.8
 )
 
 require (
 	github.com/btcsuite/btcutil v1.0.2 // indirect
+	github.com/bwmarrin/discordgo v0.23.2 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gorilla/websocket v1.4.2 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
-	golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 // indirect
-	golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
+	golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect
+	golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
 )

+ 26 - 0
go.sum

@@ -11,6 +11,8 @@ github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVa
 github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
 github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
+github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4=
+github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
 github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -21,6 +23,7 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
 github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
 github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -34,6 +37,8 @@ github.com/lopezator/migrator v0.3.0 h1:VW/rR+J8NYwPdkBxjrFdjwejpgvP59LbmANJxXuN
 github.com/lopezator/migrator v0.3.0/go.mod h1:bpVAVPkWSvTw8ya2Pk7E/KiNAyDWNImgivQY79o8/8I=
 github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
 github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
+github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -41,35 +46,52 @@ 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI=
+github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
 github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/tidwall/sjson v1.1.5/go.mod h1:VuJzsZnTowhSxWdOgsAnb886i4AjEyTkk7tNtsL7EYE=
+github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
 golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 h1:/ZScEX8SfEmUGRHs0gxpqteO5nfNW6axyZbBdw9A12g=
 golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d h1:1aflnvSoWWLI2k/dMUAl5lvU1YO4Mb4hz0gh+1rjcxU=
 golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -89,3 +111,7 @@ maunium.net/go/maulogger/v2 v2.3.1 h1:fwBYJne0pHvJrrIPHK+TAPfyxxbBEz46oVGez2x0OD
 maunium.net/go/maulogger/v2 v2.3.1/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
 maunium.net/go/mautrix v0.9.27 h1:6MV6YSCGqfw8Rb0G1PHjTOkYkTY0vcZaz6wd+U+V1Is=
 maunium.net/go/mautrix v0.9.27/go.mod h1:7IzKfWvpQtN+W2Lzxc0rLvIxFM3ryKX6Ys3S/ZoWbg8=
+maunium.net/go/mautrix v0.10.7 h1:QV5vbCY4g50N7r1ihdG6zEPfaPn/EVYjM5H+qfLy4RM=
+maunium.net/go/mautrix v0.10.7/go.mod h1:k4Ng5oci83MEbqPL6KOjPdbU7f8v01KlMjR/zTQ+7mA=
+maunium.net/go/mautrix v0.10.8 h1:h64yDl8fMPk3j/tnkb6c5itSo/LZ1QSKQ3ze5zyanUg=
+maunium.net/go/mautrix v0.10.8/go.mod h1:k4Ng5oci83MEbqPL6KOjPdbU7f8v01KlMjR/zTQ+7mA=