Răsfoiți Sursa

Add initial support for relay mode with webhooks

Tulir Asokan 2 ani în urmă
părinte
comite
17d4b79554

+ 86 - 0
commands.go

@@ -55,6 +55,7 @@ func (br *DiscordBridge) RegisterCommands() {
 		cmdLogout,
 		cmdReconnect,
 		cmdDisconnect,
+		cmdSetRelay,
 		cmdGuilds,
 		cmdRejoinSpace,
 		cmdDeleteAllPortals,
@@ -352,6 +353,91 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
 	}
 }
 
+var cmdSetRelay = &commands.FullHandler{
+	Func: wrapCommand(fnSetRelay),
+	Name: "set-relay",
+	Help: commands.HelpMeta{
+		Section:     commands.HelpSectionUnclassified,
+		Description: "Create or set a relay webhook for a portal",
+		Args:        "[room ID] <​--url URL> OR <​--create [name]>",
+	},
+	RequiresLogin: true,
+}
+
+const webhookURLFormat = "https://discord.com/api/webhooks/%d/%s"
+
+const selectRelayHelp = "Usage: `$cmdprefix [room ID] <​--url URL> OR <​--create [name]>`"
+
+func fnSetRelay(ce *WrappedCommandEvent) {
+	portal := ce.Portal
+	if len(ce.Args) > 0 && strings.HasPrefix(ce.Args[0], "!") {
+		portal = ce.Bridge.GetPortalByMXID(id.RoomID(ce.Args[0]))
+		if portal == nil {
+			ce.Reply("Portal with room ID %s not found", ce.Args[0])
+			return
+		}
+		ce.Args = ce.Args[1:]
+	} else if portal == nil {
+		ce.Reply("You must either run the command in a portal, or specify an internal room ID as the first parameter")
+		return
+	}
+	if len(ce.Args) == 0 {
+		ce.Reply(selectRelayHelp)
+		return
+	}
+	log := ce.ZLog.With().Str("channel_id", portal.Key.ChannelID).Logger()
+	createType := strings.ToLower(strings.TrimLeft(ce.Args[0], "-"))
+	var webhookID int64
+	var webhookSecret string
+	switch createType {
+	case "url":
+		if len(ce.Args) < 2 {
+			ce.Reply("Usage: `$cmdprefix [room ID] --url <URL>")
+			return
+		}
+		ce.Redact()
+		_, err := fmt.Sscanf(ce.Args[1], webhookURLFormat, &webhookID, &webhookSecret)
+		if err != nil {
+			log.Warn().Str("webhook_url", ce.Args[1]).Err(err).Msg("Failed to parse provided webhook URL")
+			ce.Reply("Invalid webhook URL")
+			return
+		}
+	case "create":
+		perms, err := ce.User.Session.UserChannelPermissions(ce.User.DiscordID, portal.Key.ChannelID)
+		if err != nil {
+			log.Warn().Err(err).Msg("Failed to check user permissions")
+			ce.Reply("Failed to check if you have permission to create webhooks")
+			return
+		} else if perms&discordgo.PermissionManageWebhooks == 0 {
+			log.Debug().Int64("perms", perms).Msg("User doesn't have permissions to manage webhooks in channel")
+			ce.Reply("You don't have permission to manage webhooks in that channel")
+			return
+		}
+		name := "mautrix"
+		if len(ce.Args) > 1 {
+			name = strings.Join(ce.Args[1:], " ")
+		}
+		log.Debug().Str("webhook_name", name).Msg("Creating webhook")
+		webhook, err := ce.User.Session.WebhookCreate(portal.Key.ChannelID, name, "")
+		if err != nil {
+			log.Warn().Err(err).Msg("Failed to create webhook")
+			ce.Reply("Failed to create webhook: %v", err)
+			return
+		}
+		webhookID, _ = strconv.ParseInt(webhook.ID, 10, 64)
+		ce.Reply("Created webhook %s", webhook.Name)
+		webhookSecret = webhook.Token
+	default:
+		ce.Reply(selectRelayHelp)
+		return
+	}
+	log.Debug().Int64("webhook_id", webhookID).Msg("Setting portal relay webhook")
+	portal.RelayWebhookID = strconv.FormatInt(webhookID, 10)
+	portal.RelayWebhookSecret = webhookSecret
+	portal.Update()
+	ce.Reply("Saved webhook ID %s as portal relay webhook", portal.RelayWebhookID)
+}
+
 var cmdGuilds = &commands.FullHandler{
 	Func:    wrapCommand(fnGuilds),
 	Name:    "guilds",

+ 2 - 0
config/upgrade.go

@@ -25,6 +25,8 @@ import (
 func DoUpgrade(helper *up.Helper) {
 	bridgeconfig.Upgrader.DoUpgrade(helper)
 
+	helper.Copy(up.Str|up.Null, "homeserver", "public_address")
+
 	helper.Copy(up.Str, "bridge", "username_template")
 	helper.Copy(up.Str, "bridge", "displayname_template")
 	helper.Copy(up.Str, "bridge", "channel_name_template")

+ 14 - 9
database/portal.go

@@ -16,7 +16,7 @@ const (
 	portalSelect = `
 		SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
 		       plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
-		       encrypted, in_space, first_event_id
+		       encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
 		FROM portal
 	`
 )
@@ -121,16 +121,19 @@ type Portal struct {
 	InSpace   id.RoomID
 
 	FirstEventID id.EventID
+
+	RelayWebhookID     string
+	RelayWebhookSecret string
 }
 
 func (p *Portal) Scan(row dbutil.Scannable) *Portal {
-	var otherUserID, guildID, parentID, mxid, firstEventID sql.NullString
+	var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString
 	var chanType int32
 	var avatarURL string
 
 	err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
 		&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
-		&p.Encrypted, &p.InSpace, &firstEventID)
+		&p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
 
 	if err != nil {
 		if err != sql.ErrNoRows {
@@ -148,6 +151,8 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
 	p.Type = discordgo.ChannelType(chanType)
 	p.FirstEventID = id.EventID(firstEventID.String)
 	p.AvatarURL, _ = id.ParseContentURI(avatarURL)
+	p.RelayWebhookID = relayWebhookID.String
+	p.RelayWebhookSecret = relayWebhookSecret.String
 
 	return p
 }
@@ -156,13 +161,13 @@ func (p *Portal) Insert() {
 	query := `
 		INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
 		                    plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
-		                    encrypted, in_space, first_event_id)
-		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
+		                    encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret)
+		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
 	`
 	_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type,
 		strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
 		p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
-		p.Encrypted, p.InSpace, p.FirstEventID.String())
+		p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
 
 	if err != nil {
 		p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
@@ -175,13 +180,13 @@ func (p *Portal) Update() {
 		UPDATE portal
 		SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5,
 			plain_name=$6, name=$7, name_set=$8, topic=$9, topic_set=$10, avatar=$11, avatar_url=$12, avatar_set=$13,
-			encrypted=$14, in_space=$15, first_event_id=$16
-		WHERE dcid=$17 AND receiver=$18
+			encrypted=$14, in_space=$15, first_event_id=$16, relay_webhook_id=$17, relay_webhook_secret=$18
+		WHERE dcid=$19 AND receiver=$20
 	`
 	_, err := p.db.Exec(query,
 		p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
 		p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
-		p.Encrypted, p.InSpace, p.FirstEventID.String(),
+		p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
 		p.Key.ChannelID, p.Key.Receiver)
 
 	if err != nil {

+ 4 - 1
database/upgrades/00-latest-revision.sql

@@ -1,4 +1,4 @@
--- v0 -> v14: Latest revision
+-- v0 -> v15: Latest revision
 
 CREATE TABLE guild (
     dcid       TEXT PRIMARY KEY,
@@ -39,6 +39,9 @@ CREATE TABLE portal (
 
     first_event_id TEXT NOT NULL,
 
+    relay_webhook_id     TEXT,
+    relay_webhook_secret TEXT,
+
     PRIMARY KEY (dcid, receiver),
     CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE,
     CONSTRAINT portal_guild_fkey  FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE

+ 3 - 0
database/upgrades/15-portal-relay-webhook.sql

@@ -0,0 +1,3 @@
+-- v15: Store relay webhook URL for portals
+ALTER TABLE portal ADD COLUMN relay_webhook_id TEXT;
+ALTER TABLE portal ADD COLUMN relay_webhook_secret TEXT;

+ 3 - 0
example-config.yaml

@@ -2,6 +2,9 @@
 homeserver:
     # The address that this appservice can use to connect to the homeserver.
     address: https://matrix.example.com
+    # Publicly accessible base URL for media, used for avatars in relay mode.
+    # If not set, the connection address above will be used.
+    public_address: null
     # The domain of the homeserver (also known as server_name, used for MXIDs, etc).
     domain: example.com
 

+ 23 - 3
formatter.go

@@ -21,6 +21,7 @@ import (
 	"regexp"
 	"strings"
 
+	"github.com/bwmarrin/discordgo"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/extension"
 	"github.com/yuin/goldmark/parser"
@@ -91,6 +92,16 @@ func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLink
 }
 
 const formatterContextPortalKey = "fi.mau.discord.portal"
+const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
+
+func appendIfNotContains(arr []string, newItem string) []string {
+	for _, item := range arr {
+		if item == newItem {
+			return arr
+		}
+	}
+	return append(arr, newItem)
+}
 
 func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
 	if len(mxid) == 0 {
@@ -124,12 +135,15 @@ func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx fo
 			}
 		}
 	} else if mxid[0] == '@' {
+		mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions)
 		parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid))
 		if ok {
+			mentions.Users = appendIfNotContains(mentions.Users, parsedID)
 			return fmt.Sprintf("<@%s>", parsedID)
 		}
 		mentionedUser := br.GetUserByMXID(id.UserID(mxid))
 		if mentionedUser != nil && mentionedUser.DiscordID != "" {
+			mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
 			return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
 		}
 	}
@@ -195,12 +209,18 @@ var matrixHTMLParser = &format.HTMLParser{
 	},
 }
 
-func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) string {
+func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) {
+	allowedMentions := &discordgo.MessageAllowedMentions{
+		Parse:       []discordgo.AllowedMentionType{},
+		Users:       []string{},
+		RepliedUser: true,
+	}
 	if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
 		ctx := format.NewContext()
 		ctx.ReturnData[formatterContextPortalKey] = portal
-		return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx))
+		ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
+		return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
 	} else {
-		return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body))
+		return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
 	}
 }

+ 2 - 2
go.mod

@@ -15,7 +15,7 @@ require (
 	github.com/stretchr/testify v1.8.1
 	github.com/yuin/goldmark v1.5.4
 	maunium.net/go/maulogger/v2 v2.4.1
-	maunium.net/go/mautrix v0.15.0-beta.1.0.20230226232632-00f40652f33d
+	maunium.net/go/mautrix v0.15.0-beta.1.0.20230227211640-c8b3566fb7ba
 )
 
 require (
@@ -37,4 +37,4 @@ require (
 	maunium.net/go/mauflag v1.0.0 // indirect
 )
 
-replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230226184350-ef6bcfe94f07
+replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230227224009-daaee0136f88

+ 4 - 4
go.sum

@@ -1,6 +1,6 @@
 github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
-github.com/beeper/discordgo v0.0.0-20230226184350-ef6bcfe94f07 h1:YajAt8iJkBn4aavUuftybeXUaeN4p0DPCE3a4wxE2Oc=
-github.com/beeper/discordgo v0.0.0-20230226184350-ef6bcfe94f07/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
+github.com/beeper/discordgo v0.0.0-20230227224009-daaee0136f88 h1:sZUZP+ClkQk1uC0KB6dYJ+v6Ygao3RaPKp/3leRjYik=
+github.com/beeper/discordgo v0.0.0-20230227224009-daaee0136f88/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
 github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534 h1:rtAn27wIbmOGUs7RIbVgPEjb31ehTVniDwPGXyMxm5U=
 github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -82,5 +82,5 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
 maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
 maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
 maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
-maunium.net/go/mautrix v0.15.0-beta.1.0.20230226232632-00f40652f33d h1:16Q4co5TusYEovGLiHSkT6FY6fFn5tNNLCR3FvGCLFk=
-maunium.net/go/mautrix v0.15.0-beta.1.0.20230226232632-00f40652f33d/go.mod h1:AE3TCX9q4W7fYfrL/1RsuOell9rTUBO27XUULuwArH4=
+maunium.net/go/mautrix v0.15.0-beta.1.0.20230227211640-c8b3566fb7ba h1:OS+zjLTyeqxzMcgnbBbXlZSr0B2yfalCo2lNhC2wP5A=
+maunium.net/go/mautrix v0.15.0-beta.1.0.20230227211640-c8b3566fb7ba/go.mod h1:AE3TCX9q4W7fYfrL/1RsuOell9rTUBO27XUULuwArH4=

+ 113 - 34
portal.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
 	"reflect"
@@ -41,6 +42,8 @@ type portalMatrixMessage struct {
 	user *User
 }
 
+var relayClient, _ = discordgo.New("")
+
 type Portal struct {
 	*database.Portal
 
@@ -85,7 +88,7 @@ func (portal *Portal) MarkEncrypted() {
 }
 
 func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) {
-	if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser /*|| portal.HasRelaybot()*/ {
+	if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.RelayWebhookID != "" {
 		portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt}
 	}
 }
@@ -971,6 +974,7 @@ var (
 	errUnknownRelationType         = errors.New("unknown relation type")
 	errTargetNotFound              = errors.New("target event not found")
 	errUnknownEmoji                = errors.New("unknown emoji")
+	errCantStartThread             = errors.New("can't create thread without being logged into Discord")
 )
 
 func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) {
@@ -981,7 +985,8 @@ func errorToStatusReason(err error) (reason event.MessageStatusReason, status ev
 		errors.Is(err, errUnknownEmoji),
 		errors.Is(err, id.InvalidContentURI),
 		errors.Is(err, attachment.UnsupportedVersion),
-		errors.Is(err, attachment.UnsupportedAlgorithm):
+		errors.Is(err, attachment.UnsupportedAlgorithm),
+		errors.Is(err, errCantStartThread):
 		return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, ""
 	case errors.Is(err, attachment.HashMismatch),
 		errors.Is(err, attachment.InvalidKey),
@@ -1065,6 +1070,22 @@ func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part strin
 	}
 }
 
+func (portal *Portal) getRelayUserMeta(sender *User) (name, avatarURL string) {
+	member := portal.bridge.StateStore.GetMember(portal.MXID, sender.MXID)
+	name = member.Displayname
+	if name == "" {
+		name = sender.MXID.String()
+	}
+	mxc := member.AvatarURL.ParseOrIgnore()
+	if !mxc.IsEmpty() {
+		avatarURL = mautrix.BuildURL(
+			portal.bridge.PublicHSAddress,
+			"_matrix", "media", "v3", "download", mxc.Homeserver, mxc.FileID,
+		).String()
+	}
+	return
+}
+
 func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
 	if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver {
 		go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
@@ -1078,14 +1099,23 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
 	}
 
 	channelID := portal.Key.ChannelID
+	sess := sender.Session
 	var threadID string
 
 	if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil {
 		edits := portal.bridge.DB.Message.GetByMXID(portal.Key, editMXID)
 		if edits != nil {
-			discordContent := portal.parseMatrixHTML(content.NewContent)
-			// TODO save edit in message table
-			_, err := sender.Session.ChannelMessageEdit(edits.DiscordProtoChannelID(), edits.DiscordID, discordContent)
+			discordContent, allowedMentions := portal.parseMatrixHTML(content.NewContent)
+			var err error
+			if sess != nil {
+				// TODO save edit in message table
+				_, err = sess.ChannelMessageEdit(edits.DiscordProtoChannelID(), edits.DiscordID, discordContent)
+			} else {
+				_, err = relayClient.WebhookMessageEdit(portal.RelayWebhookID, portal.RelayWebhookSecret, edits.DiscordID, &discordgo.WebhookEdit{
+					Content:         &discordContent,
+					AllowedMentions: allowedMentions,
+				})
+			}
 			go portal.sendMessageMetrics(evt, err, "Failed to edit")
 		} else {
 			go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEditTarget, editMXID), "Ignoring")
@@ -1096,6 +1126,11 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
 		if existingThread != nil {
 			threadID = existingThread.ID
 		} else {
+			if sess == nil {
+				// TODO start thread with bot?
+				go portal.sendMessageMetrics(evt, errCantStartThread, "Dropping")
+				return
+			}
 			var err error
 			threadID, err = portal.startThreadFromMatrix(sender, threadRoot)
 			if err != nil {
@@ -1129,48 +1164,85 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
 				}
 			}
 		}
-		sendReq.Content = portal.parseMatrixHTML(content)
+		sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content)
 	case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo:
 		data, err := downloadMatrixAttachment(portal.MainIntent(), content)
 		if err != nil {
 			go portal.sendMessageMetrics(evt, err, "Error downloading media in")
 			return
 		}
-
-		att := &discordgo.MessageAttachment{
-			ID:          "0",
-			Filename:    content.Body,
-			Description: description,
-		}
-		sendReq.Attachments = []*discordgo.MessageAttachment{att}
+		filename := content.Body
 		if content.FileName != "" && content.FileName != content.Body {
-			att.Filename = content.FileName
-			sendReq.Content = portal.parseMatrixHTML(content)
-		}
-		prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{
-			Files: []*discordgo.FilePrepare{{
-				Size: len(data),
-				Name: att.Filename,
-				ID:   sender.NextDiscordUploadID(),
-			}},
-		})
-		if err != nil {
-			go portal.sendMessageMetrics(evt, err, "Error preparing to reupload media in")
-			return
+			filename = content.FileName
+			sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content)
 		}
-		prepared := prep.Attachments[0]
-		att.UploadedFilename = prepared.UploadFilename
-		err = uploadDiscordAttachment(prepared.UploadURL, data)
-		if err != nil {
-			go portal.sendMessageMetrics(evt, err, "Error reuploading media in")
-			return
+
+		if sess != nil && sess.IsUser {
+			att := &discordgo.MessageAttachment{
+				ID:          "0",
+				Filename:    filename,
+				Description: description,
+			}
+			sendReq.Attachments = []*discordgo.MessageAttachment{att}
+			prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{
+				Files: []*discordgo.FilePrepare{{
+					Size: len(data),
+					Name: att.Filename,
+					ID:   sender.NextDiscordUploadID(),
+				}},
+			})
+			if err != nil {
+				go portal.sendMessageMetrics(evt, err, "Error preparing to reupload media in")
+				return
+			}
+			prepared := prep.Attachments[0]
+			att.UploadedFilename = prepared.UploadFilename
+			err = uploadDiscordAttachment(prepared.UploadURL, data)
+			if err != nil {
+				go portal.sendMessageMetrics(evt, err, "Error reuploading media in")
+				return
+			}
+		} else {
+			sendReq.Files = []*discordgo.File{{
+				Name:        filename,
+				ContentType: content.Info.MimeType,
+				Reader:      bytes.NewReader(data),
+			}}
 		}
 	default:
 		go portal.sendMessageMetrics(evt, fmt.Errorf("%w %q", errUnknownMsgType, content.MsgType), "Ignoring")
 		return
 	}
+	if sess != nil {
+		// AllowedMentions must not be set for real users, and it's also not that useful for personal bots.
+		// It's only important for relaying, where the webhook may have higher permissions than the user on Matrix.
+		sendReq.AllowedMentions = nil
+	} else if strings.Contains(sendReq.Content, "@everyone") || strings.Contains(sendReq.Content, "@here") {
+		powerLevels, err := portal.MainIntent().PowerLevels(portal.MXID)
+		if err != nil {
+			portal.log.Warnfln("Failed to get power levels in %s to check if %s can @everyone: %v", portal.MXID, sender.MXID, err)
+		} else if powerLevels.GetUserLevel(sender.MXID) >= powerLevels.Notifications.Room() {
+			sendReq.AllowedMentions.Parse = append(sendReq.AllowedMentions.Parse, discordgo.AllowedMentionTypeEveryone)
+		}
+	}
 	sendReq.Nonce = generateNonce()
-	msg, err := sender.Session.ChannelMessageSendComplex(channelID, &sendReq)
+	var msg *discordgo.Message
+	var err error
+	if sess != nil {
+		msg, err = sess.ChannelMessageSendComplex(channelID, &sendReq)
+	} else {
+		username, avatarURL := portal.getRelayUserMeta(sender)
+		msg, err = relayClient.WebhookThreadExecute(portal.RelayWebhookID, portal.RelayWebhookSecret, true, threadID, &discordgo.WebhookParams{
+			Content:         sendReq.Content,
+			Username:        username,
+			AvatarURL:       avatarURL,
+			TTS:             sendReq.TTS,
+			Files:           sendReq.Files,
+			Components:      sendReq.Components,
+			Embeds:          sendReq.Embeds,
+			AllowedMentions: sendReq.AllowedMentions,
+		})
+	}
 	go portal.sendMessageMetrics(evt, err, "Error sending")
 	if msg != nil {
 		dbMsg := portal.bridge.DB.Message.New()
@@ -1180,7 +1252,11 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
 			dbMsg.AttachmentID = msg.Attachments[0].ID
 		}
 		dbMsg.MXID = evt.ID
-		dbMsg.SenderID = sender.DiscordID
+		if sess != nil {
+			dbMsg.SenderID = sender.DiscordID
+		} else {
+			dbMsg.SenderID = portal.RelayWebhookID
+		}
 		dbMsg.Timestamp, _ = discordgo.SnowflakeTimestamp(msg.ID)
 		dbMsg.ThreadID = threadID
 		dbMsg.Insert()
@@ -1333,6 +1409,9 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) {
 	if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver {
 		go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
 		return
+	} else if !sender.IsLoggedIn() {
+		//go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring")
+		return
 	}
 
 	reaction := evt.Content.AsReaction()