Browse Source

A ton of work getting towards dms.

This commit also points to my fork of discordgo which makes it look like the
official client which is the only way to get the actually contents of a dm
when not authorized as a bot.
Gary Kramlich 3 năm trước cách đây
mục cha
commit
680f7bdbea

+ 1 - 1
bridge/bot.go

@@ -5,7 +5,7 @@ import (
 )
 
 func (b *Bridge) updateBotProfile() {
-	cfg := b.config.Appservice.Bot
+	cfg := b.Config.Appservice.Bot
 
 	// Set the bot's avatar.
 	if cfg.Avatar != "" {

+ 13 - 2
bridge/bridge.go

@@ -21,7 +21,7 @@ const (
 )
 
 type Bridge struct {
-	config *config.Config
+	Config *config.Config
 
 	log log.Logger
 
@@ -44,6 +44,8 @@ type Bridge struct {
 
 	puppets     map[string]*Puppet
 	puppetsLock sync.Mutex
+
+	StateStore *database.SQLStateStore
 }
 
 func New(cfg *config.Config) (*Bridge, error) {
@@ -73,12 +75,17 @@ func New(cfg *config.Config) (*Bridge, error) {
 		return nil, err
 	}
 
+	// Create the state store
+	logger.Debugln("Initializing state store")
+	stateStore := database.NewSQLStateStore(db)
+	appservice.StateStore = stateStore
+
 	// Create the bridge.
 	bridge := &Bridge{
 		as:     appservice,
 		db:     db,
 		bot:    bot,
-		config: cfg,
+		Config: cfg,
 		log:    logger,
 
 		usersByMXID: make(map[id.UserID]*User),
@@ -88,6 +95,10 @@ func New(cfg *config.Config) (*Bridge, error) {
 
 		portalsByMXID: make(map[id.RoomID]*Portal),
 		portalsByID:   make(map[database.PortalKey]*Portal),
+
+		puppets: make(map[string]*Puppet),
+
+		StateStore: stateStore,
 	}
 
 	// Setup the event processors

+ 5 - 7
bridge/matrix.go

@@ -78,7 +78,7 @@ func (mh *matrixHandler) handleMessage(evt *event.Event) {
 	content.RemoveReplyFallback()
 
 	if content.MsgType == event.MsgText {
-		prefix := mh.bridge.config.Bridge.CommandPrefix
+		prefix := mh.bridge.Config.Bridge.CommandPrefix
 
 		hasPrefix := strings.HasPrefix(content.Body, prefix)
 		if hasPrefix {
@@ -150,16 +150,16 @@ func (mh *matrixHandler) handleBotInvite(evt *event.Event) {
 
 	// 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)
+	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)
+			mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.Connected)
 		} else {
-			mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.config.Bridge.ManagementRoomText.NotConnected)
+			mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.NotConnected)
 		}
 
-		additionalHelp := mh.bridge.config.Bridge.ManagementRoomText.AdditionalHelp
+		additionalHelp := mh.bridge.Config.Bridge.ManagementRoomText.AdditionalHelp
 		if additionalHelp != "" {
 			mh.sendNoticeWithmarkdown(evt.RoomID, additionalHelp)
 		}
@@ -201,8 +201,6 @@ func (mh *matrixHandler) handleMembership(evt *event.Event) {
 			mh.handlePuppetInvite(evt, user, puppet)
 		}
 
-		mh.log.Warnln("no existing portal for", evt.RoomID)
-
 		return
 	}
 

+ 177 - 6
bridge/portal.go

@@ -2,9 +2,12 @@ package bridge
 
 import (
 	"fmt"
+	"sync"
 
-	log "maunium.net/go/maulogger/v2"
+	"github.com/bwmarrin/discordgo"
 
+	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/appservice"
 	"maunium.net/go/mautrix/event"
 	"maunium.net/go/mautrix/id"
@@ -12,7 +15,12 @@ import (
 	"gitlab.com/beeper/discord/database"
 )
 
-type PortalMatrixMessage struct {
+type portalDiscordMessage struct {
+	msg  interface{}
+	user *User
+}
+
+type portalMatrixMessage struct {
 	evt  *event.Event
 	user *User
 }
@@ -23,9 +31,18 @@ type Portal struct {
 	bridge *Bridge
 	log    log.Logger
 
-	matrixMessages chan PortalMatrixMessage
+	channelType discordgo.ChannelType
+
+	roomCreateLock sync.Mutex
+
+	discordMessages chan portalDiscordMessage
+	matrixMessages  chan portalMatrixMessage
 }
 
+var (
+	portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType}
+)
+
 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.
@@ -63,13 +80,26 @@ func (b *Bridge) GetPortalByMXID(mxid id.RoomID) *Portal {
 	return portal
 }
 
+func (b *Bridge) GetPortalByID(key database.PortalKey) *Portal {
+	b.portalsLock.Lock()
+	defer b.portalsLock.Unlock()
+
+	portal, ok := b.portalsByID[key]
+	if !ok {
+		return b.loadPortal(b.db.Portal.GetByID(key), &key)
+	}
+
+	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),
+		discordMessages: make(chan portalDiscordMessage, b.Config.Bridge.PortalMessageBuffer),
+		matrixMessages:  make(chan portalMatrixMessage, b.Config.Bridge.PortalMessageBuffer),
 	}
 
 	go portal.messageLoop()
@@ -91,13 +121,15 @@ func (p *Portal) messageLoop() {
 	for {
 		select {
 		case msg := <-p.matrixMessages:
-			p.log.Infoln("got message", msg)
+			p.log.Infoln("got matrix message", msg)
+		case msg := <-p.discordMessages:
+			p.handleDiscordMessage(msg)
 		}
 	}
 }
 
 func (p *Portal) IsPrivateChat() bool {
-	return false
+	return (p.channelType == discordgo.ChannelTypeDM || p.channelType == discordgo.ChannelTypeGroupDM)
 }
 
 func (p *Portal) MainIntent() *appservice.IntentAPI {
@@ -107,3 +139,142 @@ func (p *Portal) MainIntent() *appservice.IntentAPI {
 
 	return p.bridge.bot
 }
+
+func (p *Portal) createMatrixRoom(user *User, channel *discordgo.Channel) error {
+	p.roomCreateLock.Lock()
+	defer p.roomCreateLock.Unlock()
+
+	// If we have a matrix id the room should exist so we have nothing to do.
+	if p.MXID != "" {
+		return nil
+	}
+
+	p.channelType = channel.Type
+
+	intent := p.MainIntent()
+	if err := intent.EnsureRegistered(); err != nil {
+		return err
+	}
+
+	if p.IsPrivateChat() {
+		puppet := p.bridge.GetPuppetByID(p.Key.ID)
+		puppet.SyncContact(user)
+
+		p.Name = puppet.DisplayName
+		p.Avatar = puppet.Avatar
+		p.AvatarURL = puppet.AvatarURL
+	}
+
+	p.log.Infoln("Creating Matrix room. Info source:", p.Portal.Key.ID)
+
+	initialState := []*event.Event{}
+
+	creationContent := make(map[string]interface{})
+	// if !portal.bridge.Config.Bridge.FederateRooms {
+	creationContent["m.federate"] = false
+	// }
+
+	var invite []id.UserID
+
+	if p.IsPrivateChat() {
+		invite = append(invite, p.bridge.bot.UserID)
+	}
+
+	resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
+		Visibility:      "private",
+		Name:            p.Name,
+		Topic:           p.Topic,
+		Preset:          "private_chat",
+		IsDirect:        p.IsPrivateChat(),
+		InitialState:    initialState,
+		CreationContent: creationContent,
+	})
+	if err != nil {
+		return err
+	}
+
+	p.MXID = resp.RoomID
+	p.Update()
+	p.bridge.portalsLock.Lock()
+	p.bridge.portalsByMXID[p.MXID] = p
+	p.bridge.portalsLock.Unlock()
+
+	p.ensureUserInvited(user)
+
+	// if p.IsPrivateChat() {
+	// 	puppet := user.bridge.GetPuppetByID(p.Key.ID)
+
+	// if p.bridge.Config.Bridge.Encryption.Default {
+	// 	err = portal.bridge.Bot.EnsureJoined(portal.MXID)
+	// 	if err != nil {
+	// 		portal.log.Errorln("Failed to join created portal with bridge bot for e2be:", err)
+	// 	}
+	// }
+
+	// user.UpdateDirectChats(map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}})
+	// }
+
+	firstEventResp, err := p.MainIntent().SendMessageEvent(p.MXID, portalCreationDummyEvent, struct{}{})
+	if err != nil {
+		p.log.Errorln("Failed to send dummy event to mark portal creation:", err)
+	} else {
+		p.FirstEventID = firstEventResp.EventID
+		p.Update()
+	}
+
+	return nil
+}
+
+func (p *Portal) handleDiscordMessage(msg portalDiscordMessage) {
+	if p.MXID == "" {
+		p.log.Debugln("Creating Matrix room from incoming message")
+
+		discordMsg := msg.msg.(*discordgo.MessageCreate)
+		channel, err := msg.user.Session.Channel(discordMsg.ChannelID)
+		if err != nil {
+			p.log.Errorln("Failed to find channel for message:", err)
+
+			return
+		}
+
+		if err := p.createMatrixRoom(msg.user, channel); err != nil {
+			p.log.Errorln("Failed to create portal room:", err)
+
+			return
+		}
+	}
+
+	switch msg.msg.(type) {
+	case *discordgo.MessageCreate:
+		p.handleMessage(msg.msg.(*discordgo.MessageCreate).Message)
+	default:
+		p.log.Warnln("unknown message type")
+	}
+}
+
+func (p *Portal) ensureUserInvited(user *User) bool {
+	return user.ensureInvited(p.MainIntent(), p.MXID, p.IsPrivateChat())
+}
+
+func (p *Portal) handleMessage(msg *discordgo.Message) {
+	if p.MXID == "" {
+		p.log.Warnln("handle message called without a valid portal")
+
+		return
+	}
+
+	// TODO: Check if we already got the message
+
+	p.log.Debugln("content", msg.Content)
+	p.log.Debugln("embeds", msg.Embeds)
+	p.log.Debugln("msg", msg)
+
+	content := &event.MessageEventContent{
+		Body:    msg.Content,
+		MsgType: event.MsgText,
+	}
+
+	resp, err := p.MainIntent().SendMessageEvent(p.MXID, event.EventMessage, content)
+	p.log.Warnln("response:", resp)
+	p.log.Warnln("error:", err)
+}

+ 23 - 4
bridge/puppet.go

@@ -3,6 +3,7 @@ package bridge
 import (
 	"fmt"
 	"regexp"
+	"sync"
 
 	log "maunium.net/go/maulogger/v2"
 	"maunium.net/go/mautrix/appservice"
@@ -18,6 +19,8 @@ type Puppet struct {
 	log    log.Logger
 
 	MXID id.UserID
+
+	syncLock sync.Mutex
 }
 
 var userIDRegex *regexp.Regexp
@@ -36,8 +39,8 @@ 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,
+			b.Config.Bridge.FormatUsername("([0-9]+)"),
+			b.Config.Homeserver.Domain,
 		)
 
 		userIDRegex = regexp.MustCompile(pattern)
@@ -82,11 +85,27 @@ func (b *Bridge) GetPuppetByID(id string) *Puppet {
 
 func (b *Bridge) FormatPuppetMXID(did string) id.UserID {
 	return id.NewUserID(
-		b.config.Bridge.FormatUsername(did),
-		b.config.Homeserver.Domain,
+		b.Config.Bridge.FormatUsername(did),
+		b.Config.Homeserver.Domain,
 	)
 }
 
 func (p *Puppet) DefaultIntent() *appservice.IntentAPI {
 	return p.bridge.as.Intent(p.MXID)
 }
+
+func (p *Puppet) SyncContact(user *User) {
+	p.syncLock.Lock()
+	defer p.syncLock.Unlock()
+
+	dUser, err := user.Session.User(p.ID)
+	if err != nil {
+		p.log.Warnfln("failed to sync puppet %s: %v", p.ID, err)
+
+		return
+	}
+
+	p.DisplayName = p.bridge.Config.Bridge.FormatDisplayname(dUser)
+
+	p.Update()
+}

+ 124 - 2
bridge/user.go

@@ -1,10 +1,14 @@
 package bridge
 
 import (
+	"errors"
+	"strings"
+
 	"github.com/bwmarrin/discordgo"
 	"github.com/skip2/go-qrcode"
 
 	log "maunium.net/go/maulogger/v2"
+	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/appservice"
 	"maunium.net/go/mautrix/event"
 	"maunium.net/go/mautrix/id"
@@ -177,13 +181,131 @@ func (u *User) Login(token string) error {
 }
 
 func (u *User) Connect() error {
+	u.log.Debugln("connecting to discord")
+
+	// get our user info
+	user, err := u.User.Session.User("@me")
+	if err != nil {
+		return err
+	}
+
+	u.User.ID = user.ID
+
+	// Add our event handlers
+	u.User.Session.AddHandler(u.connectedHandler)
+	u.User.Session.AddHandler(u.disconnectedHandler)
+
+	u.User.Session.AddHandler(u.channelCreateHandler)
+	u.User.Session.AddHandler(u.channelDeleteHandler)
+	u.User.Session.AddHandler(u.channelPinsUpdateHandler)
+	u.User.Session.AddHandler(u.channelUpdateHandler)
+
 	u.User.Session.AddHandler(u.messageHandler)
 
-	u.log.Warnln("logged in, opening websocket")
+	// u.User.Session.Identify.Capabilities = 125
+	// // Setup our properties
+	// u.User.Session.Identify.Properties = discordgo.IdentifyProperties{
+	// 	OS:                "Windows",
+	// 	OSVersion:         "10",
+	// 	Browser:           "Chrome",
+	// 	BrowserUserAgent:  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36",
+	// 	BrowserVersion:    "92.0.4515.159",
+	// 	Referrer:          "https://discord.com/channels/@me",
+	// 	ReferringDomain:   "discord.com",
+	// 	ClientBuildNumber: "83364",
+	// 	ReleaseChannel:    "stable",
+	// }
+
+	u.User.Session.Identify.Presence.Status = "online"
 
 	return u.User.Session.Open()
 }
 
+func (u *User) connectedHandler(s *discordgo.Session, c *discordgo.Connect) {
+	u.log.Debugln("connected to discord")
+}
+
+func (u *User) disconnectedHandler(s *discordgo.Session, d *discordgo.Disconnect) {
+	u.log.Debugln("disconnected from discord")
+}
+
+func (u *User) channelCreateHandler(s *discordgo.Session, c *discordgo.ChannelCreate) {
+	key := database.NewPortalKey(u.User.ID, c.ID)
+	portal := u.bridge.GetPortalByID(key)
+
+	portal.Name = c.Name
+	portal.Topic = c.Topic
+
+	if c.Icon != "" {
+		u.log.Debugln("channel icon", c.Icon)
+	}
+
+	portal.Update()
+
+	portal.createMatrixRoom(u, c.Channel)
+}
+
+func (u *User) channelDeleteHandler(s *discordgo.Session, c *discordgo.ChannelDelete) {
+	u.log.Debugln("channel delete handler")
+}
+
+func (u *User) channelPinsUpdateHandler(s *discordgo.Session, c *discordgo.ChannelPinsUpdate) {
+	u.log.Debugln("channel pins update")
+}
+
+func (u *User) channelUpdateHandler(s *discordgo.Session, c *discordgo.ChannelUpdate) {
+	key := database.NewPortalKey(u.User.ID, c.ID)
+	portal := u.bridge.GetPortalByID(key)
+
+	portal.Name = c.Name
+	portal.Topic = c.Topic
+	u.log.Debugln("channel icon", c.Icon)
+	portal.Update()
+
+	u.log.Debugln("channel update")
+}
+
 func (u *User) messageHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
-	u.log.Warnln("received message", m)
+	if m.GuildID != "" {
+		u.log.Debugln("ignoring guild build messaged")
+
+		return
+	}
+
+	key := database.NewPortalKey(u.User.ID, m.ChannelID)
+	portal := u.bridge.GetPortalByID(key)
+
+	msg := portalDiscordMessage{
+		msg:  m,
+		user: u,
+	}
+
+	portal.discordMessages <- msg
+}
+
+func (u *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) bool {
+	ret := false
+
+	inviteContent := event.Content{
+		Parsed: &event.MemberEventContent{
+			Membership: event.MembershipInvite,
+			IsDirect:   isDirect,
+		},
+		Raw: map[string]interface{}{},
+	}
+
+	resp, err := intent.SendStateEvent(roomID, event.StateMember, u.MXID.String(), &inviteContent)
+	u.log.Warnfln("resp: %#v", resp)
+
+	var httpErr mautrix.HTTPError
+	if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") {
+		u.bridge.StateStore.SetMembership(roomID, u.MXID, event.MembershipJoin)
+		ret = true
+	} else if err != nil {
+		u.log.Warnfln("Failed to invite user to %s: %v", roomID, err)
+	} else {
+		ret = true
+	}
+
+	return ret
 }

+ 46 - 7
config/bridge.go

@@ -1,12 +1,15 @@
 package config
 
 import (
-	"bytes"
+	"strings"
 	"text/template"
+
+	"github.com/bwmarrin/discordgo"
 )
 
 type bridge struct {
-	UsernameTemplate string `yaml:"username_template"`
+	UsernameTemplate    string `yaml:"username_template"`
+	DisplaynameTemplate string `yaml:"displayname_template"`
 
 	CommandPrefix string `yaml:"command_prefix"`
 
@@ -14,7 +17,8 @@ type bridge struct {
 
 	PortalMessageBuffer int `yaml:"portal_message_buffer"`
 
-	usernameTemplate *template.Template `yaml:"-"`
+	usernameTemplate    *template.Template `yaml:"-"`
+	displaynameTemplate *template.Template `yaml:"-"`
 }
 
 func (b *bridge) validate() error {
@@ -24,15 +28,24 @@ 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
 	}
 
-	b.usernameTemplate, err = template.New("username").Parse(b.UsernameTemplate)
+	if b.DisplaynameTemplate == "" {
+		b.DisplaynameTemplate = "{{.Username}}#{{.Discriminator}} (D){{if .Bot}} (bot){{end}}"
+	}
+
+	b.displaynameTemplate, err = template.New("displayname").Parse(b.DisplaynameTemplate)
 	if err != nil {
 		return err
 	}
 
+	if b.PortalMessageBuffer <= 0 {
+		b.PortalMessageBuffer = 128
+	}
+
 	if b.CommandPrefix == "" {
 		b.CommandPrefix = "!dis"
 	}
@@ -60,9 +73,35 @@ func (b *bridge) UnmarshalYAML(unmarshal func(interface{}) error) error {
 }
 
 func (b bridge) FormatUsername(userid string) string {
-	var buffer bytes.Buffer
+	var buffer strings.Builder
 
 	b.usernameTemplate.Execute(&buffer, userid)
 
 	return buffer.String()
 }
+
+type simplfiedUser struct {
+	Username      string
+	Discriminator string
+	Locale        string
+	Verified      bool
+	MFAEnabled    bool
+	Bot           bool
+	System        bool
+}
+
+func (b bridge) FormatDisplayname(user *discordgo.User) string {
+	var buffer strings.Builder
+
+	b.displaynameTemplate.Execute(&buffer, simplfiedUser{
+		Username:      user.Username,
+		Discriminator: user.Discriminator,
+		Locale:        user.Locale,
+		Verified:      user.Verified,
+		MFAEnabled:    user.MFAEnabled,
+		Bot:           user.Bot,
+		System:        user.System,
+	})
+
+	return buffer.String()
+}

+ 32 - 12
database/migrations/01-initial.sql

@@ -1,20 +1,22 @@
-CREATE TABLE IF NOT EXISTS portal (
-	id       TEXT,
-	receiver TEXT,
-	mxid     TEXT UNIQUE,
+CREATE TABLE portal (
+	id         TEXT,
+	channel_id TEXT,
+	mxid       TEXT UNIQUE,
 
-	name   TEXT NOT NULL,
-	topic  TEXT NOT NULL,
+	name  TEXT NOT NULL,
+	topic TEXT NOT NULL,
 
 	avatar     TEXT NOT NULL,
-	avatar_url TEXT NOT NULL,
+	avatar_url TEXT,
+
+	first_event_id TEXT,
 
-	PRIMARY KEY (id, receiver)
+	PRIMARY KEY (id, channel_id)
 );
 
-CREATE TABLE IF NOT EXISTS puppet (
-	id          TEXT PRIMARY KEY,
-	displayname TEXT,
+CREATE TABLE puppet (
+	id           TEXT PRIMARY KEY,
+	display_name TEXT,
 
 	avatar     TEXT,
 	avatar_url TEXT,
@@ -22,7 +24,7 @@ CREATE TABLE IF NOT EXISTS puppet (
 	enable_presence BOOLEAN NOT NULL DEFAULT true
 );
 
-CREATE TABLE IF NOT EXISTS user (
+CREATE TABLE user (
 	mxid TEXT PRIMARY KEY,
 	id   TEXT UNIQUE,
 
@@ -30,3 +32,21 @@ CREATE TABLE IF NOT EXISTS user (
 
 	token TEXT
 );
+
+CREATE TABLE mx_user_profile (
+	room_id     TEXT,
+	user_id     TEXT,
+	membership  TEXT NOT NULL,
+	displayname TEXT,
+	avatar_url  TEXT,
+	PRIMARY KEY (room_id, user_id)
+);
+
+CREATE TABLE mx_registrations (
+	user_id TEXT PRIMARY KEY
+);
+
+CREATE TABLE mx_room_state (
+	room_id      TEXT PRIMARY KEY,
+	power_levels TEXT
+);

+ 23 - 6
database/portal.go

@@ -19,12 +19,14 @@ type Portal struct {
 
 	Avatar    string
 	AvatarURL id.ContentURI
+
+	FirstEventID id.EventID
 }
 
 func (p *Portal) Scan(row Scannable) *Portal {
-	var mxid, avatarURL sql.NullString
+	var mxid, avatarURL, firstEventID sql.NullString
 
-	err := row.Scan(&p.Key.ID, &p.Key.Receiver, &mxid, &p.Name, &p.Topic, &p.Avatar, &avatarURL)
+	err := row.Scan(&p.Key.ID, &p.Key.ChannelID, &mxid, &p.Name, &p.Topic, &p.Avatar, &avatarURL, &firstEventID)
 	if err != nil {
 		if err != sql.ErrNoRows {
 			p.log.Errorln("Database scan failed:", err)
@@ -35,19 +37,34 @@ func (p *Portal) Scan(row Scannable) *Portal {
 
 	p.MXID = id.RoomID(mxid.String)
 	p.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
+	p.FirstEventID = id.EventID(firstEventID.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)"
+		" (id, mxid, channel_id, name, topic, avatar, avatar_url, first_event_id)" +
+		" VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
 
-	_, err := p.db.Exec(query, p.Key.ID, p.Key.Receiver, p.MXID,
-		p.Name, p.Topic, p.Avatar, p.AvatarURL.String())
+	_, err := p.db.Exec(query, p.Key.ID, p.MXID, p.Key.ChannelID,
+		p.Name, p.Topic, p.Avatar, p.AvatarURL.String(), p.FirstEventID.String())
 
 	if err != nil {
 		p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
 	}
 }
+
+func (p *Portal) Update() {
+	query := "UPDATE portal SET" +
+		" mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5, first_event_id=$6" +
+		" WHERE id=$7 AND channel_id=$8"
+
+	_, err := p.db.Exec(query, p.MXID, p.Name, p.Topic, p.Avatar,
+		p.AvatarURL.String(), p.FirstEventID.String(), p.Key.ID,
+		p.Key.ChannelID)
+
+	if err != nil {
+		p.log.Warnfln("Failed to update %s: %v", p.Key, err)
+	}
+}

+ 11 - 4
database/portalkey.go

@@ -1,13 +1,20 @@
 package database
 
 type PortalKey struct {
-	ID       string
-	Receiver string
+	ID        string
+	ChannelID string
+}
+
+func NewPortalKey(id, channelID string) PortalKey {
+	return PortalKey{
+		ID:        id,
+		ChannelID: channelID,
+	}
 }
 
 func (key PortalKey) String() string {
-	if key.Receiver == key.ID {
+	if key.ChannelID == key.ID {
 		return key.ID
 	}
-	return key.ID + "-" + key.Receiver
+	return key.ID + "-" + key.ChannelID
 }

+ 3 - 3
database/portalquery.go

@@ -21,8 +21,8 @@ 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) GetByID(key PortalKey) *Portal {
+	return pq.get("SELECT * FROM portal WHERE id=$1 AND channel_id=$2", key.ID, key.ChannelID)
 }
 
 func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
@@ -30,7 +30,7 @@ func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
 }
 
 func (pq *PortalQuery) GetAllByDID(did string) []*Portal {
-	return pq.getAll("SELECT * FROM portal WHERE did=$1", did)
+	return pq.getAll("SELECT * FROM portal WHERE id=$1", did)
 }
 
 func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal {

+ 13 - 0
database/puppet.go

@@ -54,3 +54,16 @@ func (p *Puppet) Insert() {
 		p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
 	}
 }
+
+func (p *Puppet) Update() {
+	query := "UPDATE puppet" +
+		" SET display_name=$1, avatar=$2, avatar_url=$3, enable_presence=$4" +
+		" WHERE id=$5"
+
+	_, err := p.db.Exec(query, p.DisplayName, p.Avatar, p.AvatarURL.String(),
+		p.EnablePresence, p.ID)
+
+	if err != nil {
+		p.log.Warnfln("Failed to update %s: %v", p.ID, err)
+	}
+}

+ 1 - 1
database/puppetquery.go

@@ -19,7 +19,7 @@ func (pq *PuppetQuery) New() *Puppet {
 }
 
 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)
+	row := pq.db.QueryRow("SELECT id, display_name, avatar, avatar_url, enable_presence FROM puppet WHERE id=$1", id)
 	if row == nil {
 		return nil
 	}

+ 2 - 0
go.mod

@@ -25,3 +25,5 @@ require (
 	golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect
 	golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
 )
+
+replace github.com/bwmarrin/discordgo => github.com/grimmy/discordgo v0.23.3-0.20220126043435-7470d1aacd64

+ 2 - 0
go.sum

@@ -28,6 +28,8 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
 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/grimmy/discordgo v0.23.3-0.20220126043435-7470d1aacd64 h1:KrZb8UPGlmlnUx+eLQDHWnt1uPkdCUeVgUgFRFFdR1o=
+github.com/grimmy/discordgo v0.23.3-0.20220126043435-7470d1aacd64/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=