Switch startup, config, commands and db migrations to mautrix-go systems

This commit is contained in:
Tulir Asokan 2022-05-22 22:16:42 +03:00
parent cf5384d908
commit 9f9f7ca4fd
74 changed files with 3470 additions and 5682 deletions

View file

@ -1,4 +1,4 @@
package bridge
package main
import (
"bytes"
@ -15,7 +15,7 @@ import (
"maunium.net/go/mautrix/id"
)
func (p *Portal) downloadDiscordAttachment(url string) ([]byte, error) {
func (portal *Portal) downloadDiscordAttachment(url string) ([]byte, error) {
// We might want to make this save to disk in the future. Discord defaults
// to 8mb for all attachments to a messages for non-nitro users and
// non-boosted servers.
@ -42,7 +42,7 @@ func (p *Portal) downloadDiscordAttachment(url string) ([]byte, error) {
return ioutil.ReadAll(resp.Body)
}
func (p *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.MessageEventContent) ([]byte, error) {
func (portal *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.MessageEventContent) ([]byte, error) {
var file *event.EncryptedFileInfo
rawMXC := content.URL
@ -53,22 +53,22 @@ func (p *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.Mes
mxc, err := rawMXC.Parse()
if err != nil {
p.log.Errorln("Malformed content URL in %s: %v", eventID, err)
portal.log.Errorln("Malformed content URL in %s: %v", eventID, err)
return nil, err
}
data, err := p.MainIntent().DownloadBytes(mxc)
data, err := portal.MainIntent().DownloadBytes(mxc)
if err != nil {
p.log.Errorfln("Failed to download media in %s: %v", eventID, err)
portal.log.Errorfln("Failed to download media in %s: %v", eventID, err)
return nil, err
}
if file != nil {
data, err = file.Decrypt(data)
err = file.DecryptInPlace(data)
if err != nil {
p.log.Errorfln("Failed to decrypt media in %s: %v", eventID, err)
portal.log.Errorfln("Failed to decrypt media in %s: %v", eventID, err)
return nil, err
}
}
@ -76,13 +76,13 @@ func (p *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.Mes
return data, nil
}
func (p *Portal) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
func (portal *Portal) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
req := mautrix.ReqUploadMedia{
ContentBytes: data,
ContentType: content.Info.MimeType,
}
var mxc id.ContentURI
if p.bridge.Config.Homeserver.AsyncMedia {
if portal.bridge.Config.Homeserver.AsyncMedia {
uploaded, err := intent.UnstableUploadAsync(req)
if err != nil {
return err

View file

@ -1,4 +1,4 @@
package bridge
package main
import (
"fmt"

View file

@ -1,42 +0,0 @@
package bridge
import (
"maunium.net/go/mautrix/id"
)
func (b *Bridge) updateBotProfile() {
cfg := b.Config.Appservice.Bot
// Set the bot's avatar.
if cfg.Avatar != "" {
var err error
var mxc id.ContentURI
if cfg.Avatar == "remove" {
err = b.bot.SetAvatarURL(mxc)
} else {
mxc, err = id.ParseContentURI(cfg.Avatar)
if err == nil {
err = b.bot.SetAvatarURL(mxc)
}
}
if err != nil {
b.log.Warnln("failed to update the bot's avatar: ", err)
}
}
// Update the bot's display name.
if cfg.Displayname != "" {
var err error
if cfg.Displayname == "remove" {
err = b.bot.SetDisplayName("")
} else {
err = b.bot.SetDisplayName(cfg.Displayname)
}
if err != nil {
b.log.Warnln("failed to update the bot's display name", err)
}
}
}

View file

@ -1,203 +0,0 @@
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"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/version"
)
const (
reconnectDelay = 10 * time.Second
)
type Bridge struct {
Config *config.Config
log log.Logger
as *appservice.AppService
db *database.Database
eventProcessor *appservice.EventProcessor
matrixHandler *matrixHandler
bot *appservice.IntentAPI
provisioning *ProvisioningAPI
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
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
StateStore *database.SQLStateStore
crypto Crypto
}
func New(cfg *config.Config) (*Bridge, error) {
// Create the logger.
logger, err := cfg.CreateLogger()
if err != nil {
return nil, err
}
logger.Infoln("Initializing version", version.String)
// Create and initialize the app service.
appservice, err := cfg.CreateAppService()
if err != nil {
return nil, err
}
appservice.Log = log.Sub("matrix")
appservice.Init()
// Create the bot.
bot := appservice.BotIntent()
// Setup the database.
db, err := cfg.CreateDatabase(logger)
if err != nil {
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,
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),
puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
StateStore: stateStore,
}
bridge.crypto = NewCryptoHelper(bridge)
if cfg.Appservice.Provisioning.Enabled() {
bridge.provisioning = newProvisioningAPI(bridge)
}
// Setup the event processors
bridge.setupEvents()
return bridge, nil
}
func (b *Bridge) connect() error {
b.log.Debugln("Checking connection to homeserver")
for {
resp, err := b.bot.Whoami()
if err != nil {
if errors.Is(err, mautrix.MUnknownToken) {
b.log.Fatalln("Access token invalid. Is the registration installed in your homeserver correctly?")
return fmt.Errorf("invalid access token")
}
b.log.Errorfln("Failed to connect to homeserver : %v", err)
b.log.Errorfln("reconnecting in %s", reconnectDelay)
time.Sleep(reconnectDelay)
} else if resp.UserID != b.bot.UserID {
b.log.Fatalln("Unexpected user ID in whoami call: got %s, expected %s", resp.UserID, b.bot.UserID)
return fmt.Errorf("expected user id %q but got %q", b.bot.UserID, resp.UserID)
} else {
break
}
}
b.log.Debugln("Connected to homeserver")
return nil
}
func (b *Bridge) Start() error {
b.log.Infoln("Bridge started")
if err := b.connect(); err != nil {
return err
}
if b.crypto != nil {
if err := b.crypto.Init(); err != nil {
b.log.Fatalln("Error initializing end-to-bridge encryption:", err)
return err
}
}
b.log.Debugln("Starting application service HTTP server")
go b.as.Start()
b.log.Debugln("Starting event processor")
go b.eventProcessor.Start()
go b.updateBotProfile()
if b.crypto != nil {
go b.crypto.Start()
}
go b.startUsers()
// Finally tell the appservice we're ready
b.as.Ready = true
return nil
}
func (b *Bridge) Stop() {
if b.crypto != nil {
b.crypto.Stop()
}
b.as.Stop()
b.eventProcessor.Stop()
for _, user := range b.usersByMXID {
if user.Session == nil {
continue
}
b.log.Debugln("Disconnecting", user.MXID)
user.Session.Close()
}
b.log.Infoln("Bridge stopped")
}

View file

@ -1,117 +0,0 @@
package bridge
import (
"fmt"
"strings"
"github.com/alecthomas/kong"
"github.com/google/shlex"
"maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
type commandHandler struct {
bridge *Bridge
log maulogger.Logger
}
func newCommandHandler(bridge *Bridge) *commandHandler {
return &commandHandler{
bridge: bridge,
log: bridge.log.Sub("Commands"),
}
}
func commandsHelpPrinter(options kong.HelpOptions, ctx *kong.Context) error {
selected := ctx.Selected()
if selected == nil {
for _, cmd := range ctx.Model.Leaves(true) {
fmt.Fprintf(ctx.Stdout, " * %s - %s\n", cmd.Path(), cmd.Help)
}
} else {
fmt.Fprintf(ctx.Stdout, "%s - %s\n", selected.Path(), selected.Help)
if selected.Detail != "" {
fmt.Fprintf(ctx.Stdout, "\n%s\n", selected.Detail)
}
if len(selected.Positional) > 0 {
fmt.Fprintf(ctx.Stdout, "\nArguments:\n")
for _, arg := range selected.Positional {
fmt.Fprintf(ctx.Stdout, "%s %s\n", arg.Summary(), arg.Help)
}
}
}
return nil
}
func (h *commandHandler) handle(roomID id.RoomID, user *User, message string, replyTo id.EventID) {
cmd := commands{
globals: globals{
bot: h.bridge.bot,
bridge: h.bridge,
portal: h.bridge.GetPortalByMXID(roomID),
handler: h,
roomID: roomID,
user: user,
replyTo: replyTo,
},
}
buf := &strings.Builder{}
parse, err := kong.New(
&cmd,
kong.Exit(func(int) {}),
kong.NoDefaultHelp(),
kong.Writers(buf, buf),
kong.Help(commandsHelpPrinter),
)
if err != nil {
h.log.Warnf("Failed to create argument parser for %q: %v", roomID, err)
cmd.globals.reply("unexpected error, please try again shortly")
return
}
args, err := shlex.Split(message)
if err != nil {
h.log.Warnf("Failed to split message %q: %v", message, err)
cmd.globals.reply("failed to process the command")
return
}
ctx, err := parse.Parse(args)
if err != nil {
h.log.Warnf("Failed to parse command %q: %v", message, err)
cmd.globals.reply(fmt.Sprintf("failed to process the command: %v", err))
return
}
cmd.globals.context = ctx
err = ctx.Run(&cmd.globals)
if err != nil {
h.log.Warnf("Command %q failed: %v", message, err)
output := buf.String()
if output != "" {
cmd.globals.reply(output)
} else {
cmd.globals.reply("unexpected failure")
}
return
}
if buf.Len() > 0 {
cmd.globals.reply(buf.String())
}
}

View file

@ -1,360 +0,0 @@
package bridge
import (
"context"
"fmt"
"github.com/alecthomas/kong"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/consts"
"go.mau.fi/mautrix-discord/remoteauth"
"go.mau.fi/mautrix-discord/version"
)
type globals struct {
context *kong.Context
bridge *Bridge
bot *appservice.IntentAPI
portal *Portal
handler *commandHandler
roomID id.RoomID
user *User
replyTo id.EventID
}
func (g *globals) reply(msg string) {
content := format.RenderMarkdown(msg, true, false)
content.MsgType = event.MsgNotice
intent := g.bot
if g.portal != nil && g.portal.IsPrivateChat() {
intent = g.portal.MainIntent()
}
_, err := intent.SendMessageEvent(g.roomID, event.EventMessage, content)
if err != nil {
g.handler.log.Warnfln("Failed to reply to command from %q: %v", g.user.MXID, err)
}
}
type commands struct {
globals
Disconnect disconnectCmd `kong:"cmd,help='Disconnect from Discord'"`
Help helpCmd `kong:"cmd,help='Displays this message.'"`
Login loginCmd `kong:"cmd,help='Log in to Discord.'"`
Logout logoutCmd `kong:"cmd,help='Log out of Discord.'"`
Reconnect reconnectCmd `kong:"cmd,help='Reconnect to Discord'"`
Version versionCmd `kong:"cmd,help='Displays the version of the bridge.'"`
Guilds guildsCmd `kong:"cmd,help='Guild bridging management.'"`
LoginMatrix loginMatrixCmd `kong:"cmd,help='Replace the puppet for your Discord account with your real Matrix account.'"`
LogoutMatrix logoutMatrixCmd `kong:"cmd,help='Switch the puppet for your Discord account back to the default one.'"`
PingMatrix pingMatrixCmd `kong:"cmd,help='check if your double puppet is working properly'"`
}
///////////////////////////////////////////////////////////////////////////////
// Help Command
///////////////////////////////////////////////////////////////////////////////
type helpCmd struct {
Command []string `kong:"arg,optional,help='The command to get help on.'"`
}
func (c *helpCmd) Run(g *globals) error {
ctx, err := kong.Trace(g.context.Kong, c.Command)
if err != nil {
return err
}
if ctx.Error != nil {
return err
}
err = ctx.PrintUsage(true)
if err != nil {
return err
}
fmt.Fprintln(g.context.Stdout)
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Version Command
///////////////////////////////////////////////////////////////////////////////
type versionCmd struct{}
func (c *versionCmd) Run(g *globals) error {
fmt.Fprintln(g.context.Stdout, consts.Name, version.String)
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Login Command
///////////////////////////////////////////////////////////////////////////////
type loginCmd struct{}
func (l *loginCmd) Run(g *globals) error {
if g.user.LoggedIn() {
fmt.Fprintf(g.context.Stdout, "You are already logged in")
return fmt.Errorf("user already logged in")
}
client, err := remoteauth.New()
if err != nil {
return err
}
qrChan := make(chan string)
doneChan := make(chan struct{})
var qrCodeEvent id.EventID
go func() {
code := <-qrChan
resp, err := g.user.sendQRCode(g.bot, g.roomID, code)
if err != nil {
fmt.Fprintln(g.context.Stdout, "Failed to generate the qrcode")
return
}
qrCodeEvent = resp
}()
ctx := context.Background()
if err := client.Dial(ctx, qrChan, doneChan); err != nil {
close(qrChan)
close(doneChan)
return err
}
<-doneChan
if qrCodeEvent != "" {
_, err := g.bot.RedactEvent(g.roomID, qrCodeEvent)
if err != nil {
fmt.Errorf("Failed to redact the qrcode: %v", err)
}
}
user, err := client.Result()
if err != nil {
fmt.Fprintln(g.context.Stdout, "Failed to log in")
return err
}
if err := g.user.Login(user.Token); err != nil {
fmt.Fprintln(g.context.Stdout, "Failed to login", err)
return err
}
g.user.Lock()
g.user.ID = user.UserID
g.user.Update()
g.user.Unlock()
fmt.Fprintln(g.context.Stdout, "Successfully logged in")
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Logout Command
///////////////////////////////////////////////////////////////////////////////
type logoutCmd struct{}
func (l *logoutCmd) Run(g *globals) error {
if !g.user.LoggedIn() {
fmt.Fprintln(g.context.Stdout, "You are not logged in")
return fmt.Errorf("user is not logged in")
}
err := g.user.Logout()
if err != nil {
fmt.Fprintln(g.context.Stdout, "Failed to log out")
return err
}
fmt.Fprintln(g.context.Stdout, "Successfully logged out")
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Disconnect Command
///////////////////////////////////////////////////////////////////////////////
type disconnectCmd struct{}
func (d *disconnectCmd) Run(g *globals) error {
if !g.user.Connected() {
fmt.Fprintln(g.context.Stdout, "You are not connected")
return fmt.Errorf("user is not connected")
}
if err := g.user.Disconnect(); err != nil {
fmt.Fprintln(g.context.Stdout, "Failed to disconnect")
return err
}
fmt.Fprintln(g.context.Stdout, "Successfully disconnected")
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Reconnect Command
///////////////////////////////////////////////////////////////////////////////
type reconnectCmd struct{}
func (r *reconnectCmd) Run(g *globals) error {
if g.user.Connected() {
fmt.Fprintln(g.context.Stdout, "You are already connected")
return fmt.Errorf("user is already connected")
}
if err := g.user.Connect(); err != nil {
fmt.Fprintln(g.context.Stdout, "Failed to connect")
return err
}
fmt.Fprintln(g.context.Stdout, "Successfully connected")
return nil
}
///////////////////////////////////////////////////////////////////////////////
// LoginMatrix Command
///////////////////////////////////////////////////////////////////////////////
type loginMatrixCmd struct {
AccessToken string `kong:"arg,help='The shared secret to use the bridge'"`
}
func (m *loginMatrixCmd) Run(g *globals) error {
puppet := g.bridge.GetPuppetByID(g.user.ID)
err := puppet.SwitchCustomMXID(m.AccessToken, g.user.MXID)
if err != nil {
fmt.Fprintf(g.context.Stdout, "Failed to switch puppet: %v", err)
return err
}
fmt.Fprintf(g.context.Stdout, "Successfully switched puppet")
return nil
}
///////////////////////////////////////////////////////////////////////////////
// LogoutMatrix Command
///////////////////////////////////////////////////////////////////////////////
type logoutMatrixCmd struct{}
func (m *logoutMatrixCmd) Run(g *globals) error {
return nil
}
///////////////////////////////////////////////////////////////////////////////
// PingMatrix Command
///////////////////////////////////////////////////////////////////////////////
type pingMatrixCmd struct{}
func (m *pingMatrixCmd) Run(g *globals) error {
puppet := g.bridge.GetPuppetByCustomMXID(g.user.MXID)
if puppet == nil || puppet.CustomIntent() == nil {
fmt.Fprintf(g.context.Stdout, "You have not changed your Discord account's Matrix puppet.")
return fmt.Errorf("double puppet not configured")
}
resp, err := puppet.CustomIntent().Whoami()
if err != nil {
fmt.Fprintf(g.context.Stdout, "Failed to validate Matrix login: %v", err)
return err
}
fmt.Fprintf(g.context.Stdout, "Confirmed valid access token for %s / %s", resp.UserID, resp.DeviceID)
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Guilds Commands
///////////////////////////////////////////////////////////////////////////////
type guildsCmd struct {
Status guildStatusCmd `kong:"cmd,help='Show the bridge status for the guilds you are in'"`
Bridge guildBridgeCmd `kong:"cmd,help='Bridge a guild'"`
Unbridge guildUnbridgeCmd `kong:"cmd,help='Unbridge a guild'"`
}
type guildStatusCmd struct{}
func (c *guildStatusCmd) Run(g *globals) error {
g.user.guildsLock.Lock()
defer g.user.guildsLock.Unlock()
if len(g.user.guilds) == 0 {
fmt.Fprintf(g.context.Stdout, "you haven't joined any guilds.")
} else {
for _, guild := range g.user.guilds {
status := "not bridged"
if guild.Bridge {
status = "bridged"
}
fmt.Fprintf(g.context.Stdout, "%s %s %s\n", guild.GuildName, guild.GuildID, status)
}
}
return nil
}
type guildBridgeCmd struct {
GuildID string `kong:"arg,help='the id of the guild to unbridge'"`
Entire bool `kong:"flag,help='whether or not to bridge all channels'"`
}
func (c *guildBridgeCmd) Run(g *globals) error {
if err := g.user.bridgeGuild(c.GuildID, c.Entire); err != nil {
return err
}
fmt.Fprintf(g.context.Stdout, "Successfully bridged guild %s", c.GuildID)
return nil
}
type guildUnbridgeCmd struct {
GuildID string `kong:"arg,help='the id of the guild to unbridge'"`
}
func (c *guildUnbridgeCmd) Run(g *globals) error {
if err := g.user.unbridgeGuild(c.GuildID); err != nil {
return err
}
fmt.Fprintf(g.context.Stdout, "Successfully unbridged guild %s", c.GuildID)
return nil
}

View file

@ -1,339 +0,0 @@
package bridge
import (
"fmt"
"runtime/debug"
"time"
"maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
var NoSessionFound = crypto.NoSessionFound
var levelTrace = maulogger.Level{
Name: "TRACE",
Severity: -10,
Color: -1,
}
type Crypto interface {
HandleMemberEvent(*event.Event)
Decrypt(*event.Event) (*event.Event, error)
Encrypt(id.RoomID, event.Type, event.Content) (*event.EncryptedEventContent, error)
WaitForSession(id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool
RequestSession(id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID)
ResetSession(id.RoomID)
Init() error
Start()
Stop()
}
type CryptoHelper struct {
bridge *Bridge
client *mautrix.Client
mach *crypto.OlmMachine
store *database.SQLCryptoStore
log maulogger.Logger
baseLog maulogger.Logger
}
func NewCryptoHelper(bridge *Bridge) Crypto {
if !bridge.Config.Bridge.Encryption.Allow {
bridge.log.Debugln("Bridge built with end-to-bridge encryption, but disabled in config")
return nil
}
baseLog := bridge.log.Sub("Crypto")
return &CryptoHelper{
bridge: bridge,
log: baseLog.Sub("Helper"),
baseLog: baseLog,
}
}
func (helper *CryptoHelper) Init() error {
helper.log.Debugln("Initializing end-to-bridge encryption...")
helper.store = database.NewSQLCryptoStore(helper.bridge.db, helper.bridge.as.BotMXID(),
fmt.Sprintf("@%s:%s", helper.bridge.Config.Bridge.FormatUsername("%"), helper.bridge.as.HomeserverDomain))
var err error
helper.client, err = helper.loginBot()
if err != nil {
return err
}
helper.log.Debugln("Logged in as bridge bot with device ID", helper.client.DeviceID)
logger := &cryptoLogger{helper.baseLog}
stateStore := &cryptoStateStore{helper.bridge}
helper.mach = crypto.NewOlmMachine(helper.client, logger, helper.store, stateStore)
helper.mach.AllowKeyShare = helper.allowKeyShare
helper.client.Syncer = &cryptoSyncer{helper.mach}
helper.client.Store = &cryptoClientStore{helper.store}
return helper.mach.Load()
}
func (helper *CryptoHelper) allowKeyShare(device *crypto.DeviceIdentity, info event.RequestedKeyInfo) *crypto.KeyShareRejection {
cfg := helper.bridge.Config.Bridge.Encryption.KeySharing
if !cfg.Allow {
return &crypto.KeyShareRejectNoResponse
} else if device.Trust == crypto.TrustStateBlacklisted {
return &crypto.KeyShareRejectBlacklisted
} else if device.Trust == crypto.TrustStateVerified || !cfg.RequireVerification {
portal := helper.bridge.GetPortalByMXID(info.RoomID)
if portal == nil {
helper.log.Debugfln("Rejecting key request for %s from %s/%s: room is not a portal", info.SessionID, device.UserID, device.DeviceID)
return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"}
}
user := helper.bridge.GetUserByMXID(device.UserID)
// FIXME reimplement IsInPortal
if !user.Admin /*&& !user.IsInPortal(portal.Key)*/ {
helper.log.Debugfln("Rejecting key request for %s from %s/%s: user is not in portal", info.SessionID, device.UserID, device.DeviceID)
return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "You're not in that portal"}
}
helper.log.Debugfln("Accepting key request for %s from %s/%s", info.SessionID, device.UserID, device.DeviceID)
return nil
}
return &crypto.KeyShareRejectUnverified
}
func (helper *CryptoHelper) loginBot() (*mautrix.Client, error) {
deviceID := helper.store.FindDeviceID()
if len(deviceID) > 0 {
helper.log.Debugln("Found existing device ID for bot in database:", deviceID)
}
client, err := mautrix.NewClient(helper.bridge.as.HomeserverURL, "", "")
if err != nil {
return nil, fmt.Errorf("failed to initialize client: %w", err)
}
client.Logger = helper.baseLog.Sub("Bot")
client.Client = helper.bridge.as.HTTPClient
client.DefaultHTTPRetries = helper.bridge.as.DefaultHTTPRetries
flows, err := client.GetLoginFlows()
if err != nil {
return nil, fmt.Errorf("failed to get supported login flows: %w", err)
}
flow := flows.FirstFlowOfType(mautrix.AuthTypeAppservice, mautrix.AuthTypeHalfyAppservice)
if flow == nil {
return nil, fmt.Errorf("homeserver does not support appservice login")
}
// We set the API token to the AS token here to authenticate the appservice login
// It'll get overridden after the login
client.AccessToken = helper.bridge.as.Registration.AppToken
resp, err := client.Login(&mautrix.ReqLogin{
Type: flow.Type,
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(helper.bridge.as.BotMXID())},
DeviceID: deviceID,
InitialDeviceDisplayName: "Discord Bridge",
StoreCredentials: true,
})
if err != nil {
return nil, fmt.Errorf("failed to log in as bridge bot: %w", err)
}
helper.store.DeviceID = resp.DeviceID
return client, nil
}
func (helper *CryptoHelper) Start() {
helper.log.Debugln("Starting syncer for receiving to-device messages")
err := helper.client.Sync()
if err != nil {
helper.log.Errorln("Fatal error syncing:", err)
} else {
helper.log.Infoln("Bridge bot to-device syncer stopped without error")
}
}
func (helper *CryptoHelper) Stop() {
helper.log.Debugln("CryptoHelper.Stop() called, stopping bridge bot sync")
helper.client.StopSync()
}
func (helper *CryptoHelper) Decrypt(evt *event.Event) (*event.Event, error) {
return helper.mach.DecryptMegolmEvent(evt)
}
func (helper *CryptoHelper) Encrypt(roomID id.RoomID, evtType event.Type, content event.Content) (*event.EncryptedEventContent, error) {
encrypted, err := helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
if err != nil {
if err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession {
return nil, err
}
helper.log.Debugfln("Got %v while encrypting event for %s, sharing group session and trying again...", err, roomID)
users, err := helper.store.GetRoomMembers(roomID)
if err != nil {
return nil, fmt.Errorf("failed to get room member list: %w", err)
}
err = helper.mach.ShareGroupSession(roomID, users)
if err != nil {
return nil, fmt.Errorf("failed to share group session: %w", err)
}
encrypted, err = helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
if err != nil {
return nil, fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err)
}
}
return encrypted, nil
}
func (helper *CryptoHelper) WaitForSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
return helper.mach.WaitForSession(roomID, senderKey, sessionID, timeout)
}
func (helper *CryptoHelper) RequestSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
err := helper.mach.SendRoomKeyRequest(roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}})
if err != nil {
helper.log.Warnfln("Failed to send key request to %s/%s for %s in %s: %v", userID, deviceID, sessionID, roomID, err)
} else {
helper.log.Debugfln("Sent key request to %s/%s for %s in %s", userID, deviceID, sessionID, roomID)
}
}
func (helper *CryptoHelper) ResetSession(roomID id.RoomID) {
err := helper.mach.CryptoStore.RemoveOutboundGroupSession(roomID)
if err != nil {
helper.log.Debugfln("Error manually removing outbound group session in %s: %v", roomID, err)
}
}
func (helper *CryptoHelper) HandleMemberEvent(evt *event.Event) {
helper.mach.HandleMemberEvent(evt)
}
type cryptoSyncer struct {
*crypto.OlmMachine
}
func (syncer *cryptoSyncer) ProcessResponse(resp *mautrix.RespSync, since string) error {
done := make(chan struct{})
go func() {
defer func() {
if err := recover(); err != nil {
syncer.Log.Error("Processing sync response (%s) panicked: %v\n%s", since, err, debug.Stack())
}
done <- struct{}{}
}()
syncer.Log.Trace("Starting sync response handling (%s)", since)
syncer.ProcessSyncResponse(resp, since)
syncer.Log.Trace("Successfully handled sync response (%s)", since)
}()
select {
case <-done:
case <-time.After(30 * time.Second):
syncer.Log.Warn("Handling sync response (%s) is taking unusually long", since)
}
return nil
}
func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
syncer.Log.Error("Error /syncing, waiting 10 seconds: %v", err)
return 10 * time.Second, nil
}
func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
everything := []event.Type{{Type: "*"}}
return &mautrix.Filter{
Presence: mautrix.FilterPart{NotTypes: everything},
AccountData: mautrix.FilterPart{NotTypes: everything},
Room: mautrix.RoomFilter{
IncludeLeave: false,
Ephemeral: mautrix.FilterPart{NotTypes: everything},
AccountData: mautrix.FilterPart{NotTypes: everything},
State: mautrix.FilterPart{NotTypes: everything},
Timeline: mautrix.FilterPart{NotTypes: everything},
},
}
}
type cryptoLogger struct {
int maulogger.Logger
}
func (c *cryptoLogger) Error(message string, args ...interface{}) {
c.int.Errorfln(message, args...)
}
func (c *cryptoLogger) Warn(message string, args ...interface{}) {
c.int.Warnfln(message, args...)
}
func (c *cryptoLogger) Debug(message string, args ...interface{}) {
c.int.Debugfln(message, args...)
}
func (c *cryptoLogger) Trace(message string, args ...interface{}) {
c.int.Logfln(levelTrace, message, args...)
}
type cryptoClientStore struct {
int *database.SQLCryptoStore
}
func (c cryptoClientStore) SaveFilterID(_ id.UserID, _ string) {}
func (c cryptoClientStore) LoadFilterID(_ id.UserID) string { return "" }
func (c cryptoClientStore) SaveRoom(_ *mautrix.Room) {}
func (c cryptoClientStore) LoadRoom(_ id.RoomID) *mautrix.Room { return nil }
func (c cryptoClientStore) SaveNextBatch(_ id.UserID, nextBatchToken string) {
c.int.PutNextBatch(nextBatchToken)
}
func (c cryptoClientStore) LoadNextBatch(_ id.UserID) string {
return c.int.GetNextBatch()
}
var _ mautrix.Storer = (*cryptoClientStore)(nil)
type cryptoStateStore struct {
bridge *Bridge
}
var _ crypto.StateStore = (*cryptoStateStore)(nil)
func (c *cryptoStateStore) IsEncrypted(id id.RoomID) bool {
portal := c.bridge.GetPortalByMXID(id)
if portal != nil {
return portal.Encrypted
}
return false
}
func (c *cryptoStateStore) FindSharedRooms(id id.UserID) []id.RoomID {
return c.bridge.StateStore.FindSharedRooms(id)
}
func (c *cryptoStateStore) GetEncryptionEvent(id.RoomID) *event.EncryptionEventContent {
// TODO implement
return nil
}

View file

@ -1,337 +0,0 @@
package bridge
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
var (
ErrNoCustomMXID = errors.New("no custom mxid set")
ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
)
///////////////////////////////////////////////////////////////////////////////
// additional bridge api
///////////////////////////////////////////////////////////////////////////////
func (b *Bridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) {
_, homeserver, err := mxid.Parse()
if err != nil {
return nil, err
}
homeserverURL, found := b.Config.Bridge.DoublePuppetServerMap[homeserver]
if !found {
if homeserver == b.as.HomeserverDomain {
homeserverURL = b.as.HomeserverURL
} else if b.Config.Bridge.DoublePuppetAllowDiscovery {
resp, err := mautrix.DiscoverClientAPI(homeserver)
if err != nil {
return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err)
}
homeserverURL = resp.Homeserver.BaseURL
b.log.Debugfln("Discovered URL %s for %s to enable double puppeting for %s", homeserverURL, homeserver, mxid)
} else {
return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
}
}
client, err := mautrix.NewClient(homeserverURL, mxid, accessToken)
if err != nil {
return nil, err
}
client.Logger = b.as.Log.Sub(mxid.String())
client.Client = b.as.HTTPClient
client.DefaultHTTPRetries = b.as.DefaultHTTPRetries
return client, nil
}
///////////////////////////////////////////////////////////////////////////////
// mautrix.Syncer implementation
///////////////////////////////////////////////////////////////////////////////
func (p *Puppet) GetFilterJSON(_ id.UserID) *mautrix.Filter {
everything := []event.Type{{Type: "*"}}
return &mautrix.Filter{
Presence: mautrix.FilterPart{
Senders: []id.UserID{p.CustomMXID},
Types: []event.Type{event.EphemeralEventPresence},
},
AccountData: mautrix.FilterPart{NotTypes: everything},
Room: mautrix.RoomFilter{
Ephemeral: mautrix.FilterPart{Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}},
IncludeLeave: false,
AccountData: mautrix.FilterPart{NotTypes: everything},
State: mautrix.FilterPart{NotTypes: everything},
Timeline: mautrix.FilterPart{NotTypes: everything},
},
}
}
func (p *Puppet) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
p.log.Warnln("Sync error:", err)
if errors.Is(err, mautrix.MUnknownToken) {
if !p.tryRelogin(err, "syncing") {
return 0, err
}
p.customIntent.AccessToken = p.AccessToken
return 0, nil
}
return 10 * time.Second, nil
}
func (p *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error {
if !p.customUser.LoggedIn() {
p.log.Debugln("Skipping sync processing: custom user not connected to discord")
return nil
}
// for roomID, events := range resp.Rooms.Join {
// for _, evt := range events.Ephemeral.Events {
// evt.RoomID = roomID
// err := evt.Content.ParseRaw(evt.Type)
// if err != nil {
// continue
// }
// switch evt.Type {
// case event.EphemeralEventReceipt:
// if p.EnableReceipts {
// go p.bridge.matrixHandler.HandleReceipt(evt)
// }
// case event.EphemeralEventTyping:
// go p.bridge.matrixHandler.HandleTyping(evt)
// }
// }
// }
// if p.EnablePresence {
// for _, evt := range resp.Presence.Events {
// if evt.Sender != p.CustomMXID {
// continue
// }
// err := evt.Content.ParseRaw(evt.Type)
// if err != nil {
// continue
// }
// go p.bridge.matrixHandler.HandlePresence(evt)
// }
// }
return nil
}
///////////////////////////////////////////////////////////////////////////////
// mautrix.Storer implementation
///////////////////////////////////////////////////////////////////////////////
func (p *Puppet) SaveFilterID(_ id.UserID, _ string) {
}
func (p *Puppet) SaveNextBatch(_ id.UserID, nbt string) {
p.NextBatch = nbt
p.Update()
}
func (p *Puppet) SaveRoom(_ *mautrix.Room) {
}
func (p *Puppet) LoadFilterID(_ id.UserID) string {
return ""
}
func (p *Puppet) LoadNextBatch(_ id.UserID) string {
return p.NextBatch
}
func (p *Puppet) LoadRoom(_ id.RoomID) *mautrix.Room {
return nil
}
///////////////////////////////////////////////////////////////////////////////
// additional puppet api
///////////////////////////////////////////////////////////////////////////////
func (p *Puppet) clearCustomMXID() {
p.CustomMXID = ""
p.AccessToken = ""
p.customIntent = nil
p.customUser = nil
}
func (p *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
if p.CustomMXID == "" {
return nil, ErrNoCustomMXID
}
client, err := p.bridge.newDoublePuppetClient(p.CustomMXID, p.AccessToken)
if err != nil {
return nil, err
}
client.Syncer = p
client.Store = p
ia := p.bridge.as.NewIntentAPI("custom")
ia.Client = client
ia.Localpart, _, _ = p.CustomMXID.Parse()
ia.UserID = p.CustomMXID
ia.IsCustomPuppet = true
return ia, nil
}
func (p *Puppet) StartCustomMXID(reloginOnFail bool) error {
if p.CustomMXID == "" {
p.clearCustomMXID()
return nil
}
intent, err := p.newCustomIntent()
if err != nil {
p.clearCustomMXID()
return err
}
resp, err := intent.Whoami()
if err != nil {
if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !p.tryRelogin(err, "initializing double puppeting")) {
p.clearCustomMXID()
return err
}
intent.AccessToken = p.AccessToken
} else if resp.UserID != p.CustomMXID {
p.clearCustomMXID()
return ErrMismatchingMXID
}
p.customIntent = intent
p.customUser = p.bridge.GetUserByMXID(p.CustomMXID)
p.startSyncing()
return nil
}
func (p *Puppet) tryRelogin(cause error, action string) bool {
if !p.bridge.Config.CanAutoDoublePuppet(p.CustomMXID) {
return false
}
p.log.Debugfln("Trying to relogin after '%v' while %s", cause, action)
accessToken, err := p.loginWithSharedSecret(p.CustomMXID)
if err != nil {
p.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err)
return false
}
p.log.Infofln("Successfully relogined after '%v' while %s", cause, action)
p.AccessToken = accessToken
return true
}
func (p *Puppet) startSyncing() {
if !p.bridge.Config.Bridge.SyncWithCustomPuppets {
return
}
go func() {
p.log.Debugln("Starting syncing...")
p.customIntent.SyncPresence = "offline"
err := p.customIntent.Sync()
if err != nil {
p.log.Errorln("Fatal error syncing:", err)
}
}()
}
func (p *Puppet) stopSyncing() {
if !p.bridge.Config.Bridge.SyncWithCustomPuppets {
return
}
p.customIntent.StopSync()
}
func (p *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
_, homeserver, _ := mxid.Parse()
p.log.Debugfln("Logging into %s with shared secret", mxid)
mac := hmac.New(sha512.New, []byte(p.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]))
mac.Write([]byte(mxid))
client, err := p.bridge.newDoublePuppetClient(mxid, "")
if err != nil {
return "", fmt.Errorf("failed to create mautrix client to log in: %v", err)
}
resp, err := client.Login(&mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)},
Password: hex.EncodeToString(mac.Sum(nil)),
DeviceID: "Discord Bridge",
InitialDeviceDisplayName: "Discord Bridge",
})
if err != nil {
return "", err
}
return resp.AccessToken, nil
}
func (p *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
prevCustomMXID := p.CustomMXID
if p.customIntent != nil {
p.stopSyncing()
}
p.CustomMXID = mxid
p.AccessToken = accessToken
err := p.StartCustomMXID(false)
if err != nil {
return err
}
if prevCustomMXID != "" {
delete(p.bridge.puppetsByCustomMXID, prevCustomMXID)
}
if p.CustomMXID != "" {
p.bridge.puppetsByCustomMXID[p.CustomMXID] = p
}
p.EnablePresence = p.bridge.Config.Bridge.DefaultBridgePresence
p.EnableReceipts = p.bridge.Config.Bridge.DefaultBridgeReceipts
p.bridge.as.StateStore.MarkRegistered(p.CustomMXID)
p.Update()
// TODO leave rooms with default puppet
return nil
}

View file

@ -1,376 +0,0 @@
package bridge
import (
"errors"
"fmt"
"strings"
"time"
"maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
type matrixHandler struct {
as *appservice.AppService
bridge *Bridge
log maulogger.Logger
cmd *commandHandler
}
func (b *Bridge) setupEvents() {
b.eventProcessor = appservice.NewEventProcessor(b.as)
b.matrixHandler = &matrixHandler{
as: b.as,
bridge: b,
log: b.log.Sub("Matrix"),
cmd: newCommandHandler(b),
}
b.eventProcessor.On(event.EventMessage, b.matrixHandler.handleMessage)
b.eventProcessor.On(event.EventEncrypted, b.matrixHandler.handleEncrypted)
b.eventProcessor.On(event.EventReaction, b.matrixHandler.handleReaction)
b.eventProcessor.On(event.EventRedaction, b.matrixHandler.handleRedaction)
b.eventProcessor.On(event.StateMember, b.matrixHandler.handleMembership)
b.eventProcessor.On(event.StateEncryption, b.matrixHandler.handleEncryption)
}
func (mh *matrixHandler) join(evt *event.Event, intent *appservice.IntentAPI) *mautrix.RespJoinedMembers {
resp, err := intent.JoinRoomByID(evt.RoomID)
if err != nil {
mh.log.Debugfln("Failed to join room %s as %s with invite from %s: %v", evt.RoomID, intent.UserID, evt.Sender, err)
return nil
}
members, err := intent.JoinedMembers(resp.RoomID)
if err != nil {
intent.LeaveRoom(resp.RoomID)
mh.log.Debugfln("Failed to get members in room %s after accepting invite from %s as %s: %v", resp.RoomID, evt.Sender, intent.UserID, err)
return nil
}
if len(members.Joined) < 2 {
intent.LeaveRoom(resp.RoomID)
mh.log.Debugln("Leaving empty room", resp.RoomID, "after accepting invite from", evt.Sender, "as", intent.UserID)
return nil
}
return members
}
func (mh *matrixHandler) ignoreEvent(evt *event.Event) bool {
return false
}
func (mh *matrixHandler) handleMessage(evt *event.Event) {
if mh.ignoreEvent(evt) {
return
}
user := mh.bridge.GetUserByMXID(evt.Sender)
if user == nil {
mh.log.Debugln("unknown user", evt.Sender)
return
}
content := evt.Content.AsMessage()
content.RemoveReplyFallback()
if content.MsgType == event.MsgText {
prefix := mh.bridge.Config.Bridge.CommandPrefix
hasPrefix := strings.HasPrefix(content.Body, prefix)
if hasPrefix {
content.Body = strings.TrimLeft(content.Body[len(prefix):], " ")
}
if hasPrefix || evt.RoomID == user.ManagementRoom {
mh.cmd.handle(evt.RoomID, user, content.Body, content.GetReplyTo())
return
}
}
portal := mh.bridge.GetPortalByMXID(evt.RoomID)
if portal != nil {
portal.matrixMessages <- portalMatrixMessage{user: user, evt: evt}
}
}
func (mh *matrixHandler) joinAndCheckMembers(evt *event.Event, intent *appservice.IntentAPI) int {
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 0
}
members, err := intent.Members(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 0
}
if len(members.Chunk) < 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 0
}
return len(members.Chunk)
}
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 == 0 {
return
}
// If this is a DM and the user doesn't have a management room, make this
// the management room.
if members == 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)
}
if evt.RoomID == user.ManagementRoom {
// 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 user.Connected() {
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
}
if mh.bridge.crypto != nil {
mh.bridge.crypto.HandleMemberEvent(evt)
}
// Grab the content of the event.
content := evt.Content.AsMember()
// 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
}
puppet := mh.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey()))
// Load or create a new portal.
portal := mh.bridge.GetPortalByMXID(evt.RoomID)
if portal == nil {
if content.Membership == event.MembershipInvite && puppet != nil {
mh.handlePuppetInvite(evt, user, puppet)
}
return
}
isSelf := id.UserID(evt.GetStateKey()) == evt.Sender
if content.Membership == event.MembershipLeave {
if evt.Unsigned.PrevContent != nil {
_ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
prevContent, ok := evt.Unsigned.PrevContent.Parsed.(*event.MemberEventContent)
if ok && prevContent.Membership != "join" {
return
}
}
if isSelf {
portal.handleMatrixLeave(user)
} else if puppet != nil {
portal.handleMatrixKick(user, puppet)
}
} else if content.Membership == event.MembershipInvite {
portal.handleMatrixInvite(user, evt)
}
}
func (mh *matrixHandler) handleReaction(evt *event.Event) {
if mh.ignoreEvent(evt) {
return
}
portal := mh.bridge.GetPortalByMXID(evt.RoomID)
if portal != nil {
portal.handleMatrixReaction(evt)
}
}
func (mh *matrixHandler) handleRedaction(evt *event.Event) {
if mh.ignoreEvent(evt) {
return
}
portal := mh.bridge.GetPortalByMXID(evt.RoomID)
if portal != nil {
portal.handleMatrixRedaction(evt)
}
}
func (mh *matrixHandler) handleEncryption(evt *event.Event) {
if evt.Content.AsEncryption().Algorithm != id.AlgorithmMegolmV1 {
return
}
portal := mh.bridge.GetPortalByMXID(evt.RoomID)
if portal != nil && !portal.Encrypted {
mh.log.Debugfln("%s enabled encryption in %s", evt.Sender, evt.RoomID)
portal.Encrypted = true
portal.Update()
}
}
const sessionWaitTimeout = 5 * time.Second
func (mh *matrixHandler) handleEncrypted(evt *event.Event) {
if mh.ignoreEvent(evt) || mh.bridge.crypto == nil {
return
}
decrypted, err := mh.bridge.crypto.Decrypt(evt)
decryptionRetryCount := 0
if errors.Is(err, NoSessionFound) {
content := evt.Content.AsEncrypted()
mh.log.Debugfln("Couldn't find session %s trying to decrypt %s, waiting %d seconds...", content.SessionID, evt.ID, int(sessionWaitTimeout.Seconds()))
mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, false, decryptionRetryCount)
decryptionRetryCount++
if mh.bridge.crypto.WaitForSession(evt.RoomID, content.SenderKey, content.SessionID, sessionWaitTimeout) {
mh.log.Debugfln("Got session %s after waiting, trying to decrypt %s again", content.SessionID, evt.ID)
decrypted, err = mh.bridge.crypto.Decrypt(evt)
} else {
mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, fmt.Errorf("didn't receive encryption keys"), false, decryptionRetryCount)
go mh.waitLongerForSession(evt)
return
}
}
if err != nil {
mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, true, decryptionRetryCount)
mh.log.Warnfln("Failed to decrypt %s: %v", evt.ID, err)
_, _ = mh.bridge.bot.SendNotice(evt.RoomID, fmt.Sprintf(
"\u26a0 Your message was not bridged: %v", err))
return
}
mh.as.SendMessageSendCheckpoint(decrypted, appservice.StepDecrypted, decryptionRetryCount)
mh.bridge.eventProcessor.Dispatch(decrypted)
}
func (mh *matrixHandler) waitLongerForSession(evt *event.Event) {
const extendedTimeout = sessionWaitTimeout * 3
content := evt.Content.AsEncrypted()
mh.log.Debugfln("Couldn't find session %s trying to decrypt %s, waiting %d more seconds...",
content.SessionID, evt.ID, int(extendedTimeout.Seconds()))
go mh.bridge.crypto.RequestSession(evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
resp, err := mh.bridge.bot.SendNotice(evt.RoomID, fmt.Sprintf(
"\u26a0 Your message was not bridged: the bridge hasn't received the decryption keys. "+
"The bridge will retry for %d seconds. If this error keeps happening, try restarting your client.",
int(extendedTimeout.Seconds())))
if err != nil {
mh.log.Errorfln("Failed to send decryption error to %s: %v", evt.RoomID, err)
}
update := event.MessageEventContent{MsgType: event.MsgNotice}
if mh.bridge.crypto.WaitForSession(evt.RoomID, content.SenderKey, content.SessionID, extendedTimeout) {
mh.log.Debugfln("Got session %s after waiting more, trying to decrypt %s again", content.SessionID, evt.ID)
decrypted, err := mh.bridge.crypto.Decrypt(evt)
if err == nil {
mh.as.SendMessageSendCheckpoint(decrypted, appservice.StepDecrypted, 2)
mh.bridge.eventProcessor.Dispatch(decrypted)
_, _ = mh.bridge.bot.RedactEvent(evt.RoomID, resp.EventID)
return
}
mh.log.Warnfln("Failed to decrypt %s: %v", evt.ID, err)
mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, true, 2)
update.Body = fmt.Sprintf("\u26a0 Your message was not bridged: %v", err)
} else {
mh.log.Debugfln("Didn't get %s, giving up on %s", content.SessionID, evt.ID)
mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, fmt.Errorf("didn't receive encryption keys"), true, 2)
update.Body = "\u26a0 Your message was not bridged: the bridge hasn't received the decryption keys. " +
"If this error keeps happening, try restarting your client."
}
newContent := update
update.NewContent = &newContent
if resp != nil {
update.RelatesTo = &event.RelatesTo{
Type: event.RelReplace,
EventID: resp.EventID,
}
}
_, err = mh.bridge.bot.SendMessageEvent(evt.RoomID, event.EventMessage, &update)
if err != nil {
mh.log.Debugfln("Failed to update decryption error notice %s: %v", resp.EventID, err)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,291 +0,0 @@
package bridge
import (
"fmt"
"regexp"
"sync"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
type Puppet struct {
*database.Puppet
bridge *Bridge
log log.Logger
MXID id.UserID
customIntent *appservice.IntentAPI
customUser *User
syncLock sync.Mutex
}
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) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
b.puppetsLock.Lock()
defer b.puppetsLock.Unlock()
puppet, ok := b.puppetsByCustomMXID[mxid]
if !ok {
dbPuppet := b.db.Puppet.GetByCustomMXID(mxid)
if dbPuppet == nil {
return nil
}
puppet = b.NewPuppet(dbPuppet)
b.puppets[puppet.ID] = puppet
b.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
return puppet
}
func (b *Bridge) GetAllPuppetsWithCustomMXID() []*Puppet {
return b.dbPuppetsToPuppets(b.db.Puppet.GetAllWithCustomMXID())
}
func (b *Bridge) GetAllPuppets() []*Puppet {
return b.dbPuppetsToPuppets(b.db.Puppet.GetAll())
}
func (b *Bridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet {
b.puppetsLock.Lock()
defer b.puppetsLock.Unlock()
output := make([]*Puppet, len(dbPuppets))
for index, dbPuppet := range dbPuppets {
if dbPuppet == nil {
continue
}
puppet, ok := b.puppets[dbPuppet.ID]
if !ok {
puppet = b.NewPuppet(dbPuppet)
b.puppets[dbPuppet.ID] = puppet
if dbPuppet.CustomMXID != "" {
b.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
}
}
output[index] = puppet
}
return output
}
func (b *Bridge) FormatPuppetMXID(did string) id.UserID {
return id.NewUserID(
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) IntentFor(portal *Portal) *appservice.IntentAPI {
if p.customIntent == nil {
return p.DefaultIntent()
}
return p.customIntent
}
func (p *Puppet) CustomIntent() *appservice.IntentAPI {
return p.customIntent
}
func (p *Puppet) updatePortalMeta(meta func(portal *Portal)) {
for _, portal := range p.bridge.GetAllPortalsByID(p.ID) {
// Get room create lock to prevent races between receiving contact info and room creation.
portal.roomCreateLock.Lock()
meta(portal)
portal.roomCreateLock.Unlock()
}
}
func (p *Puppet) updateName(source *User) bool {
user, err := source.Session.User(p.ID)
if err != nil {
p.log.Warnln("failed to get user from id:", err)
return false
}
newName := p.bridge.Config.Bridge.FormatDisplayname(user)
if p.DisplayName != newName {
err := p.DefaultIntent().SetDisplayName(newName)
if err == nil {
p.DisplayName = newName
go p.updatePortalName()
p.Update()
} else {
p.log.Warnln("failed to set display name:", err)
}
return true
}
return false
}
func (p *Puppet) updatePortalName() {
p.updatePortalMeta(func(portal *Portal) {
if portal.MXID != "" {
_, err := portal.MainIntent().SetRoomName(portal.MXID, p.DisplayName)
if err != nil {
portal.log.Warnln("Failed to set name:", err)
}
}
portal.Name = p.DisplayName
portal.Update()
})
}
func (p *Puppet) updateAvatar(source *User) bool {
user, err := source.Session.User(p.ID)
if err != nil {
p.log.Warnln("Failed to get user:", err)
return false
}
if p.Avatar == user.Avatar {
return false
}
if user.Avatar == "" {
p.log.Warnln("User does not have an avatar")
return false
}
url, err := uploadAvatar(p.DefaultIntent(), user.AvatarURL(""))
if err != nil {
p.log.Warnln("Failed to upload user avatar:", err)
return false
}
p.AvatarURL = url
err = p.DefaultIntent().SetAvatarURL(p.AvatarURL)
if err != nil {
p.log.Warnln("Failed to set avatar:", err)
}
p.log.Debugln("Updated avatar", p.Avatar, "->", user.Avatar)
p.Avatar = user.Avatar
go p.updatePortalAvatar()
return true
}
func (p *Puppet) updatePortalAvatar() {
p.updatePortalMeta(func(portal *Portal) {
if portal.MXID != "" {
_, err := portal.MainIntent().SetRoomAvatar(portal.MXID, p.AvatarURL)
if err != nil {
portal.log.Warnln("Failed to set avatar:", err)
}
}
portal.AvatarURL = p.AvatarURL
portal.Avatar = p.Avatar
portal.Update()
})
}
func (p *Puppet) SyncContact(source *User) {
p.syncLock.Lock()
defer p.syncLock.Unlock()
p.log.Debugln("syncing contact", p.DisplayName)
err := p.DefaultIntent().EnsureRegistered()
if err != nil {
p.log.Errorln("Failed to ensure registered:", err)
}
update := false
update = p.updateName(source) || update
if p.Avatar == "" {
update = p.updateAvatar(source) || update
p.log.Debugln("update avatar returned", update)
}
if update {
p.Update()
}
}

View file

@ -1,826 +0,0 @@
package bridge
import (
"errors"
"fmt"
"net/http"
"strings"
"sync"
"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"
"go.mau.fi/mautrix-discord/database"
)
var (
ErrNotConnected = errors.New("not connected")
ErrNotLoggedIn = errors.New("not logged in")
)
type User struct {
*database.User
sync.Mutex
bridge *Bridge
log log.Logger
// TODO finish implementing
Admin bool
guilds map[string]*database.Guild
guildsLock sync.Mutex
Session *discordgo.Session
}
// this assume you are holding the guilds lock!!!
func (u *User) loadGuilds() {
u.guilds = map[string]*database.Guild{}
for _, guild := range u.bridge.db.Guild.GetAll(u.ID) {
u.guilds[guild.GuildID] = guild
}
}
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()
}
// Load our guilds state from the database and turn it into a map
user.guildsLock.Lock()
user.loadGuilds()
user.guildsLock.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) GetUserByID(id string) *User {
b.usersLock.Lock()
defer b.usersLock.Unlock()
user, ok := b.usersByID[id]
if !ok {
return b.loadUser(b.db.User.GetByID(id), nil)
}
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)),
guilds: map[string]*database.Guild{},
}
return user
}
func (b *Bridge) getAllUsers() []*User {
b.usersLock.Lock()
defer b.usersLock.Unlock()
dbUsers := b.db.User.GetAll()
users := make([]*User, len(dbUsers))
for idx, dbUser := range dbUsers {
user, ok := b.usersByMXID[dbUser.MXID]
if !ok {
user = b.loadUser(dbUser, nil)
}
users[idx] = user
}
return users
}
func (b *Bridge) startUsers() {
b.log.Debugln("Starting users")
for _, user := range b.getAllUsers() {
go user.Connect()
}
b.log.Debugln("Starting custom puppets")
for _, customPuppet := range b.GetAllPuppetsWithCustomMXID() {
go func(puppet *Puppet) {
b.log.Debugln("Starting custom puppet", puppet.CustomMXID)
if err := puppet.StartCustomMXID(true); err != nil {
puppet.log.Errorln("Failed to start custom puppet:", err)
}
}(customPuppet)
}
}
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) sendQRCode(bot *appservice.IntentAPI, roomID id.RoomID, code string) (id.EventID, error) {
url, err := u.uploadQRCode(code)
if err != nil {
return "", err
}
content := event.MessageEventContent{
MsgType: event.MsgImage,
Body: code,
URL: url.CUString(),
}
resp, err := bot.SendMessageEvent(roomID, event.EventMessage, &content)
if err != nil {
return "", err
}
return resp.EventID, nil
}
func (u *User) uploadQRCode(code string) (id.ContentURI, error) {
qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
if err != nil {
u.log.Errorln("Failed to encode QR code:", err)
return id.ContentURI{}, err
}
bot := u.bridge.as.BotClient()
resp, err := bot.UploadBytes(qrCode, "image/png")
if err != nil {
u.log.Errorln("Failed to upload QR code:", err)
return id.ContentURI{}, err
}
return resp.ContentURI, nil
}
func (u *User) tryAutomaticDoublePuppeting() {
u.Lock()
defer u.Unlock()
if !u.bridge.Config.CanAutoDoublePuppet(u.MXID) {
return
}
u.log.Debugln("Checking if double puppeting needs to be enabled")
puppet := u.bridge.GetPuppetByID(u.ID)
if puppet.CustomMXID != "" {
u.log.Debugln("User already has double-puppeting enabled")
return
}
accessToken, err := puppet.loginWithSharedSecret(u.MXID)
if err != nil {
u.log.Warnln("Failed to login with shared secret:", err)
return
}
err = puppet.SwitchCustomMXID(accessToken, u.MXID)
if err != nil {
puppet.log.Warnln("Failed to switch to auto-logined custom puppet:", err)
return
}
u.log.Infoln("Successfully automatically enabled custom puppet")
}
func (u *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) {
doublePuppet := portal.bridge.GetPuppetByCustomMXID(u.MXID)
if doublePuppet == nil {
return
}
if doublePuppet == nil || doublePuppet.CustomIntent() == nil || portal.MXID == "" {
return
}
// TODO sync mute status
}
func (u *User) Login(token string) error {
if token == "" {
return fmt.Errorf("No token specified")
}
u.Token = token
u.Update()
return u.Connect()
}
func (u *User) LoggedIn() bool {
u.Lock()
defer u.Unlock()
return u.Token != ""
}
func (u *User) Logout() error {
u.Lock()
defer u.Unlock()
if u.Session == nil {
return ErrNotLoggedIn
}
puppet := u.bridge.GetPuppetByID(u.ID)
if puppet.CustomMXID != "" {
err := puppet.SwitchCustomMXID("", "")
if err != nil {
u.log.Warnln("Failed to logout-matrix while logging out of Discord:", err)
}
}
if err := u.Session.Close(); err != nil {
return err
}
u.Session = nil
u.Token = ""
u.Update()
return nil
}
func (u *User) Connected() bool {
u.Lock()
defer u.Unlock()
return u.Session != nil
}
func (u *User) Connect() error {
u.Lock()
defer u.Unlock()
if u.Token == "" {
return ErrNotLoggedIn
}
u.log.Debugln("connecting to discord")
session, err := discordgo.New(u.Token)
if err != nil {
return err
}
u.Session = session
// Add our event handlers
u.Session.AddHandler(u.readyHandler)
u.Session.AddHandler(u.connectedHandler)
u.Session.AddHandler(u.disconnectedHandler)
u.Session.AddHandler(u.guildCreateHandler)
u.Session.AddHandler(u.guildDeleteHandler)
u.Session.AddHandler(u.guildUpdateHandler)
u.Session.AddHandler(u.channelCreateHandler)
u.Session.AddHandler(u.channelDeleteHandler)
u.Session.AddHandler(u.channelPinsUpdateHandler)
u.Session.AddHandler(u.channelUpdateHandler)
u.Session.AddHandler(u.messageCreateHandler)
u.Session.AddHandler(u.messageDeleteHandler)
u.Session.AddHandler(u.messageUpdateHandler)
u.Session.AddHandler(u.reactionAddHandler)
u.Session.AddHandler(u.reactionRemoveHandler)
u.Session.Identify.Presence.Status = "online"
return u.Session.Open()
}
func (u *User) Disconnect() error {
u.Lock()
defer u.Unlock()
if u.Session == nil {
return ErrNotConnected
}
if err := u.Session.Close(); err != nil {
return err
}
u.Session = nil
return nil
}
func (u *User) bridgeMessage(guildID string) bool {
// Non guild message always get bridged.
if guildID == "" {
return true
}
u.guildsLock.Lock()
defer u.guildsLock.Unlock()
if guild, found := u.guilds[guildID]; found {
if guild.Bridge {
return true
}
}
u.log.Debugfln("ignoring message for non-bridged guild %s-%s", u.ID, guildID)
return false
}
func (u *User) readyHandler(s *discordgo.Session, r *discordgo.Ready) {
u.log.Debugln("discord connection ready")
// Update our user fields
u.ID = r.User.ID
// Update our guild map to match watch discord thinks we're in. This is the
// only time we can get the full guild map as discordgo doesn't make it
// available to us later. Also, discord might not give us the full guild
// information here, so we use this to remove guilds the user left and only
// add guilds whose full information we have. The are told about the
// "unavailable" guilds later via the GuildCreate handler.
u.guildsLock.Lock()
defer u.guildsLock.Unlock()
// build a list of the current guilds we're in so we can prune the old ones
current := []string{}
u.log.Debugln("database guild count", len(u.guilds))
u.log.Debugln("discord guild count", len(r.Guilds))
for _, guild := range r.Guilds {
current = append(current, guild.ID)
// If we already know about this guild, make sure we reset it's bridge
// status.
if val, found := u.guilds[guild.ID]; found {
bridge := val.Bridge
u.guilds[guild.ID].Bridge = bridge
// Update the name if the guild is available
if !guild.Unavailable {
u.guilds[guild.ID].GuildName = guild.Name
}
val.Upsert()
} else {
g := u.bridge.db.Guild.New()
g.DiscordID = u.ID
g.GuildID = guild.ID
u.guilds[guild.ID] = g
if !guild.Unavailable {
g.GuildName = guild.Name
}
g.Upsert()
}
}
// Sync the guilds to the database.
u.bridge.db.Guild.Prune(u.ID, current)
// Finally reload from the database since it purged servers we're not in
// anymore.
u.loadGuilds()
u.log.Debugln("updated database guild count", len(u.guilds))
u.Update()
}
func (u *User) connectedHandler(s *discordgo.Session, c *discordgo.Connect) {
u.log.Debugln("connected to discord")
u.tryAutomaticDoublePuppeting()
}
func (u *User) disconnectedHandler(s *discordgo.Session, d *discordgo.Disconnect) {
u.log.Debugln("disconnected from discord")
}
func (u *User) guildCreateHandler(s *discordgo.Session, g *discordgo.GuildCreate) {
u.guildsLock.Lock()
defer u.guildsLock.Unlock()
// If we somehow already know about the guild, just update it's name
if guild, found := u.guilds[g.ID]; found {
guild.GuildName = g.Name
guild.Upsert()
return
}
// This is a brand new guild so lets get it added.
guild := u.bridge.db.Guild.New()
guild.DiscordID = u.ID
guild.GuildID = g.ID
guild.GuildName = g.Name
guild.Upsert()
u.guilds[g.ID] = guild
}
func (u *User) guildDeleteHandler(s *discordgo.Session, g *discordgo.GuildDelete) {
u.guildsLock.Lock()
defer u.guildsLock.Unlock()
if guild, found := u.guilds[g.ID]; found {
guild.Delete()
delete(u.guilds, g.ID)
u.log.Debugln("deleted guild", g.Guild.ID)
}
}
func (u *User) guildUpdateHandler(s *discordgo.Session, g *discordgo.GuildUpdate) {
u.guildsLock.Lock()
defer u.guildsLock.Unlock()
// If we somehow already know about the guild, just update it's name
if guild, found := u.guilds[g.ID]; found {
guild.GuildName = g.Name
guild.Upsert()
u.log.Debugln("updated guild", g.ID)
}
}
func (u *User) createChannel(c *discordgo.Channel) {
key := database.NewPortalKey(c.ID, u.User.ID)
portal := u.bridge.GetPortalByID(key)
if portal.MXID != "" {
return
}
portal.Name = c.Name
portal.Topic = c.Topic
portal.Type = c.Type
if portal.Type == discordgo.ChannelTypeDM {
portal.DMUser = c.Recipients[0].ID
}
if c.Icon != "" {
u.log.Debugln("channel icon", c.Icon)
}
portal.Update()
portal.createMatrixRoom(u, c)
}
func (u *User) channelCreateHandler(s *discordgo.Session, c *discordgo.ChannelCreate) {
u.createChannel(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(c.ID, u.User.ID)
portal := u.bridge.GetPortalByID(key)
portal.update(u, c.Channel)
}
func (u *User) messageCreateHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
if !u.bridgeMessage(m.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, u.ID)
portal := u.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: u,
}
portal.discordMessages <- msg
}
func (u *User) messageDeleteHandler(s *discordgo.Session, m *discordgo.MessageDelete) {
if !u.bridgeMessage(m.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, u.ID)
portal := u.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: u,
}
portal.discordMessages <- msg
}
func (u *User) messageUpdateHandler(s *discordgo.Session, m *discordgo.MessageUpdate) {
if !u.bridgeMessage(m.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, u.ID)
portal := u.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: u,
}
portal.discordMessages <- msg
}
func (u *User) reactionAddHandler(s *discordgo.Session, m *discordgo.MessageReactionAdd) {
if !u.bridgeMessage(m.MessageReaction.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, u.User.ID)
portal := u.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: u,
}
portal.discordMessages <- msg
}
func (u *User) reactionRemoveHandler(s *discordgo.Session, m *discordgo.MessageReactionRemove) {
if !u.bridgeMessage(m.MessageReaction.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, u.User.ID)
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{}{},
}
customPuppet := u.bridge.GetPuppetByCustomMXID(u.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
inviteContent.Raw["fi.mau.will_auto_accept"] = true
}
_, err := intent.SendStateEvent(roomID, event.StateMember, u.MXID.String(), &inviteContent)
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
}
if customPuppet != nil && customPuppet.CustomIntent() != nil {
err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
if err != nil {
u.log.Warnfln("Failed to auto-join %s: %v", roomID, err)
ret = false
} else {
ret = true
}
}
return ret
}
func (u *User) getDirectChats() map[id.UserID][]id.RoomID {
chats := map[id.UserID][]id.RoomID{}
privateChats := u.bridge.db.Portal.FindPrivateChats(u.ID)
for _, portal := range privateChats {
if portal.MXID != "" {
puppetMXID := u.bridge.FormatPuppetMXID(portal.Key.Receiver)
chats[puppetMXID] = []id.RoomID{portal.MXID}
}
}
return chats
}
func (u *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
if !u.bridge.Config.Bridge.SyncDirectChatList {
return
}
puppet := u.bridge.GetPuppetByMXID(u.MXID)
if puppet == nil {
return
}
intent := puppet.CustomIntent()
if intent == nil {
return
}
method := http.MethodPatch
if chats == nil {
chats = u.getDirectChats()
method = http.MethodPut
}
u.log.Debugln("Updating m.direct list on homeserver")
var err error
if u.bridge.Config.Homeserver.Asmux {
urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"})
_, err = intent.MakeFullRequest(mautrix.FullRequest{
Method: method,
URL: urlPath,
Headers: http.Header{"X-Asmux-Auth": {u.bridge.as.Registration.AppToken}},
RequestJSON: chats,
})
} else {
existingChats := map[id.UserID][]id.RoomID{}
err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats)
if err != nil {
u.log.Warnln("Failed to get m.direct list to update it:", err)
return
}
for userID, rooms := range existingChats {
if _, ok := u.bridge.ParsePuppetMXID(userID); !ok {
// This is not a ghost user, include it in the new list
chats[userID] = rooms
} else if _, ok := chats[userID]; !ok && method == http.MethodPatch {
// This is a ghost user, but we're not replacing the whole list, so include it too
chats[userID] = rooms
}
}
err = intent.SetAccountData(event.AccountDataDirectChats.Type, &chats)
}
if err != nil {
u.log.Warnln("Failed to update m.direct list:", err)
}
}
func (u *User) bridgeGuild(guildID string, everything bool) error {
u.guildsLock.Lock()
defer u.guildsLock.Unlock()
guild, found := u.guilds[guildID]
if !found {
return fmt.Errorf("guildID not found")
}
// Update the guild
guild.Bridge = true
guild.Upsert()
// If this is a full bridge, create portals for all the channels
if everything {
channels, err := u.Session.GuildChannels(guildID)
if err != nil {
return err
}
for _, channel := range channels {
if channelIsBridgeable(channel) {
u.createChannel(channel)
}
}
}
return nil
}
func (u *User) unbridgeGuild(guildID string) error {
u.guildsLock.Lock()
defer u.guildsLock.Unlock()
guild, exists := u.guilds[guildID]
if !exists {
return fmt.Errorf("guildID not found")
}
if !guild.Bridge {
return fmt.Errorf("guild not bridged")
}
// First update the guild so we don't have any other go routines recreating
// channels we're about to destroy.
guild.Bridge = false
guild.Upsert()
// Now run through the channels in the guild and remove any portals we
// have for them.
channels, err := u.Session.GuildChannels(guildID)
if err != nil {
return err
}
for _, channel := range channels {
if channelIsBridgeable(channel) {
key := database.PortalKey{
ChannelID: channel.ID,
Receiver: u.ID,
}
portal := u.bridge.GetPortalByID(key)
portal.leave(u)
}
}
return nil
}

2
build.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"

285
commands.go Normal file
View file

@ -0,0 +1,285 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"fmt"
"strings"
"github.com/skip2/go-qrcode"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/remoteauth"
)
type WrappedCommandEvent struct {
*commands.Event
Bridge *DiscordBridge
User *User
Portal *Portal
}
func (br *DiscordBridge) RegisterCommands() {
proc := br.CommandProcessor.(*commands.Processor)
proc.AddHandlers(
cmdLogin,
cmdLogout,
cmdReconnect,
cmdDisconnect,
cmdGuilds,
)
}
func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) {
return func(ce *commands.Event) {
user := ce.User.(*User)
var portal *Portal
if ce.Portal != nil {
portal = ce.Portal.(*Portal)
}
br := ce.Bridge.Child.(*DiscordBridge)
handler(&WrappedCommandEvent{ce, br, user, portal})
}
}
var cmdLogin = &commands.FullHandler{
Func: wrapCommand(fnLogin),
Name: "login",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Link the bridge to your Discord account by scanning a QR code.",
},
}
func fnLogin(ce *WrappedCommandEvent) {
if ce.User.IsLoggedIn() {
ce.Reply("You're already logged in")
return
}
client, err := remoteauth.New()
if err != nil {
ce.Reply("Failed to prepare login: %v", err)
return
}
qrChan := make(chan string)
doneChan := make(chan struct{})
var qrCodeEvent id.EventID
go func() {
code := <-qrChan
resp := sendQRCode(ce, code)
qrCodeEvent = resp
}()
ctx := context.Background()
if err = client.Dial(ctx, qrChan, doneChan); err != nil {
close(qrChan)
close(doneChan)
ce.Reply("Error connecting to login websocket: %v", err)
return
}
<-doneChan
if qrCodeEvent != "" {
_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)
}
user, err := client.Result()
if err != nil || len(user.Token) == 0 {
ce.Reply("Error logging in: %v", err)
} else if err = ce.User.Login(user.Token); err != nil {
ce.Reply("Error connecting after login: %v", err)
}
ce.User.Lock()
ce.User.ID = user.UserID
ce.User.Update()
ce.User.Unlock()
ce.Reply("Successfully logged in as %s#%s", user.Username, user.Discriminator)
}
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
url, ok := uploadQRCode(ce, code)
if !ok {
return ""
}
content := event.MessageEventContent{
MsgType: event.MsgImage,
Body: code,
URL: url.CUString(),
}
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
if err != nil {
ce.Log.Errorfln("Failed to send QR code: %v", err)
return ""
}
return resp.EventID
}
func uploadQRCode(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) {
qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
if err != nil {
ce.Log.Errorln("Failed to encode QR code:", err)
ce.Reply("Failed to encode QR code: %v", err)
return id.ContentURI{}, false
}
resp, err := ce.Bot.UploadBytes(qrCode, "image/png")
if err != nil {
ce.Log.Errorln("Failed to upload QR code:", err)
ce.Reply("Failed to upload QR code: %v", err)
return id.ContentURI{}, false
}
return resp.ContentURI, true
}
var cmdLogout = &commands.FullHandler{
Func: wrapCommand(fnLogout),
Name: "logout",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Unlink the bridge from your WhatsApp account.",
},
RequiresLogin: true,
}
func fnLogout(ce *WrappedCommandEvent) {
err := ce.User.Logout()
if err != nil {
ce.Reply("Error logging out: %v", err)
} else {
ce.Reply("Logged out successfully.")
}
}
var cmdDisconnect = &commands.FullHandler{
Func: wrapCommand(fnDisconnect),
Name: "disconnect",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Disconnect from Discord (without logging out)",
},
RequiresLogin: true,
}
func fnDisconnect(ce *WrappedCommandEvent) {
if !ce.User.Connected() {
ce.Reply("You're already not connected")
} else if err := ce.User.Disconnect(); err != nil {
ce.Reply("Error while disconnecting: %v", err)
} else {
ce.Reply("Successfully disconnected")
}
}
var cmdReconnect = &commands.FullHandler{
Func: wrapCommand(fnReconnect),
Name: "reconnect",
Aliases: []string{"connect"},
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Reconnect to Discord after disconnecting",
},
RequiresLogin: true,
}
func fnReconnect(ce *WrappedCommandEvent) {
if ce.User.Connected() {
ce.Reply("You're already connected")
} else if err := ce.User.Connect(); err != nil {
ce.Reply("Error while reconnecting: %v", err)
} else {
ce.Reply("Successfully reconnected")
}
}
var cmdGuilds = &commands.FullHandler{
Func: wrapCommand(fnGuilds),
Name: "guilds",
Aliases: []string{"servers", "guild", "server"},
Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified,
Description: "Guild bridging management",
Args: "<status/bridge/unbridge> [_guild ID_] [--entire]",
},
RequiresLogin: true,
}
func fnGuilds(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply("**Usage**: `$cmdprefix guilds <status/bridge/unbridge> [guild ID] [--entire]`")
}
subcommand := strings.ToLower(ce.Args[0])
ce.Args = ce.Args[1:]
switch subcommand {
case "status":
fnListGuilds(ce)
case "bridge":
fnBridgeGuild(ce)
case "unbridge":
fnUnbridgeGuild(ce)
}
}
func fnListGuilds(ce *WrappedCommandEvent) {
ce.User.guildsLock.Lock()
defer ce.User.guildsLock.Unlock()
if len(ce.User.guilds) == 0 {
ce.Reply("You haven't joined any guilds")
} else {
var output strings.Builder
for _, guild := range ce.User.guilds {
status := "not bridged"
if guild.Bridge {
status = "bridged"
}
_, _ = fmt.Fprintf(&output, "* %s (`%s`) - %s\n", guild.GuildName, guild.GuildID, status)
}
ce.Reply("List of guilds:\n\n%s", output.String())
}
}
func fnBridgeGuild(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 || len(ce.Args) > 2 {
ce.Reply("**Usage**: `$cmdprefix guilds bridge <guild ID> [--entire]")
} else if err := ce.User.bridgeGuild(ce.Args[0], len(ce.Args) == 2 && strings.ToLower(ce.Args[1]) == "--entire"); err != nil {
ce.Reply("Error bridging guild: %v", err)
} else {
ce.Reply("Successfully bridged guild")
}
}
func fnUnbridgeGuild(ce *WrappedCommandEvent) {
if len(ce.Args) != 1 {
ce.Reply("**Usage**: `$cmdprefix guilds unbridge <guild ID>")
} else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil {
ce.Reply("Error unbridging guild: %v", err)
} else {
ce.Reply("Successfully unbridged guild")
}
}

View file

@ -1,85 +0,0 @@
package config
import (
as "maunium.net/go/mautrix/appservice"
)
type appservice struct {
Address string `yaml:"address"`
Hostname string `yaml:"hostname"`
Port uint16 `yaml:"port"`
ID string `yaml:"id"`
Bot bot `yaml:"bot"`
Provisioning provisioning `yaml:"provisioning"`
Database database `yaml:"database"`
EphemeralEvents bool `yaml:"ephemeral_events"`
ASToken string `yaml:"as_token"`
HSToken string `yaml:"hs_token"`
}
func (a *appservice) validate() error {
if a.ID == "" {
a.ID = "discord"
}
if a.Address == "" {
a.Address = "http://localhost:29350"
}
if a.Hostname == "" {
a.Hostname = "0.0.0.0"
}
if a.Port == 0 {
a.Port = 29350
}
if err := a.Database.validate(); err != nil {
return err
}
if err := a.Bot.validate(); err != nil {
return err
}
return nil
}
func (a *appservice) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawAppservice appservice
raw := rawAppservice{}
if err := unmarshal(&raw); err != nil {
return err
}
*a = appservice(raw)
return a.validate()
}
func (cfg *Config) CreateAppService() (*as.AppService, error) {
appservice := as.Create()
appservice.HomeserverURL = cfg.Homeserver.Address
appservice.HomeserverDomain = cfg.Homeserver.Domain
appservice.Host.Hostname = cfg.Appservice.Hostname
appservice.Host.Port = cfg.Appservice.Port
appservice.DefaultHTTPRetries = 4
reg, err := cfg.getRegistration()
if err != nil {
return nil, err
}
appservice.Registration = reg
return appservice, nil
}

View file

@ -1,33 +0,0 @@
package config
type bot struct {
Username string `yaml:"username"`
Displayname string `yaml:"displayname"`
Avatar string `yaml:"avatar"`
}
func (b *bot) validate() error {
if b.Username == "" {
b.Username = "discordbot"
}
if b.Displayname == "" {
b.Displayname = "Discord Bridge Bot"
}
return nil
}
func (b *bot) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawBot bot
raw := rawBot{}
if err := unmarshal(&raw); err != nil {
return err
}
*b = bot(raw)
return b.validate()
}

View file

@ -1,3 +1,19 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
@ -5,19 +21,19 @@ import (
"strings"
"text/template"
"maunium.net/go/mautrix/id"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridge/bridgeconfig"
)
type bridge struct {
type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_template"`
ChannelnameTemplate string `yaml:"channelname_template"`
CommandPrefix string `yaml:"command_prefix"`
ManagementRoomText managementRoomText `yaml:"management_root_text"`
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
@ -30,127 +46,81 @@ type bridge struct {
DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"`
LoginSharedSecretMap map[string]string `yaml:"login_shared_secret_map"`
Encryption encryption `yaml:"encryption"`
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
Provisioning struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
} `yaml:"provisioning"`
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
channelnameTemplate *template.Template `yaml:"-"`
}
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
type umBridgeConfig BridgeConfig
return hasSecret
}
func (b *bridge) validate() error {
var err error
if b.UsernameTemplate == "" {
b.UsernameTemplate = "discord_{{.}}"
}
b.usernameTemplate, err = template.New("username").Parse(b.UsernameTemplate)
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umBridgeConfig)(bc))
if err != nil {
return err
}
if b.DisplaynameTemplate == "" {
b.DisplaynameTemplate = "{{.Username}}#{{.Discriminator}} (D){{if .Bot}} (bot){{end}}"
bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
if err != nil {
return err
} else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
return fmt.Errorf("username template is missing user ID placeholder")
}
b.displaynameTemplate, err = template.New("displayname").Parse(b.DisplaynameTemplate)
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
if err != nil {
return err
}
if b.ChannelnameTemplate == "" {
b.ChannelnameTemplate = "{{if .Guild}}{{.Guild}} - {{end}}{{if .Folder}}{{.Folder}} - {{end}}{{.Name}} (D)"
}
b.channelnameTemplate, err = template.New("channelname").Parse(b.ChannelnameTemplate)
bc.channelnameTemplate, err = template.New("channelname").Parse(bc.ChannelnameTemplate)
if err != nil {
return err
}
if b.PortalMessageBuffer <= 0 {
b.PortalMessageBuffer = 128
}
if b.CommandPrefix == "" {
b.CommandPrefix = "!dis"
}
if err := b.ManagementRoomText.validate(); err != nil {
return err
}
return nil
}
func (b *bridge) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawBridge bridge
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
// Set our defaults that aren't zero values.
raw := rawBridge{
SyncWithCustomPuppets: true,
DefaultBridgeReceipts: true,
DefaultBridgePresence: true,
}
err := unmarshal(&raw)
if err != nil {
return err
}
*b = bridge(raw)
return b.validate()
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
return bc.Encryption
}
func (b bridge) FormatUsername(userid string) string {
func (bc BridgeConfig) GetCommandPrefix() string {
return bc.CommandPrefix
}
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
return bc.ManagementRoomText
}
func (bc BridgeConfig) FormatUsername(userid string) string {
var buffer strings.Builder
b.usernameTemplate.Execute(&buffer, userid)
_ = bc.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 {
func (bc BridgeConfig) 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,
})
_ = bc.displaynameTemplate.Execute(&buffer, user)
return buffer.String()
}
type simplfiedChannel struct {
type wrappedChannel struct {
*discordgo.Channel
Guild string
Folder string
Name string
NSFW bool
}
func (b bridge) FormatChannelname(channel *discordgo.Channel, session *discordgo.Session) (string, error) {
func (bc BridgeConfig) FormatChannelname(channel *discordgo.Channel, session *discordgo.Session) (string, error) {
var buffer strings.Builder
var guildName, folderName string
@ -171,18 +141,17 @@ func (b bridge) FormatChannelname(channel *discordgo.Channel, session *discordgo
if channel.Name == "" {
recipients := make([]string, len(channel.Recipients))
for idx, user := range channel.Recipients {
recipients[idx] = b.FormatDisplayname(user)
recipients[idx] = bc.FormatDisplayname(user)
}
return strings.Join(recipients, ", "), nil
}
}
b.channelnameTemplate.Execute(&buffer, simplfiedChannel{
Guild: guildName,
Folder: folderName,
Name: channel.Name,
NSFW: channel.NSFW,
_ = bc.channelnameTemplate.Execute(&buffer, wrappedChannel{
Channel: channel,
Guild: guildName,
Folder: folderName,
})
return buffer.String(), nil

View file

@ -1,36 +0,0 @@
package config
import (
"fmt"
"os"
"go.mau.fi/mautrix-discord/globals"
)
type Cmd struct {
HomeserverAddress string `kong:"arg,help='The url to for the homeserver',required='1'"`
Domain string `kong:"arg,help='The domain for the homeserver',required='1'"`
Force bool `kong:"flag,help='Overwrite an existing configuration file if one already exists',short='f',default='0'"`
}
func (c *Cmd) Run(g *globals.Globals) error {
if _, err := os.Stat(g.Config); err == nil {
if c.Force == false {
return fmt.Errorf("file %q exists, use -f to overwrite", g.Config)
}
}
cfg := &Config{
Homeserver: homeserver{
Address: c.HomeserverAddress,
Domain: c.Domain,
},
}
if err := cfg.validate(); err != nil {
return err
}
return cfg.Save(g.Config)
}

View file

@ -1,101 +1,35 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
"fmt"
"io/ioutil"
"gopkg.in/yaml.v2"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
)
type Config struct {
Homeserver homeserver `yaml:"homeserver"`
Appservice appservice `yaml:"appservice"`
Bridge bridge `yaml:"bridge"`
Logging logging `yaml:"logging"`
*bridgeconfig.BaseConfig `yaml:",inline"`
filename string `yaml:"-"`
Bridge BridgeConfig `yaml:"bridge"`
}
var configUpdated bool
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
func (cfg *Config) validate() error {
if err := cfg.Homeserver.validate(); err != nil {
return err
}
if err := cfg.Appservice.validate(); err != nil {
return err
}
if err := cfg.Bridge.validate(); err != nil {
return err
}
if err := cfg.Logging.validate(); err != nil {
return err
}
if configUpdated {
return cfg.Save(cfg.filename)
}
return nil
}
func (cfg *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawConfig Config
raw := rawConfig{
filename: cfg.filename,
}
if err := unmarshal(&raw); err != nil {
return err
}
*cfg = Config(raw)
return cfg.validate()
}
func FromBytes(filename string, data []byte) (*Config, error) {
cfg := Config{
filename: filename,
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
func FromString(str string) (*Config, error) {
return FromBytes("", []byte(str))
}
func FromFile(filename string) (*Config, error) {
data, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
return FromBytes(filename, data)
}
func (cfg *Config) Save(filename string) error {
if filename == "" {
return fmt.Errorf("no filename specified yep")
}
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return ioutil.WriteFile(filename, data, 0600)
return hasSecret
}

View file

@ -1,58 +0,0 @@
package config
import (
log "maunium.net/go/maulogger/v2"
db "go.mau.fi/mautrix-discord/database"
)
type database struct {
Type string `yaml:"type"`
URI string `yaml:"uri"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
}
func (d *database) validate() error {
if d.Type == "" {
d.Type = "sqlite3"
}
if d.URI == "" {
d.URI = "mautrix-discord.db"
}
if d.MaxOpenConns == 0 {
d.MaxOpenConns = 20
}
if d.MaxIdleConns == 0 {
d.MaxIdleConns = 2
}
return nil
}
func (d *database) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawDatabase database
raw := rawDatabase{}
if err := unmarshal(&raw); err != nil {
return err
}
*d = database(raw)
return d.validate()
}
func (c *Config) CreateDatabase(baseLog log.Logger) (*db.Database, error) {
return db.New(
c.Appservice.Database.Type,
c.Appservice.Database.URI,
c.Appservice.Database.MaxOpenConns,
c.Appservice.Database.MaxIdleConns,
baseLog,
)
}

View file

@ -1,29 +0,0 @@
package config
type encryption struct {
Allow bool `yaml:"allow"`
Default bool `yaml:"default"`
KeySharing struct {
Allow bool `yaml:"allow"`
RequireCrossSigning bool `yaml:"require_cross_signing"`
RequireVerification bool `yaml:"require_verification"`
} `yaml:"key_sharing"`
}
func (e *encryption) validate() error {
return nil
}
func (e *encryption) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawEncryption encryption
raw := rawEncryption{}
if err := unmarshal(&raw); err != nil {
return err
}
*e = encryption(raw)
return e.validate()
}

View file

@ -1,43 +0,0 @@
package config
import (
"errors"
)
var (
ErrHomeserverNoAddress = errors.New("no homeserver address specified")
ErrHomeserverNoDomain = errors.New("no homeserver domain specified")
)
type homeserver struct {
Address string `yaml:"address"`
Domain string `yaml:"domain"`
Asmux bool `yaml:"asmux"`
StatusEndpoint string `yaml:"status_endpoint"`
AsyncMedia bool `yaml:"async_media"`
}
func (h *homeserver) validate() error {
if h.Address == "" {
return ErrHomeserverNoAddress
}
if h.Domain == "" {
return ErrHomeserverNoDomain
}
return nil
}
func (h *homeserver) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawHomeserver homeserver
raw := rawHomeserver{}
if err := unmarshal(&raw); err != nil {
return err
}
*h = homeserver(raw)
return h.validate()
}

View file

@ -1,89 +0,0 @@
package config
import (
"errors"
"strings"
"maunium.net/go/maulogger/v2"
as "maunium.net/go/mautrix/appservice"
)
type logging as.LogConfig
func (l *logging) validate() error {
if l.Directory == "" {
l.Directory = "./logs"
}
if l.FileNameFormat == "" {
l.FileNameFormat = "{{.Date}}-{{.Index}}.log"
}
if l.FileDateFormat == "" {
l.FileDateFormat = "2006-01-02"
}
if l.FileMode == 0 {
l.FileMode = 384
}
if l.TimestampFormat == "" {
l.TimestampFormat = "Jan _2, 2006 15:04:05"
}
if l.RawPrintLevel == "" {
l.RawPrintLevel = "debug"
} else {
switch strings.ToUpper(l.RawPrintLevel) {
case "TRACE":
l.PrintLevel = -10
case "DEBUG":
l.PrintLevel = maulogger.LevelDebug.Severity
case "INFO":
l.PrintLevel = maulogger.LevelInfo.Severity
case "WARN", "WARNING":
l.PrintLevel = maulogger.LevelWarn.Severity
case "ERR", "ERROR":
l.PrintLevel = maulogger.LevelError.Severity
case "FATAL":
l.PrintLevel = maulogger.LevelFatal.Severity
default:
return errors.New("invalid print level " + l.RawPrintLevel)
}
}
return nil
}
func (l *logging) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawLogging logging
raw := rawLogging{}
if err := unmarshal(&raw); err != nil {
return err
}
*l = logging(raw)
return l.validate()
}
func (cfg *Config) CreateLogger() (maulogger.Logger, error) {
logger := maulogger.Create()
// create an as.LogConfig from our config so we can configure the logger
realLogConfig := as.LogConfig(cfg.Logging)
realLogConfig.Configure(logger)
// Set the default logger.
maulogger.DefaultLogger = logger.(*maulogger.BasicLogger)
// If we were given a filename format attempt to open the file.
if cfg.Logging.FileNameFormat != "" {
if err := maulogger.OpenFile(); err != nil {
return nil, err
}
}
return logger, nil
}

View file

@ -1,38 +0,0 @@
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()
}

View file

@ -1,43 +0,0 @@
package config
import (
"strings"
as "maunium.net/go/mautrix/appservice"
)
type provisioning struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
}
func (p *provisioning) validate() error {
if p.Prefix == "" {
p.Prefix = "/_matrix/provision/v1"
}
if strings.ToLower(p.SharedSecret) == "generate" {
p.SharedSecret = as.RandomString(64)
configUpdated = true
}
return nil
}
func (p *provisioning) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawProvisioning provisioning
raw := rawProvisioning{}
if err := unmarshal(&raw); err != nil {
return err
}
*p = provisioning(raw)
return p.validate()
}
func (p *provisioning) Enabled() bool {
return strings.ToLower(p.SharedSecret) != "disable"
}

View file

@ -1,47 +0,0 @@
package config
import (
"fmt"
"regexp"
as "maunium.net/go/mautrix/appservice"
)
func (cfg *Config) CopyToRegistration(registration *as.Registration) error {
registration.ID = cfg.Appservice.ID
registration.URL = cfg.Appservice.Address
registration.EphemeralEvents = cfg.Appservice.EphemeralEvents
falseVal := false
registration.RateLimited = &falseVal
registration.SenderLocalpart = cfg.Appservice.Bot.Username
pattern := fmt.Sprintf(
"^@%s:%s$",
cfg.Bridge.FormatUsername("[0-9]+"),
cfg.Homeserver.Domain,
)
userIDRegex, err := regexp.Compile(pattern)
if err != nil {
return err
}
registration.Namespaces.RegisterUserIDs(userIDRegex, true)
return nil
}
func (cfg *Config) getRegistration() (*as.Registration, error) {
registration := as.CreateRegistration()
if err := cfg.CopyToRegistration(registration); err != nil {
return nil, err
}
registration.AppToken = cfg.Appservice.ASToken
registration.ServerToken = cfg.Appservice.HSToken
return registration, nil
}

79
config/upgrade.go Normal file
View file

@ -0,0 +1,79 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/bridgeconfig"
up "maunium.net/go/mautrix/util/configupgrade"
)
func DoUpgrade(helper *up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(helper)
helper.Copy(up.Str, "bridge", "username_template")
helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Str, "bridge", "channelname_template")
helper.Copy(up.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "sync_with_custom_puppets")
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
helper.Copy(up.Bool, "bridge", "default_bridge_receipts")
helper.Copy(up.Bool, "bridge", "default_bridge_presence")
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
helper.Copy(up.Bool, "bridge", "encryption", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "key_sharing", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "key_sharing", "require_cross_signing")
helper.Copy(up.Bool, "bridge", "encryption", "key_sharing", "require_verification")
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := appservice.RandomString(64)
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
}
helper.Copy(up.Map, "bridge", "permissions")
//helper.Copy(up.Bool, "bridge", "relay", "enabled")
//helper.Copy(up.Bool, "bridge", "relay", "admin_only")
//helper.Copy(up.Map, "bridge", "relay", "message_formats")
}
var SpacedBlocks = [][]string{
{"homeserver", "asmux"},
{"appservice"},
{"appservice", "hostname"},
{"appservice", "database"},
{"appservice", "id"},
{"appservice", "as_token"},
{"bridge"},
{"bridge", "command_prefix"},
{"bridge", "management_room_text"},
{"bridge", "encryption"},
{"bridge", "provisioning"},
{"bridge", "permissions"},
//{"bridge", "relay"},
{"logging"},
}

View file

@ -1,6 +0,0 @@
package consts
const (
Name = "mautrix-discord"
Description = "Discord-Matrix puppeting bridge"
)

337
custompuppet.go Normal file
View file

@ -0,0 +1,337 @@
package main
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
var (
ErrNoCustomMXID = errors.New("no custom mxid set")
ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
)
///////////////////////////////////////////////////////////////////////////////
// additional bridge api
///////////////////////////////////////////////////////////////////////////////
func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) {
_, homeserver, err := mxid.Parse()
if err != nil {
return nil, err
}
homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver]
if !found {
if homeserver == br.AS.HomeserverDomain {
homeserverURL = br.AS.HomeserverURL
} else if br.Config.Bridge.DoublePuppetAllowDiscovery {
resp, err := mautrix.DiscoverClientAPI(homeserver)
if err != nil {
return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err)
}
homeserverURL = resp.Homeserver.BaseURL
br.Log.Debugfln("Discovered URL %s for %s to enable double puppeting for %s", homeserverURL, homeserver, mxid)
} else {
return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
}
}
client, err := mautrix.NewClient(homeserverURL, mxid, accessToken)
if err != nil {
return nil, err
}
client.Logger = br.AS.Log.Sub(mxid.String())
client.Client = br.AS.HTTPClient
client.DefaultHTTPRetries = br.AS.DefaultHTTPRetries
return client, nil
}
///////////////////////////////////////////////////////////////////////////////
// mautrix.Syncer implementation
///////////////////////////////////////////////////////////////////////////////
func (puppet *Puppet) GetFilterJSON(_ id.UserID) *mautrix.Filter {
everything := []event.Type{{Type: "*"}}
return &mautrix.Filter{
Presence: mautrix.FilterPart{
Senders: []id.UserID{puppet.CustomMXID},
Types: []event.Type{event.EphemeralEventPresence},
},
AccountData: mautrix.FilterPart{NotTypes: everything},
Room: mautrix.RoomFilter{
Ephemeral: mautrix.FilterPart{Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}},
IncludeLeave: false,
AccountData: mautrix.FilterPart{NotTypes: everything},
State: mautrix.FilterPart{NotTypes: everything},
Timeline: mautrix.FilterPart{NotTypes: everything},
},
}
}
func (puppet *Puppet) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
puppet.log.Warnln("Sync error:", err)
if errors.Is(err, mautrix.MUnknownToken) {
if !puppet.tryRelogin(err, "syncing") {
return 0, err
}
puppet.customIntent.AccessToken = puppet.AccessToken
return 0, nil
}
return 10 * time.Second, nil
}
func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error {
if !puppet.customUser.IsLoggedIn() {
puppet.log.Debugln("Skipping sync processing: custom user not connected to discord")
return nil
}
// for roomID, events := range resp.Rooms.Join {
// for _, evt := range events.Ephemeral.Events {
// evt.RoomID = roomID
// err := evt.Content.ParseRaw(evt.Type)
// if err != nil {
// continue
// }
// switch evt.Type {
// case event.EphemeralEventReceipt:
// if puppet.EnableReceipts {
// go puppet.bridge.MatrixHandler.HandleReceipt(evt)
// }
// case event.EphemeralEventTyping:
// go puppet.bridge.MatrixHandler.HandleTyping(evt)
// }
// }
// }
// if puppet.EnablePresence {
// for _, evt := range resp.Presence.Events {
// if evt.Sender != puppet.CustomMXID {
// continue
// }
// err := evt.Content.ParseRaw(evt.Type)
// if err != nil {
// continue
// }
// go puppet.bridge.MatrixHandler.HandlePresence(evt)
// }
// }
return nil
}
///////////////////////////////////////////////////////////////////////////////
// mautrix.Storer implementation
///////////////////////////////////////////////////////////////////////////////
func (puppet *Puppet) SaveFilterID(_ id.UserID, _ string) {
}
func (puppet *Puppet) SaveNextBatch(_ id.UserID, nbt string) {
puppet.NextBatch = nbt
puppet.Update()
}
func (puppet *Puppet) SaveRoom(_ *mautrix.Room) {
}
func (puppet *Puppet) LoadFilterID(_ id.UserID) string {
return ""
}
func (puppet *Puppet) LoadNextBatch(_ id.UserID) string {
return puppet.NextBatch
}
func (puppet *Puppet) LoadRoom(_ id.RoomID) *mautrix.Room {
return nil
}
///////////////////////////////////////////////////////////////////////////////
// additional puppet api
///////////////////////////////////////////////////////////////////////////////
func (puppet *Puppet) clearCustomMXID() {
puppet.CustomMXID = ""
puppet.AccessToken = ""
puppet.customIntent = nil
puppet.customUser = nil
}
func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
if puppet.CustomMXID == "" {
return nil, ErrNoCustomMXID
}
client, err := puppet.bridge.newDoublePuppetClient(puppet.CustomMXID, puppet.AccessToken)
if err != nil {
return nil, err
}
client.Syncer = puppet
client.Store = puppet
ia := puppet.bridge.AS.NewIntentAPI("custom")
ia.Client = client
ia.Localpart, _, _ = puppet.CustomMXID.Parse()
ia.UserID = puppet.CustomMXID
ia.IsCustomPuppet = true
return ia, nil
}
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
if puppet.CustomMXID == "" {
puppet.clearCustomMXID()
return nil
}
intent, err := puppet.newCustomIntent()
if err != nil {
puppet.clearCustomMXID()
return err
}
resp, err := intent.Whoami()
if err != nil {
if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !puppet.tryRelogin(err, "initializing double puppeting")) {
puppet.clearCustomMXID()
return err
}
intent.AccessToken = puppet.AccessToken
} else if resp.UserID != puppet.CustomMXID {
puppet.clearCustomMXID()
return ErrMismatchingMXID
}
puppet.customIntent = intent
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
puppet.startSyncing()
return nil
}
func (puppet *Puppet) tryRelogin(cause error, action string) bool {
if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) {
return false
}
puppet.log.Debugfln("Trying to relogin after '%v' while %s", cause, action)
accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID)
if err != nil {
puppet.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err)
return false
}
puppet.log.Infofln("Successfully relogined after '%v' while %s", cause, action)
puppet.AccessToken = accessToken
return true
}
func (puppet *Puppet) startSyncing() {
if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
return
}
go func() {
puppet.log.Debugln("Starting syncing...")
puppet.customIntent.SyncPresence = "offline"
err := puppet.customIntent.Sync()
if err != nil {
puppet.log.Errorln("Fatal error syncing:", err)
}
}()
}
func (puppet *Puppet) stopSyncing() {
if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
return
}
puppet.customIntent.StopSync()
}
func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
_, homeserver, _ := mxid.Parse()
puppet.log.Debugfln("Logging into %s with shared secret", mxid)
mac := hmac.New(sha512.New, []byte(puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]))
mac.Write([]byte(mxid))
client, err := puppet.bridge.newDoublePuppetClient(mxid, "")
if err != nil {
return "", fmt.Errorf("failed to create mautrix client to log in: %v", err)
}
resp, err := client.Login(&mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)},
Password: hex.EncodeToString(mac.Sum(nil)),
DeviceID: "Discord Bridge",
InitialDeviceDisplayName: "Discord Bridge",
})
if err != nil {
return "", err
}
return resp.AccessToken, nil
}
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
prevCustomMXID := puppet.CustomMXID
if puppet.customIntent != nil {
puppet.stopSyncing()
}
puppet.CustomMXID = mxid
puppet.AccessToken = accessToken
err := puppet.StartCustomMXID(false)
if err != nil {
return err
}
if prevCustomMXID != "" {
delete(puppet.bridge.puppetsByCustomMXID, prevCustomMXID)
}
if puppet.CustomMXID != "" {
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
puppet.EnablePresence = puppet.bridge.Config.Bridge.DefaultBridgePresence
puppet.EnableReceipts = puppet.bridge.Config.Bridge.DefaultBridgeReceipts
puppet.bridge.AS.StateStore.MarkRegistered(puppet.CustomMXID)
puppet.Update()
// TODO leave rooms with default puppet
return nil
}

View file

@ -5,7 +5,9 @@ import (
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type Attachment struct {
@ -19,7 +21,7 @@ type Attachment struct {
MatrixEventID id.EventID
}
func (a *Attachment) Scan(row Scannable) *Attachment {
func (a *Attachment) Scan(row dbutil.Scannable) *Attachment {
err := row.Scan(
&a.Channel.ChannelID, &a.Channel.Receiver,
&a.DiscordMessageID, &a.DiscordAttachmentID,

View file

@ -1,97 +0,0 @@
package database
import (
"database/sql"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/id"
)
type SQLCryptoStore struct {
*crypto.SQLCryptoStore
UserID id.UserID
GhostIDFormat string
}
var _ crypto.Store = (*SQLCryptoStore)(nil)
func NewSQLCryptoStore(db *Database, userID id.UserID, ghostIDFormat string) *SQLCryptoStore {
return &SQLCryptoStore{
SQLCryptoStore: crypto.NewSQLCryptoStore(db.DB, db.dialect, "", "",
[]byte("maunium.net/go/mautrix-whatsapp"),
&cryptoLogger{db.log.Sub("CryptoStore")}),
UserID: userID,
GhostIDFormat: ghostIDFormat,
}
}
func (store *SQLCryptoStore) FindDeviceID() id.DeviceID {
var deviceID id.DeviceID
query := `SELECT device_id FROM crypto_account WHERE account_id=$1`
err := store.DB.QueryRow(query, store.AccountID).Scan(&deviceID)
if err != nil && err != sql.ErrNoRows {
store.Log.Warn("Failed to scan device ID: %v", err)
}
return deviceID
}
func (store *SQLCryptoStore) GetRoomMembers(roomID id.RoomID) ([]id.UserID, error) {
query := `
SELECT user_id FROM mx_user_profile
WHERE room_id=$1
AND (membership='join' OR membership='invite')
AND user_id<>$2
AND user_id NOT LIKE $3
`
members := []id.UserID{}
rows, err := store.DB.Query(query, roomID, store.UserID, store.GhostIDFormat)
if err != nil {
return members, err
}
for rows.Next() {
var userID id.UserID
err := rows.Scan(&userID)
if err != nil {
store.Log.Warn("Failed to scan member in %s: %v", roomID, err)
return members, err
}
members = append(members, userID)
}
return members, nil
}
// TODO merge this with the one in the parent package
type cryptoLogger struct {
int log.Logger
}
var levelTrace = log.Level{
Name: "TRACE",
Severity: -10,
Color: -1,
}
func (c *cryptoLogger) Error(message string, args ...interface{}) {
c.int.Errorfln(message, args...)
}
func (c *cryptoLogger) Warn(message string, args ...interface{}) {
c.int.Warnfln(message, args...)
}
func (c *cryptoLogger) Debug(message string, args ...interface{}) {
c.int.Debugfln(message, args...)
}
func (c *cryptoLogger) Trace(message string, args ...interface{}) {
c.int.Logfln(levelTrace, message, args...)
}

View file

@ -1,20 +1,18 @@
package database
import (
"database/sql"
_ "embed"
"fmt"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
log "maunium.net/go/maulogger/v2"
"go.mau.fi/mautrix-discord/database/migrations"
"go.mau.fi/mautrix-discord/database/upgrades"
"maunium.net/go/mautrix/util/dbutil"
)
type Database struct {
*sql.DB
log log.Logger
dialect string
*dbutil.Database
User *UserQuery
Portal *PortalQuery
@ -26,70 +24,51 @@ type Database struct {
Guild *GuildQuery
}
func New(dbType, uri string, maxOpenConns, maxIdleConns int, baseLog log.Logger) (*Database, error) {
conn, err := sql.Open(dbType, uri)
if err != nil {
return nil, err
//go:embed legacymigrate.sql
var legacyMigrate string
func New(baseDB *dbutil.Database) *Database {
db := &Database{Database: baseDB}
_, err := db.Exec("SELECT id FROM version")
if err == nil {
baseDB.Log.Infoln("Migrating from legacy database versioning")
_, err = db.Exec(legacyMigrate)
if err != nil {
panic(fmt.Errorf("failed to migrate from legacy database versioning: %v", err))
}
}
if dbType == "sqlite3" {
conn.Exec("PRAGMA foreign_keys = ON")
}
conn.SetMaxOpenConns(maxOpenConns)
conn.SetMaxIdleConns(maxIdleConns)
dbLog := baseLog.Sub("Database")
if err := migrations.Run(conn, dbLog, dbType); err != nil {
return nil, err
}
db := &Database{
DB: conn,
log: dbLog,
dialect: dbType,
}
db.UpgradeTable = upgrades.Table
db.User = &UserQuery{
db: db,
log: db.log.Sub("User"),
log: db.Log.Sub("User"),
}
db.Portal = &PortalQuery{
db: db,
log: db.log.Sub("Portal"),
log: db.Log.Sub("Portal"),
}
db.Puppet = &PuppetQuery{
db: db,
log: db.log.Sub("Puppet"),
log: db.Log.Sub("Puppet"),
}
db.Message = &MessageQuery{
db: db,
log: db.log.Sub("Message"),
log: db.Log.Sub("Message"),
}
db.Reaction = &ReactionQuery{
db: db,
log: db.log.Sub("Reaction"),
log: db.Log.Sub("Reaction"),
}
db.Attachment = &AttachmentQuery{
db: db,
log: db.log.Sub("Attachment"),
log: db.Log.Sub("Attachment"),
}
db.Emoji = &EmojiQuery{
db: db,
log: db.log.Sub("Emoji"),
log: db.Log.Sub("Emoji"),
}
db.Guild = &GuildQuery{
db: db,
log: db.log.Sub("Guild"),
log: db.Log.Sub("Guild"),
}
return db, nil
return db
}

View file

@ -7,6 +7,7 @@ import (
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type Emoji struct {
@ -19,7 +20,7 @@ type Emoji struct {
MatrixURL id.ContentURI
}
func (e *Emoji) Scan(row Scannable) *Emoji {
func (e *Emoji) Scan(row dbutil.Scannable) *Emoji {
var matrixURL sql.NullString
err := row.Scan(&e.DiscordID, &e.DiscordName, &matrixURL)

View file

@ -5,6 +5,8 @@ import (
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
)
type Guild struct {
@ -17,7 +19,7 @@ type Guild struct {
Bridge bool
}
func (g *Guild) Scan(row Scannable) *Guild {
func (g *Guild) Scan(row dbutil.Scannable) *Guild {
err := row.Scan(&g.DiscordID, &g.GuildID, &g.GuildName, &g.Bridge)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {

View file

@ -0,0 +1,10 @@
DROP TABLE version;
CREATE TABLE version(version INTEGER PRIMARY KEY);
INSERT INTO version VALUES (1);
CREATE TABLE crypto_version (version INTEGER PRIMARY KEY);
INSERT INTO crypto_version VALUES (6);
CREATE TABLE mx_version (version INTEGER PRIMARY KEY);
INSERT INTO mx_version VALUES (1);
UPDATE "user" SET id=null WHERE id='';
ALTER TABLE "user" ADD CONSTRAINT user_id_key UNIQUE (id);

View file

@ -6,7 +6,9 @@ import (
"time"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type Message struct {
@ -22,7 +24,7 @@ type Message struct {
Timestamp time.Time
}
func (m *Message) Scan(row Scannable) *Message {
func (m *Message) Scan(row dbutil.Scannable) *Message {
var ts int64
err := row.Scan(&m.Channel.ChannelID, &m.Channel.Receiver, &m.DiscordID, &m.MatrixID, &m.AuthorID, &ts)

View file

@ -1,12 +0,0 @@
CREATE TABLE attachment (
channel_id TEXT NOT NULL,
receiver TEXT NOT NULL,
discord_message_id TEXT NOT NULL,
discord_attachment_id TEXT NOT NULL,
matrix_event_id TEXT NOT NULL UNIQUE,
PRIMARY KEY(discord_attachment_id, matrix_event_id),
FOREIGN KEY(channel_id, receiver) REFERENCES portal(channel_id, receiver) ON DELETE CASCADE
);

View file

@ -1,5 +0,0 @@
CREATE TABLE emoji (
discord_id TEXT NOT NULL PRIMARY KEY,
discord_name TEXT,
matrix_url TEXT
);

View file

@ -1,2 +0,0 @@
ALTER TABLE puppet ADD COLUMN custom_mxid TEXT;
ALTER TABLE puppet ADD COLUMN access_token TEXT;

View file

@ -1,2 +0,0 @@
ALTER TABLE puppet ADD COLUMN next_batch TEXT;
ALTER TABLE puppet ADD COLUMN enable_receipts BOOLEAN NOT NULL DEFAULT true;

View file

@ -1 +0,0 @@
ALTER TABLE "user" DROP CONSTRAINT user_id_key;

View file

@ -1,18 +0,0 @@
PRAGMA foreign_keys=off;
ALTER TABLE "user" RENAME TO "old_user";
CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
id TEXT,
management_room TEXT,
token TEXT
);
INSERT INTO "user" SELECT mxid, id, management_room, token FROM "old_user";
DROP TABLE "old_user";
PRAGMA foreign_keys=on;

View file

@ -1,7 +0,0 @@
CREATE TABLE guild (
discord_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
guild_name TEXT NOT NULL,
bridge BOOLEAN DEFAULT FALSE,
PRIMARY KEY(discord_id, guild_id)
);

View file

@ -1,3 +0,0 @@
-- This migration is implemented in migrations.go as it comes from
-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 0
-- which is described as "Add crypto store to database".

View file

@ -1,3 +0,0 @@
-- This migration is implemented in migrations.go as it comes from
-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 1
-- which is described as "Add account_id to crypto store".

View file

@ -1,3 +0,0 @@
-- This migration is implemented in migrations.go as it comes from
-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 2
-- which is described as "Add megolm withheld data to crypto store".

View file

@ -1,3 +0,0 @@
-- This migration is implemented in migrations.go as it comes from
-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 3
-- which is described as "Add cross-signing keys to crypto store".

View file

@ -1,4 +0,0 @@
-- This migration is implemented in migrations.go as it comes from
-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 4
-- which is described as "Replace VARCHAR(255) with TEXT in the crypto
-- database".

View file

@ -1,4 +0,0 @@
-- This migration is implemented in migrations.go as it comes from
-- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 5
-- which is described as "Split last_used into last_encrypted and
-- last_decrypted in crypto store".

View file

@ -1 +0,0 @@
ALTER TABLE portal ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,120 +0,0 @@
package migrations
import (
"database/sql"
"embed"
"github.com/lopezator/migrator"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/crypto/sql_store_upgrade"
)
//go:embed *.sql
var embeddedMigrations embed.FS
func migrationFromFile(description, filename string) *migrator.Migration {
return &migrator.Migration{
Name: description,
Func: func(tx *sql.Tx) error {
data, err := embeddedMigrations.ReadFile(filename)
if err != nil {
return err
}
if _, err := tx.Exec(string(data)); err != nil {
return err
}
return nil
},
}
}
func migrationFromFileWithDialect(dialect, description, sqliteFile, postgresFile string) *migrator.Migration {
switch dialect {
case "sqlite3":
return migrationFromFile(description, sqliteFile)
case "postgres":
return migrationFromFile(description, postgresFile)
default:
return nil
}
}
func Run(db *sql.DB, baseLog log.Logger, dialect string) error {
subLogger := baseLog.Sub("Migrations")
logger := migrator.LoggerFunc(func(msg string, args ...interface{}) {
subLogger.Infof(msg, args...)
})
m, err := migrator.New(
migrator.TableName("version"),
migrator.WithLogger(logger),
migrator.Migrations(
migrationFromFile("Initial Schema", "01-initial.sql"),
migrationFromFile("Attachments", "02-attachments.sql"),
migrationFromFile("Emoji", "03-emoji.sql"),
migrationFromFile("Custom Puppets", "04-custom-puppet.sql"),
migrationFromFile(
"Additional puppet fields",
"05-additional-puppet-fields.sql",
),
migrationFromFileWithDialect(
dialect,
"Remove unique user constraint",
"06-remove-unique-user-constraint.sqlite.sql",
"06-remove-unique-user-constraint.postgres.sql",
),
migrationFromFile("Guild Bridging", "07-guilds.sql"),
&migrator.Migration{
Name: "Add crypto store to database",
Func: func(tx *sql.Tx) error {
return sql_store_upgrade.Upgrades[0](tx, dialect)
},
},
&migrator.Migration{
Name: "Add account_id to crypto store",
Func: func(tx *sql.Tx) error {
return sql_store_upgrade.Upgrades[1](tx, dialect)
},
},
&migrator.Migration{
Name: "Add megolm withheld data to crypto store",
Func: func(tx *sql.Tx) error {
return sql_store_upgrade.Upgrades[2](tx, dialect)
},
},
&migrator.Migration{
Name: "Add cross-signing keys to crypto store",
Func: func(tx *sql.Tx) error {
return sql_store_upgrade.Upgrades[3](tx, dialect)
},
},
&migrator.Migration{
Name: "Replace VARCHAR(255) with TEXT in the crypto database",
Func: func(tx *sql.Tx) error {
return sql_store_upgrade.Upgrades[4](tx, dialect)
},
},
&migrator.Migration{
Name: "Split last_used into last_encrypted and last_decrypted in crypto store",
Func: func(tx *sql.Tx) error {
return sql_store_upgrade.Upgrades[5](tx, dialect)
},
},
migrationFromFile(
"Add encryption column to portal table",
"14-add-encrypted-column-to-portal-table.sql",
),
),
)
if err != nil {
return err
}
if err := m.Migrate(db); err != nil {
return err
}
return nil
}

View file

@ -6,7 +6,9 @@ import (
"github.com/bwmarrin/discordgo"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type Portal struct {
@ -30,7 +32,7 @@ type Portal struct {
FirstEventID id.EventID
}
func (p *Portal) Scan(row Scannable) *Portal {
func (p *Portal) Scan(row dbutil.Scannable) *Portal {
var mxid, avatarURL, firstEventID sql.NullString
var typ sql.NullInt32

View file

@ -4,7 +4,9 @@ import (
"database/sql"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
const (
@ -34,7 +36,7 @@ type Puppet struct {
EnableReceipts bool
}
func (p *Puppet) Scan(row Scannable) *Puppet {
func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
var did, displayName, avatar, avatarURL sql.NullString
var enablePresence sql.NullBool
var customMXID, accessToken, nextBatch sql.NullString

View file

@ -5,7 +5,9 @@ import (
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type Reaction struct {
@ -26,7 +28,7 @@ type Reaction struct {
DiscordID string // The id or unicode of the emoji for discord
}
func (r *Reaction) Scan(row Scannable) *Reaction {
func (r *Reaction) Scan(row dbutil.Scannable) *Reaction {
var discordID sql.NullString
err := row.Scan(

View file

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

View file

@ -1,304 +0,0 @@
package database
import (
"database/sql"
"encoding/json"
"sync"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type SQLStateStore struct {
*appservice.TypingStateStore
db *Database
log log.Logger
Typing map[id.RoomID]map[id.UserID]int64
typingLock sync.RWMutex
}
// make sure that SQLStateStore implements the appservice.StateStore interface
var _ appservice.StateStore = (*SQLStateStore)(nil)
func NewSQLStateStore(db *Database) *SQLStateStore {
return &SQLStateStore{
TypingStateStore: appservice.NewTypingStateStore(),
db: db,
log: db.log.Sub("StateStore"),
}
}
func (s *SQLStateStore) IsRegistered(userID id.UserID) bool {
var isRegistered bool
query := "SELECT EXISTS(SELECT 1 FROM mx_registrations WHERE user_id=$1)"
row := s.db.QueryRow(query, userID)
err := row.Scan(&isRegistered)
if err != nil {
s.log.Warnfln("Failed to scan registration existence for %s: %v", userID, err)
}
return isRegistered
}
func (s *SQLStateStore) MarkRegistered(userID id.UserID) {
query := "INSERT INTO mx_registrations (user_id) VALUES ($1)" +
" ON CONFLICT (user_id) DO NOTHING"
_, err := s.db.Exec(query, userID)
if err != nil {
s.log.Warnfln("Failed to mark %s as registered: %v", userID, err)
}
}
func (s *SQLStateStore) IsTyping(roomID id.RoomID, userID id.UserID) bool {
s.log.Debugln("IsTyping")
return false
}
func (s *SQLStateStore) SetTyping(roomID id.RoomID, userID id.UserID, timeout int64) {
s.log.Debugln("SetTyping")
}
func (s *SQLStateStore) IsInRoom(roomID id.RoomID, userID id.UserID) bool {
return s.IsMembership(roomID, userID, "join")
}
func (s *SQLStateStore) IsInvited(roomID id.RoomID, userID id.UserID) bool {
return s.IsMembership(roomID, userID, "join", "invite")
}
func (s *SQLStateStore) IsMembership(roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool {
membership := s.GetMembership(roomID, userID)
for _, allowedMembership := range allowedMemberships {
if allowedMembership == membership {
return true
}
}
return false
}
func (s *SQLStateStore) GetMembership(roomID id.RoomID, userID id.UserID) event.Membership {
query := "SELECT membership FROM mx_user_profile WHERE " +
"room_id=$1 AND user_id=$2"
row := s.db.QueryRow(query, roomID, userID)
membership := event.MembershipLeave
err := row.Scan(&membership)
if err != nil && err != sql.ErrNoRows {
s.log.Warnfln("Failed to scan membership of %s in %s: %v", userID, roomID, err)
}
return membership
}
func (s *SQLStateStore) GetMember(roomID id.RoomID, userID id.UserID) *event.MemberEventContent {
member, ok := s.TryGetMember(roomID, userID)
if !ok {
member.Membership = event.MembershipLeave
}
return member
}
func (s *SQLStateStore) TryGetMember(roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, bool) {
query := "SELECT membership, displayname, avatar_url FROM mx_user_profile " +
"WHERE room_id=$1 AND user_id=$2"
row := s.db.QueryRow(query, roomID, userID)
var member event.MemberEventContent
err := row.Scan(&member.Membership, &member.Displayname, &member.AvatarURL)
if err != nil && err != sql.ErrNoRows {
s.log.Warnfln("Failed to scan member info of %s in %s: %v", userID, roomID, err)
}
return &member, err == nil
}
func (s *SQLStateStore) SetMembership(roomID id.RoomID, userID id.UserID, membership event.Membership) {
query := "INSERT INTO mx_user_profile (room_id, user_id, membership)" +
" VALUES ($1, $2, $3) ON CONFLICT (room_id, user_id) DO UPDATE SET" +
" membership=excluded.membership"
_, err := s.db.Exec(query, roomID, userID, membership)
if err != nil {
s.log.Warnfln("Failed to set membership of %s in %s to %s: %v", userID, roomID, membership, err)
}
}
func (s *SQLStateStore) SetMember(roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) {
query := "INSERT INTO mx_user_profile" +
" (room_id, user_id, membership, displayname, avatar_url)" +
" VALUES ($1, $2, $3, $4, $5) ON CONFLICT (room_id, user_id)" +
" DO UPDATE SET membership=excluded.membership," +
" displayname=excluded.displayname, avatar_url=excluded.avatar_url"
_, err := s.db.Exec(query, roomID, userID, member.Membership, member.Displayname, member.AvatarURL)
if err != nil {
s.log.Warnfln("Failed to set membership of %s in %s to %s: %v", userID, roomID, member, err)
}
}
func (s *SQLStateStore) SetPowerLevels(roomID id.RoomID, levels *event.PowerLevelsEventContent) {
levelsBytes, err := json.Marshal(levels)
if err != nil {
s.log.Errorfln("Failed to marshal power levels of %s: %v", roomID, err)
return
}
query := "INSERT INTO mx_room_state (room_id, power_levels)" +
" VALUES ($1, $2) ON CONFLICT (room_id) DO UPDATE SET" +
" power_levels=excluded.power_levels"
_, err = s.db.Exec(query, roomID, levelsBytes)
if err != nil {
s.log.Warnfln("Failed to store power levels of %s: %v", roomID, err)
}
}
func (s *SQLStateStore) GetPowerLevels(roomID id.RoomID) *event.PowerLevelsEventContent {
query := "SELECT power_levels FROM mx_room_state WHERE room_id=$1"
row := s.db.QueryRow(query, roomID)
if row == nil {
return nil
}
var data []byte
err := row.Scan(&data)
if err != nil {
s.log.Errorfln("Failed to scan power levels of %s: %v", roomID, err)
return nil
}
levels := &event.PowerLevelsEventContent{}
err = json.Unmarshal(data, levels)
if err != nil {
s.log.Errorfln("Failed to parse power levels of %s: %v", roomID, err)
return nil
}
return levels
}
func (s *SQLStateStore) GetPowerLevel(roomID id.RoomID, userID id.UserID) int {
if s.db.dialect == "postgres" {
query := "SELECT COALESCE((power_levels->'users'->$2)::int," +
" (power_levels->'users_default')::int, 0)" +
" FROM mx_room_state WHERE room_id=$1"
row := s.db.QueryRow(query, roomID, userID)
if row == nil {
// Power levels not in db
return 0
}
var powerLevel int
err := row.Scan(&powerLevel)
if err != nil {
s.log.Errorfln("Failed to scan power level of %s in %s: %v", userID, roomID, err)
}
return powerLevel
}
return s.GetPowerLevels(roomID).GetUserLevel(userID)
}
func (s *SQLStateStore) GetPowerLevelRequirement(roomID id.RoomID, eventType event.Type) int {
if s.db.dialect == "postgres" {
defaultType := "events_default"
defaultValue := 0
if eventType.IsState() {
defaultType = "state_default"
defaultValue = 50
}
query := "SELECT COALESCE((power_levels->'events'->$2)::int," +
" (power_levels->'$3')::int, $4)" +
" FROM mx_room_state WHERE room_id=$1"
row := s.db.QueryRow(query, roomID, eventType.Type, defaultType, defaultValue)
if row == nil {
// Power levels not in db
return defaultValue
}
var powerLevel int
err := row.Scan(&powerLevel)
if err != nil {
s.log.Errorfln("Failed to scan power level for %s in %s: %v", eventType, roomID, err)
}
return powerLevel
}
return s.GetPowerLevels(roomID).GetEventLevel(eventType)
}
func (s *SQLStateStore) HasPowerLevel(roomID id.RoomID, userID id.UserID, eventType event.Type) bool {
if s.db.dialect == "postgres" {
defaultType := "events_default"
defaultValue := 0
if eventType.IsState() {
defaultType = "state_default"
defaultValue = 50
}
query := "SELECT COALESCE((power_levels->'users'->$2)::int," +
" (power_levels->'users_default')::int, 0) >=" +
" COALESCE((power_levels->'events'->$3)::int," +
" (power_levels->'$4')::int, $5)" +
" FROM mx_room_state WHERE room_id=$1"
row := s.db.QueryRow(query, roomID, userID, eventType.Type, defaultType, defaultValue)
if row == nil {
// Power levels not in db
return defaultValue == 0
}
var hasPower bool
err := row.Scan(&hasPower)
if err != nil {
s.log.Errorfln("Failed to scan power level for %s in %s: %v", eventType, roomID, err)
}
return hasPower
}
return s.GetPowerLevel(roomID, userID) >= s.GetPowerLevelRequirement(roomID, eventType)
}
func (store *SQLStateStore) FindSharedRooms(userID id.UserID) []id.RoomID {
query := `
SELECT room_id FROM mx_user_profile
LEFT JOIN portal ON portal.mxid=mx_user_profile.room_id
WHERE user_id=$1 AND portal.encrypted=true
`
rooms := []id.RoomID{}
rows, err := store.db.Query(query, userID)
if err != nil {
store.log.Warnfln("Failed to query shared rooms with %s: %v", userID, err)
return rooms
}
for rows.Next() {
var roomID id.RoomID
err = rows.Scan(&roomID)
if err != nil {
store.log.Warnfln("Failed to scan room ID: %v", err)
} else {
rooms = append(rooms, roomID)
}
}
return rooms
}

View file

@ -1,3 +1,5 @@
-- v1: Initial revision
CREATE TABLE portal (
channel_id TEXT,
receiver TEXT,
@ -9,6 +11,8 @@ CREATE TABLE portal (
avatar TEXT NOT NULL,
avatar_url TEXT,
encrypted BOOLEAN NOT NULL DEFAULT false,
type INT,
dmuser TEXT,
@ -24,7 +28,12 @@ CREATE TABLE puppet (
avatar TEXT,
avatar_url TEXT,
enable_presence BOOLEAN NOT NULL DEFAULT true
enable_presence BOOLEAN NOT NULL DEFAULT true,
enable_receipts BOOLEAN NOT NULL DEFAULT true,
custom_mxid TEXT,
access_token TEXT,
next_batch TEXT
);
CREATE TABLE "user" (
@ -38,12 +47,12 @@ CREATE TABLE "user" (
CREATE TABLE message (
channel_id TEXT NOT NULL,
receiver TEXT NOT NULL,
receiver TEXT NOT NULL,
discord_message_id TEXT NOT NULL,
matrix_message_id TEXT NOT NULL UNIQUE,
matrix_message_id TEXT NOT NULL UNIQUE,
author_id TEXT NOT NULL,
author_id TEXT NOT NULL,
timestamp BIGINT NOT NULL,
PRIMARY KEY(discord_message_id, channel_id, receiver),
@ -52,10 +61,10 @@ CREATE TABLE message (
CREATE TABLE reaction (
channel_id TEXT NOT NULL,
receiver TEXT NOT NULL,
receiver TEXT NOT NULL,
discord_message_id TEXT NOT NULL,
matrix_event_id TEXT NOT NULL UNIQUE,
matrix_event_id TEXT NOT NULL UNIQUE,
author_id TEXT NOT NULL,
@ -68,20 +77,29 @@ CREATE TABLE reaction (
FOREIGN KEY(channel_id, receiver) REFERENCES portal(channel_id, receiver) ON DELETE CASCADE
);
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 attachment (
channel_id TEXT NOT NULL,
receiver TEXT NOT NULL,
discord_message_id TEXT NOT NULL,
discord_attachment_id TEXT NOT NULL,
matrix_event_id TEXT NOT NULL UNIQUE,
PRIMARY KEY(discord_attachment_id, matrix_event_id),
FOREIGN KEY(channel_id, receiver) REFERENCES portal(channel_id, receiver) ON DELETE CASCADE
);
CREATE TABLE mx_registrations (
user_id TEXT PRIMARY KEY
CREATE TABLE emoji (
discord_id TEXT PRIMARY KEY,
discord_name TEXT,
matrix_url TEXT
);
CREATE TABLE mx_room_state (
room_id TEXT PRIMARY KEY,
power_levels TEXT
CREATE TABLE guild (
discord_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
guild_name TEXT NOT NULL,
bridge BOOLEAN DEFAULT FALSE,
PRIMARY KEY(discord_id, guild_id)
);

View file

@ -0,0 +1,32 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package upgrades
import (
"embed"
"maunium.net/go/mautrix/util/dbutil"
)
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() {
Table.RegisterFS(rawUpgrades)
}

View file

@ -4,7 +4,9 @@ import (
"database/sql"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type User struct {
@ -19,10 +21,11 @@ type User struct {
Token string
}
func (u *User) Scan(row Scannable) *User {
func (u *User) Scan(row dbutil.Scannable) *User {
var token sql.NullString
var discordID sql.NullString
err := row.Scan(&u.MXID, &u.ID, &u.ManagementRoom, &token)
err := row.Scan(&u.MXID, &discordID, &u.ManagementRoom, &token)
if err != nil {
if err != sql.ErrNoRows {
u.log.Errorln("Database scan failed:", err)
@ -35,6 +38,10 @@ func (u *User) Scan(row Scannable) *User {
u.Token = token.String
}
if discordID.Valid {
u.ID = discordID.String
}
return u
}
@ -44,13 +51,19 @@ func (u *User) Insert() {
" VALUES ($1, $2, $3, $4);"
var token sql.NullString
var discordID sql.NullString
if u.Token != "" {
token.String = u.Token
token.Valid = true
}
_, err := u.db.Exec(query, u.MXID, u.ID, u.ManagementRoom, token)
if u.ID != "" {
discordID.String = u.ID
discordID.Valid = true
}
_, err := u.db.Exec(query, u.MXID, discordID, u.ManagementRoom, token)
if err != nil {
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
@ -63,13 +76,19 @@ func (u *User) Update() {
" WHERE mxid=$4;"
var token sql.NullString
var discordID sql.NullString
if u.Token != "" {
token.String = u.Token
token.Valid = true
}
_, err := u.db.Exec(query, u.ID, u.ManagementRoom, token, u.MXID)
if u.ID != "" {
discordID.String = u.ID
discordID.Valid = true
}
_, err := u.db.Exec(query, discordID, u.ManagementRoom, token, u.MXID)
if err != nil {
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)

View file

@ -1,4 +1,4 @@
package bridge
package main
import (
"github.com/bwmarrin/discordgo"

View file

@ -1,4 +1,4 @@
package bridge
package main
import (
"io/ioutil"
@ -10,7 +10,7 @@ import (
"maunium.net/go/mautrix/id"
)
func (p *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string, error) {
func (portal *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string, error) {
var url string
var mimeType string
@ -43,7 +43,7 @@ func (p *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string,
return data, mimeType, err
}
func (p *Portal) uploadMatrixEmoji(intent *appservice.IntentAPI, data []byte, mimeType string) (id.ContentURI, error) {
func (portal *Portal) uploadMatrixEmoji(intent *appservice.IntentAPI, data []byte, mimeType string) (id.ContentURI, error) {
uploaded, err := intent.UploadBytes(data, mimeType)
if err != nil {
return id.ContentURI{}, err

View file

@ -11,6 +11,8 @@ homeserver:
# If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes.
# The bridge will use the appservice as_token to authorize requests.
status_endpoint: null
# Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
@ -23,6 +25,7 @@ appservice:
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29334
# Database config.
database:
# The database type. "sqlite3" and "postgres" are supported.
@ -40,19 +43,16 @@ appservice:
max_conn_idle_time: null
max_conn_lifetime: null
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
prefix: /_matrix/provision
# Shared secret for authentication. If set to "generate", a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# The unique ID of this appservice.
id: discord
# Appservice bot details.
bot:
username: discordbot
displayname: Discord bridge bot
avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC
# Username of the appservice bot.
username: discordbot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
displayname: Discord bridge bot
avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
@ -71,6 +71,7 @@ bridge:
# Displayname template for Discord users.
# TODO: document variables
displayname_template: '{{.Username}}#{{.Discriminator}} (D){{if .Bot}} (bot){{end}}'
channelname_template: '{{if .Guild}}{{.Guild}} - {{end}}{{if .Folder}}{{.Folder}} - {{end}}{{.Name}} (D)'
portal_message_buffer: 128
@ -99,12 +100,12 @@ bridge:
example.com: foobar
# The prefix for commands. Only required in non-management rooms.
command_prefix: '!dis'
command_prefix: '!discord'
# Messages sent upon joining a management room.
# Markdown is supported. The defaults are listed below.
management_room_text:
# Sent when joining a room.
welcome: "Hello, I'm a WhatsApp bridge bot."
welcome: "Hello, I'm a Discord bridge bot."
# Sent when joining a management room and the user is already logged in.
welcome_connected: "Use `help` for help."
# Sent when joining a management room and the user is not logged in.
@ -135,6 +136,28 @@ bridge:
# Verification by the bridge is not yet implemented.
require_verification: true
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
prefix: /_matrix/provision
# Shared secret for authentication. If set to "generate", a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# Permissions for using the bridge.
# Permitted values:
# relay - Talk through the relaybot (if enabled), no access otherwise
# user - Access to use the bridge to chat with a Discord account.
# admin - User level and some additional administration tools
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": relay
"example.com": user
"@admin:example.com": admin
logging:
directory: ./logs
file_name_format: '{{.Date}}-{{.Index}}.log'

View file

@ -1,5 +0,0 @@
package globals
type Globals struct {
Config string `kong:"flag,name='config',short='c',env='CONFIG',help='The configuration file to use',default='config.yaml'"`
}

18
go.mod
View file

@ -3,30 +3,28 @@ module go.mau.fi/mautrix-discord
go 1.17
require (
github.com/alecthomas/kong v0.5.0
github.com/bwmarrin/discordgo v0.23.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.5
github.com/lopezator/migrator v0.3.0
github.com/mattn/go-sqlite3 v1.14.12
github.com/lib/pq v1.10.6
github.com/mattn/go-sqlite3 v1.14.13
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
gopkg.in/yaml.v2 v2.4.0
maunium.net/go/maulogger/v2 v2.3.2
maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417
maunium.net/go/mautrix v0.11.1-0.20220522190042-ec20c3fc994a
)
require (
github.com/pkg/errors v0.9.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/tidwall/gjson v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.4 // indirect
github.com/yuin/goldmark v1.4.12 // indirect
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 // indirect
golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 // indirect
golang.org/x/net v0.0.0-20220513224357-95641704303c // indirect
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
gopkg.in/yaml.v3 v3.0.0 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
replace github.com/bwmarrin/discordgo v0.23.2 => gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7

59
go.sum
View file

@ -1,42 +1,23 @@
github.com/alecthomas/kong v0.5.0 h1:u8Kdw+eeml93qtMZ04iei0CFYve/WPcA5IFh+9wSskE=
github.com/alecthomas/kong v0.5.0/go.mod h1:uzxf/HUh0tj43x1AyJROl3JT7SgsZ5m+icOv1csRhc0=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lopezator/migrator v0.3.0 h1:VW/rR+J8NYwPdkBxjrFdjwejpgvP59LbmANJxXuNbuk=
github.com/lopezator/migrator v0.3.0/go.mod h1:bpVAVPkWSvTw8ya2Pk7E/KiNAyDWNImgivQY79o8/8I=
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w=
github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@ -45,47 +26,35 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc=
github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM=
github.com/yuin/goldmark v1.4.11 h1:i45YIzqLnUc2tGaTlJCyUxSG8TvgyGqhqOZOUKIjJ6w=
github.com/yuin/goldmark v1.4.11/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0=
github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7 h1:8ieR27GadHnShqhsvPrDzL1/ZOntavGGt4TXqafncYE=
gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7/go.mod h1:Hwfv4M8yP/MDh47BN+4Z1WItJ1umLKUyplCH5KcQPgE=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20220408190544-5352b0902921 h1:iU7T1X1J6yxDr0rda54sWGkHgOp5XJrqm79gcNlC2VM=
golang.org/x/crypto v0.0.0-20220408190544-5352b0902921/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f h1:OeJjE6G4dgCY4PIXvIRQbE8+RX+uXZyGhUy/ksMGJoc=
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 h1:NUzdAbFtCJSXU20AOXgeqaUwg8Ypg4MPYmL+d+rsB5c=
golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c=
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220513224357-95641704303c h1:nF9mHSvoKBLkQNQhJZNsc66z2UzAMUbLGjC95CF3pU0=
golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
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/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 h1:QyVthZKMsyaQwBTJE04jdNN0Pp5Fn9Qga0mrgxyERQM=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417 h1:dEJ9MKQvd4v2Rk2W6EUiO1T6PrSWPsB/JQOHQn4H6X0=
maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417/go.mod h1:zOor2zO/F10T/GbU67vWr0vnhLso88rlRr1HIrb1XWU=
maunium.net/go/mautrix v0.11.1-0.20220522190042-ec20c3fc994a h1:hkr4xK3sXJv+WFAVAmpzBPbT2Q3bUn9S13QFIqzJgAw=
maunium.net/go/mautrix v0.11.1-0.20220522190042-ec20c3fc994a/go.mod h1:CiKpMhAx5QZFHK03jpWb0iKI3sGU8x6+LfsOjDrcO8I=

181
main.go
View file

@ -1,43 +1,168 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"os"
_ "embed"
"sync"
"github.com/alecthomas/kong"
"go.mau.fi/mautrix-discord/database"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/configupgrade"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/consts"
"go.mau.fi/mautrix-discord/globals"
"go.mau.fi/mautrix-discord/registration"
"go.mau.fi/mautrix-discord/run"
"go.mau.fi/mautrix-discord/version"
)
var cli struct {
globals.Globals
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
GenerateConfig config.Cmd `kong:"cmd,help='Generate the default configuration and exit.'"`
GenerateRegistration registration.Cmd `kong:"cmd,help='Generate the registration file for synapse and exit.'"`
Run run.Cmd `kong:"cmd,help='Run the bridge.',default='1'"`
Version version.Cmd `kong:"cmd,help='Display the version and exit.'"`
//go:embed example-config.yaml
var ExampleConfig string
type DiscordBridge struct {
bridge.Bridge
Config *config.Config
DB *database.Database
provisioning *ProvisioningAPI
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
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
}
func (br *DiscordBridge) GetExampleConfig() string {
return ExampleConfig
}
func (br *DiscordBridge) GetConfigPtr() interface{} {
br.Config = &config.Config{
BaseConfig: &br.Bridge.Config,
}
br.Config.BaseConfig.Bridge = &br.Config.Bridge
return br.Config
}
func (br *DiscordBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands()
br.DB = database.New(br.Bridge.DB)
}
func (br *DiscordBridge) Start() {
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
br.provisioning = newProvisioningAPI(br)
}
go br.startUsers()
}
func (br *DiscordBridge) Stop() {
for _, user := range br.usersByMXID {
if user.Session == nil {
continue
}
br.Log.Debugln("Disconnecting", user.MXID)
user.Session.Close()
}
}
func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal {
p := br.GetPortalByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User {
p := br.GetUserByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) IsGhost(mxid id.UserID) bool {
_, isGhost := br.ParsePuppetMXID(mxid)
return isGhost
}
func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost {
p := br.GetPuppetByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) {
//TODO implement
}
func main() {
ctx := kong.Parse(
&cli,
kong.Name(consts.Name),
kong.Description(consts.Description),
kong.UsageOnError(),
kong.ConfigureHelp(kong.HelpOptions{
Compact: true,
Summary: true,
}),
)
br := &DiscordBridge{
usersByMXID: make(map[id.UserID]*User),
usersByID: make(map[string]*User),
err := ctx.Run(&cli.Globals)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
managementRooms: make(map[id.RoomID]*User),
portalsByMXID: make(map[id.RoomID]*Portal),
portalsByID: make(map[database.PortalKey]*Portal),
puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.",
Version: "0.1.0",
ProtocolName: "Discord",
ConfigUpgrader: &configupgrade.StructUpgrader{
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
Blocks: config.SpacedBlocks,
Base: ExampleConfig,
},
Child: br,
}
br.InitVersion(Tag, Commit, BuildTime)
br.Main()
}

1178
portal.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
package bridge
package main
import (
"bufio"
@ -25,21 +25,21 @@ const (
)
type ProvisioningAPI struct {
bridge *Bridge
bridge *DiscordBridge
log log.Logger
}
func newProvisioningAPI(bridge *Bridge) *ProvisioningAPI {
func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI {
p := &ProvisioningAPI{
bridge: bridge,
log: bridge.log.Sub("Provisioning"),
bridge: br,
log: br.Log.Sub("Provisioning"),
}
prefix := bridge.Config.Appservice.Provisioning.Prefix
prefix := br.Config.Bridge.Provisioning.Prefix
p.log.Debugln("Enabling provisioning API at", prefix)
r := bridge.as.Router.PathPrefix(prefix).Subrouter()
r := br.AS.Router.PathPrefix(prefix).Subrouter()
r.Use(p.authMiddleware)
@ -117,7 +117,7 @@ func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler {
auth = auth[len("Bearer "):]
}
if auth != p.bridge.Config.Appservice.Provisioning.SharedSecret {
if auth != p.bridge.Config.Bridge.Provisioning.SharedSecret {
jsonResponse(w, http.StatusForbidden, map[string]interface{}{
"error": "Invalid auth token",
"errcode": "M_FORBIDDEN",
@ -176,7 +176,7 @@ func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
discord := map[string]interface{}{
"logged_in": user.LoggedIn(),
"logged_in": user.IsLoggedIn(),
"connected": user.Connected(),
"conn": nil,
}
@ -210,7 +210,7 @@ func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
force := strings.ToLower(r.URL.Query().Get("force")) != "false"
if !user.LoggedIn() {
if !user.IsLoggedIn() {
jsonResponse(w, http.StatusNotFound, Error{
Error: "You're not logged in",
ErrCode: "not logged in",
@ -285,7 +285,7 @@ func (p *ProvisioningAPI) login(w http.ResponseWriter, r *http.Request) {
return nil
})
if user.LoggedIn() {
if user.IsLoggedIn() {
c.WriteJSON(Error{
Error: "You're already logged into Discord",
ErrCode: "already logged in",

299
puppet.go Normal file
View file

@ -0,0 +1,299 @@
package main
import (
"fmt"
"regexp"
"sync"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
type Puppet struct {
*database.Puppet
bridge *DiscordBridge
log log.Logger
MXID id.UserID
customIntent *appservice.IntentAPI
customUser *User
syncLock sync.Mutex
}
var _ bridge.Ghost = (*Puppet)(nil)
func (puppet *Puppet) GetMXID() id.UserID {
return puppet.MXID
}
var userIDRegex *regexp.Regexp
func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
return &Puppet{
Puppet: dbPuppet,
bridge: br,
log: br.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)),
MXID: br.FormatPuppetMXID(dbPuppet.ID),
}
}
func (br *DiscordBridge) ParsePuppetMXID(mxid id.UserID) (string, bool) {
if userIDRegex == nil {
pattern := fmt.Sprintf(
"^@%s:%s$",
br.Config.Bridge.FormatUsername("([0-9]+)"),
br.Config.Homeserver.Domain,
)
userIDRegex = regexp.MustCompile(pattern)
}
match := userIDRegex.FindStringSubmatch(string(mxid))
if len(match) == 2 {
return match[1], true
}
return "", false
}
func (br *DiscordBridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
id, ok := br.ParsePuppetMXID(mxid)
if !ok {
return nil
}
return br.GetPuppetByID(id)
}
func (br *DiscordBridge) GetPuppetByID(id string) *Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
puppet, ok := br.puppets[id]
if !ok {
dbPuppet := br.DB.Puppet.Get(id)
if dbPuppet == nil {
dbPuppet = br.DB.Puppet.New()
dbPuppet.ID = id
dbPuppet.Insert()
}
puppet = br.NewPuppet(dbPuppet)
br.puppets[puppet.ID] = puppet
}
return puppet
}
func (br *DiscordBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
puppet, ok := br.puppetsByCustomMXID[mxid]
if !ok {
dbPuppet := br.DB.Puppet.GetByCustomMXID(mxid)
if dbPuppet == nil {
return nil
}
puppet = br.NewPuppet(dbPuppet)
br.puppets[puppet.ID] = puppet
br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
return puppet
}
func (br *DiscordBridge) GetAllPuppetsWithCustomMXID() []*Puppet {
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID())
}
func (br *DiscordBridge) GetAllPuppets() []*Puppet {
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll())
}
func (br *DiscordBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
output := make([]*Puppet, len(dbPuppets))
for index, dbPuppet := range dbPuppets {
if dbPuppet == nil {
continue
}
puppet, ok := br.puppets[dbPuppet.ID]
if !ok {
puppet = br.NewPuppet(dbPuppet)
br.puppets[dbPuppet.ID] = puppet
if dbPuppet.CustomMXID != "" {
br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
}
}
output[index] = puppet
}
return output
}
func (br *DiscordBridge) FormatPuppetMXID(did string) id.UserID {
return id.NewUserID(
br.Config.Bridge.FormatUsername(did),
br.Config.Homeserver.Domain,
)
}
func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
return puppet.bridge.AS.Intent(puppet.MXID)
}
func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
if puppet.customIntent == nil {
return puppet.DefaultIntent()
}
return puppet.customIntent
}
func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
return puppet.customIntent
}
func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
for _, portal := range puppet.bridge.GetAllPortalsByID(puppet.ID) {
// Get room create lock to prevent races between receiving contact info and room creation.
portal.roomCreateLock.Lock()
meta(portal)
portal.roomCreateLock.Unlock()
}
}
func (puppet *Puppet) updateName(source *User) bool {
user, err := source.Session.User(puppet.ID)
if err != nil {
puppet.log.Warnln("failed to get user from id:", err)
return false
}
newName := puppet.bridge.Config.Bridge.FormatDisplayname(user)
if puppet.DisplayName != newName {
err := puppet.DefaultIntent().SetDisplayName(newName)
if err == nil {
puppet.DisplayName = newName
go puppet.updatePortalName()
puppet.Update()
} else {
puppet.log.Warnln("failed to set display name:", err)
}
return true
}
return false
}
func (puppet *Puppet) updatePortalName() {
puppet.updatePortalMeta(func(portal *Portal) {
if portal.MXID != "" {
_, err := portal.MainIntent().SetRoomName(portal.MXID, puppet.DisplayName)
if err != nil {
portal.log.Warnln("Failed to set name:", err)
}
}
portal.Name = puppet.DisplayName
portal.Update()
})
}
func (puppet *Puppet) updateAvatar(source *User) bool {
user, err := source.Session.User(puppet.ID)
if err != nil {
puppet.log.Warnln("Failed to get user:", err)
return false
}
if puppet.Avatar == user.Avatar {
return false
}
if user.Avatar == "" {
puppet.log.Warnln("User does not have an avatar")
return false
}
url, err := uploadAvatar(puppet.DefaultIntent(), user.AvatarURL(""))
if err != nil {
puppet.log.Warnln("Failed to upload user avatar:", err)
return false
}
puppet.AvatarURL = url
err = puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
if err != nil {
puppet.log.Warnln("Failed to set avatar:", err)
}
puppet.log.Debugln("Updated avatar", puppet.Avatar, "->", user.Avatar)
puppet.Avatar = user.Avatar
go puppet.updatePortalAvatar()
return true
}
func (puppet *Puppet) updatePortalAvatar() {
puppet.updatePortalMeta(func(portal *Portal) {
if portal.MXID != "" {
_, err := portal.MainIntent().SetRoomAvatar(portal.MXID, puppet.AvatarURL)
if err != nil {
portal.log.Warnln("Failed to set avatar:", err)
}
}
portal.AvatarURL = puppet.AvatarURL
portal.Avatar = puppet.Avatar
portal.Update()
})
}
func (puppet *Puppet) SyncContact(source *User) {
puppet.syncLock.Lock()
defer puppet.syncLock.Unlock()
puppet.log.Debugln("syncing contact", puppet.DisplayName)
err := puppet.DefaultIntent().EnsureRegistered()
if err != nil {
puppet.log.Errorln("Failed to ensure registered:", err)
}
update := false
update = puppet.updateName(source) || update
if puppet.Avatar == "" {
update = puppet.updateAvatar(source) || update
puppet.log.Debugln("update avatar returned", update)
}
if update {
puppet.Update()
}
}

View file

@ -1,68 +0,0 @@
package registration
import (
"fmt"
"os"
"regexp"
"maunium.net/go/mautrix/appservice"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/globals"
)
type Cmd struct {
Filename string `kong:"flag,help='The filename to store the registration into',name='REGISTRATION',short='r',default='registration.yaml'"`
Force bool `kong:"flag,help='Overwrite an existing registration file if it already exists',short='f',default='0'"`
}
func (c *Cmd) Run(g *globals.Globals) error {
// Check if the file exists before blinding overwriting it.
if _, err := os.Stat(c.Filename); err == nil {
if c.Force == false {
return fmt.Errorf("file %q exists, use -f to overwrite", c.Filename)
}
}
cfg, err := config.FromFile(g.Config)
if err != nil {
return err
}
registration := appservice.CreateRegistration()
// Load existing values from the config into the registration.
if err := cfg.CopyToRegistration(registration); err != nil {
return err
}
// Save the new App and Server tokens in the config.
cfg.Appservice.ASToken = registration.AppToken
cfg.Appservice.HSToken = registration.ServerToken
// Workaround for https://github.com/matrix-org/synapse/pull/5758
registration.SenderLocalpart = appservice.RandomString(32)
// Register the bot's user.
pattern := fmt.Sprintf(
"^@%s:%s$",
cfg.Appservice.Bot.Username,
cfg.Homeserver.Domain,
)
botRegex, err := regexp.Compile(pattern)
if err != nil {
return err
}
registration.Namespaces.RegisterUserIDs(botRegex, true)
// Finally save the registration and the updated config file.
if err := registration.Save(c.Filename); err != nil {
return err
}
if err := cfg.Save(g.Config); err != nil {
return err
}
return nil
}

View file

@ -1,39 +0,0 @@
package run
import (
"fmt"
"os"
"os/signal"
"syscall"
"go.mau.fi/mautrix-discord/bridge"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/globals"
)
type Cmd struct{}
func (c *Cmd) Run(g *globals.Globals) error {
fmt.Printf("g.Config: %q\n", g.Config)
cfg, err := config.FromFile(g.Config)
if err != nil {
return err
}
bridge, err := bridge.New(cfg)
if err != nil {
return err
}
if err := bridge.Start(); err != nil {
return err
}
ch := make(chan os.Signal)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
<-ch
bridge.Stop()
return nil
}

820
user.go Normal file
View file

@ -0,0 +1,820 @@
package main
import (
"errors"
"fmt"
"net/http"
"strings"
"sync"
"github.com/bwmarrin/discordgo"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
var (
ErrNotConnected = errors.New("not connected")
ErrNotLoggedIn = errors.New("not logged in")
)
type User struct {
*database.User
sync.Mutex
bridge *DiscordBridge
log log.Logger
PermissionLevel bridgeconfig.PermissionLevel
guilds map[string]*database.Guild
guildsLock sync.Mutex
Session *discordgo.Session
}
func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel {
return user.PermissionLevel
}
func (user *User) GetManagementRoomID() id.RoomID {
return user.ManagementRoom
}
func (user *User) GetMXID() id.UserID {
return user.MXID
}
func (user *User) GetCommandState() map[string]interface{} {
return nil
}
func (user *User) GetIDoublePuppet() bridge.DoublePuppet {
p := user.bridge.GetPuppetByCustomMXID(user.MXID)
if p == nil || p.CustomIntent() == nil {
return nil
}
return p
}
func (user *User) GetIGhost() bridge.Ghost {
if user.ID == "" {
return nil
}
p := user.bridge.GetPuppetByID(user.ID)
if p == nil {
return nil
}
return p
}
var _ bridge.User = (*User)(nil)
// this assume you are holding the guilds lock!!!
func (user *User) loadGuilds() {
user.guilds = map[string]*database.Guild{}
for _, guild := range user.bridge.DB.Guild.GetAll(user.ID) {
user.guilds[guild.GuildID] = guild
}
}
func (br *DiscordBridge) 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 = br.DB.User.New()
dbUser.MXID = *mxid
dbUser.Insert()
}
user := br.NewUser(dbUser)
// We assume the usersLock was acquired by our caller.
br.usersByMXID[user.MXID] = user
if user.ID != "" {
br.usersByID[user.ID] = user
}
if user.ManagementRoom != "" {
// Lock the management rooms for our update
br.managementRoomsLock.Lock()
br.managementRooms[user.ManagementRoom] = user
br.managementRoomsLock.Unlock()
}
// Load our guilds state from the database and turn it into a map
user.guildsLock.Lock()
user.loadGuilds()
user.guildsLock.Unlock()
return user
}
func (br *DiscordBridge) GetUserByMXID(userID id.UserID) *User {
// TODO: check if puppet
br.usersLock.Lock()
defer br.usersLock.Unlock()
user, ok := br.usersByMXID[userID]
if !ok {
return br.loadUser(br.DB.User.GetByMXID(userID), &userID)
}
return user
}
func (br *DiscordBridge) GetUserByID(id string) *User {
br.usersLock.Lock()
defer br.usersLock.Unlock()
user, ok := br.usersByID[id]
if !ok {
return br.loadUser(br.DB.User.GetByID(id), nil)
}
return user
}
func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
user := &User{
User: dbUser,
bridge: br,
log: br.Log.Sub("User").Sub(string(dbUser.MXID)),
guilds: map[string]*database.Guild{},
}
user.PermissionLevel = br.Config.Bridge.Permissions.Get(user.MXID)
return user
}
func (br *DiscordBridge) getAllUsers() []*User {
br.usersLock.Lock()
defer br.usersLock.Unlock()
dbUsers := br.DB.User.GetAll()
users := make([]*User, len(dbUsers))
for idx, dbUser := range dbUsers {
user, ok := br.usersByMXID[dbUser.MXID]
if !ok {
user = br.loadUser(dbUser, nil)
}
users[idx] = user
}
return users
}
func (br *DiscordBridge) startUsers() {
br.Log.Debugln("Starting users")
for _, user := range br.getAllUsers() {
go user.Connect()
}
br.Log.Debugln("Starting custom puppets")
for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() {
go func(puppet *Puppet) {
br.Log.Debugln("Starting custom puppet", puppet.CustomMXID)
if err := puppet.StartCustomMXID(true); err != nil {
puppet.log.Errorln("Failed to start custom puppet:", err)
}
}(customPuppet)
}
}
func (user *User) SetManagementRoom(roomID id.RoomID) {
user.bridge.managementRoomsLock.Lock()
defer user.bridge.managementRoomsLock.Unlock()
existing, ok := user.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()
}
user.ManagementRoom = roomID
user.bridge.managementRooms[user.ManagementRoom] = user
user.Update()
}
func (user *User) tryAutomaticDoublePuppeting() {
user.Lock()
defer user.Unlock()
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
return
}
user.log.Debugln("Checking if double puppeting needs to be enabled")
puppet := user.bridge.GetPuppetByID(user.ID)
if puppet.CustomMXID != "" {
user.log.Debugln("User already has double-puppeting enabled")
return
}
accessToken, err := puppet.loginWithSharedSecret(user.MXID)
if err != nil {
user.log.Warnln("Failed to login with shared secret:", err)
return
}
err = puppet.SwitchCustomMXID(accessToken, user.MXID)
if err != nil {
puppet.log.Warnln("Failed to switch to auto-logined custom puppet:", err)
return
}
user.log.Infoln("Successfully automatically enabled custom puppet")
}
func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) {
doublePuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID)
if doublePuppet == nil {
return
}
if doublePuppet == nil || doublePuppet.CustomIntent() == nil || portal.MXID == "" {
return
}
// TODO sync mute status
}
func (user *User) Login(token string) error {
user.Token = token
user.Update()
return user.Connect()
}
func (user *User) IsLoggedIn() bool {
user.Lock()
defer user.Unlock()
return user.Token != ""
}
func (user *User) Logout() error {
user.Lock()
defer user.Unlock()
if user.Session == nil {
return ErrNotLoggedIn
}
puppet := user.bridge.GetPuppetByID(user.ID)
if puppet.CustomMXID != "" {
err := puppet.SwitchCustomMXID("", "")
if err != nil {
user.log.Warnln("Failed to logout-matrix while logging out of Discord:", err)
}
}
if err := user.Session.Close(); err != nil {
return err
}
user.Session = nil
user.Token = ""
user.Update()
return nil
}
func (user *User) Connected() bool {
user.Lock()
defer user.Unlock()
return user.Session != nil
}
func (user *User) Connect() error {
user.Lock()
defer user.Unlock()
if user.Token == "" {
return ErrNotLoggedIn
}
user.log.Debugln("connecting to discord")
session, err := discordgo.New(user.Token)
if err != nil {
return err
}
user.Session = session
// Add our event handlers
user.Session.AddHandler(user.readyHandler)
user.Session.AddHandler(user.connectedHandler)
user.Session.AddHandler(user.disconnectedHandler)
user.Session.AddHandler(user.guildCreateHandler)
user.Session.AddHandler(user.guildDeleteHandler)
user.Session.AddHandler(user.guildUpdateHandler)
user.Session.AddHandler(user.channelCreateHandler)
user.Session.AddHandler(user.channelDeleteHandler)
user.Session.AddHandler(user.channelPinsUpdateHandler)
user.Session.AddHandler(user.channelUpdateHandler)
user.Session.AddHandler(user.messageCreateHandler)
user.Session.AddHandler(user.messageDeleteHandler)
user.Session.AddHandler(user.messageUpdateHandler)
user.Session.AddHandler(user.reactionAddHandler)
user.Session.AddHandler(user.reactionRemoveHandler)
user.Session.Identify.Presence.Status = "online"
return user.Session.Open()
}
func (user *User) Disconnect() error {
user.Lock()
defer user.Unlock()
if user.Session == nil {
return ErrNotConnected
}
if err := user.Session.Close(); err != nil {
return err
}
user.Session = nil
return nil
}
func (user *User) bridgeMessage(guildID string) bool {
// Non guild message always get bridged.
if guildID == "" {
return true
}
user.guildsLock.Lock()
defer user.guildsLock.Unlock()
if guild, found := user.guilds[guildID]; found {
if guild.Bridge {
return true
}
}
user.log.Debugfln("ignoring message for non-bridged guild %s-%s", user.ID, guildID)
return false
}
func (user *User) readyHandler(s *discordgo.Session, r *discordgo.Ready) {
user.log.Debugln("discord connection ready")
// Update our user fields
user.ID = r.User.ID
// Update our guild map to match watch discord thinks we're in. This is the
// only time we can get the full guild map as discordgo doesn't make it
// available to us later. Also, discord might not give us the full guild
// information here, so we use this to remove guilds the user left and only
// add guilds whose full information we have. The are told about the
// "unavailable" guilds later via the GuildCreate handler.
user.guildsLock.Lock()
defer user.guildsLock.Unlock()
// build a list of the current guilds we're in so we can prune the old ones
current := []string{}
user.log.Debugln("database guild count", len(user.guilds))
user.log.Debugln("discord guild count", len(r.Guilds))
for _, guild := range r.Guilds {
current = append(current, guild.ID)
// If we already know about this guild, make sure we reset it's bridge
// status.
if val, found := user.guilds[guild.ID]; found {
bridge := val.Bridge
user.guilds[guild.ID].Bridge = bridge
// Update the name if the guild is available
if !guild.Unavailable {
user.guilds[guild.ID].GuildName = guild.Name
}
val.Upsert()
} else {
g := user.bridge.DB.Guild.New()
g.DiscordID = user.ID
g.GuildID = guild.ID
user.guilds[guild.ID] = g
if !guild.Unavailable {
g.GuildName = guild.Name
}
g.Upsert()
}
}
// Sync the guilds to the database.
user.bridge.DB.Guild.Prune(user.ID, current)
// Finally reload from the database since it purged servers we're not in
// anymore.
user.loadGuilds()
user.log.Debugln("updated database guild count", len(user.guilds))
user.Update()
}
func (user *User) connectedHandler(s *discordgo.Session, c *discordgo.Connect) {
user.log.Debugln("connected to discord")
user.tryAutomaticDoublePuppeting()
}
func (user *User) disconnectedHandler(s *discordgo.Session, d *discordgo.Disconnect) {
user.log.Debugln("disconnected from discord")
}
func (user *User) guildCreateHandler(s *discordgo.Session, g *discordgo.GuildCreate) {
user.guildsLock.Lock()
defer user.guildsLock.Unlock()
// If we somehow already know about the guild, just update it's name
if guild, found := user.guilds[g.ID]; found {
guild.GuildName = g.Name
guild.Upsert()
return
}
// This is a brand new guild so lets get it added.
guild := user.bridge.DB.Guild.New()
guild.DiscordID = user.ID
guild.GuildID = g.ID
guild.GuildName = g.Name
guild.Upsert()
user.guilds[g.ID] = guild
}
func (user *User) guildDeleteHandler(s *discordgo.Session, g *discordgo.GuildDelete) {
user.guildsLock.Lock()
defer user.guildsLock.Unlock()
if guild, found := user.guilds[g.ID]; found {
guild.Delete()
delete(user.guilds, g.ID)
user.log.Debugln("deleted guild", g.Guild.ID)
}
}
func (user *User) guildUpdateHandler(s *discordgo.Session, g *discordgo.GuildUpdate) {
user.guildsLock.Lock()
defer user.guildsLock.Unlock()
// If we somehow already know about the guild, just update it's name
if guild, found := user.guilds[g.ID]; found {
guild.GuildName = g.Name
guild.Upsert()
user.log.Debugln("updated guild", g.ID)
}
}
func (user *User) createChannel(c *discordgo.Channel) {
key := database.NewPortalKey(c.ID, user.User.ID)
portal := user.bridge.GetPortalByID(key)
if portal.MXID != "" {
return
}
portal.Name = c.Name
portal.Topic = c.Topic
portal.Type = c.Type
if portal.Type == discordgo.ChannelTypeDM {
portal.DMUser = c.Recipients[0].ID
}
if c.Icon != "" {
user.log.Debugln("channel icon", c.Icon)
}
portal.Update()
portal.createMatrixRoom(user, c)
}
func (user *User) channelCreateHandler(s *discordgo.Session, c *discordgo.ChannelCreate) {
user.createChannel(c.Channel)
}
func (user *User) channelDeleteHandler(s *discordgo.Session, c *discordgo.ChannelDelete) {
user.log.Debugln("channel delete handler")
}
func (user *User) channelPinsUpdateHandler(s *discordgo.Session, c *discordgo.ChannelPinsUpdate) {
user.log.Debugln("channel pins update")
}
func (user *User) channelUpdateHandler(s *discordgo.Session, c *discordgo.ChannelUpdate) {
key := database.NewPortalKey(c.ID, user.User.ID)
portal := user.bridge.GetPortalByID(key)
portal.update(user, c.Channel)
}
func (user *User) messageCreateHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
if !user.bridgeMessage(m.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, user.ID)
portal := user.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: user,
}
portal.discordMessages <- msg
}
func (user *User) messageDeleteHandler(s *discordgo.Session, m *discordgo.MessageDelete) {
if !user.bridgeMessage(m.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, user.ID)
portal := user.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: user,
}
portal.discordMessages <- msg
}
func (user *User) messageUpdateHandler(s *discordgo.Session, m *discordgo.MessageUpdate) {
if !user.bridgeMessage(m.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, user.ID)
portal := user.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: user,
}
portal.discordMessages <- msg
}
func (user *User) reactionAddHandler(s *discordgo.Session, m *discordgo.MessageReactionAdd) {
if !user.bridgeMessage(m.MessageReaction.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, user.User.ID)
portal := user.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: user,
}
portal.discordMessages <- msg
}
func (user *User) reactionRemoveHandler(s *discordgo.Session, m *discordgo.MessageReactionRemove) {
if !user.bridgeMessage(m.MessageReaction.GuildID) {
return
}
key := database.NewPortalKey(m.ChannelID, user.User.ID)
portal := user.bridge.GetPortalByID(key)
msg := portalDiscordMessage{
msg: m,
user: user,
}
portal.discordMessages <- msg
}
func (user *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{}{},
}
customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
inviteContent.Raw["fi.mau.will_auto_accept"] = true
}
_, err := intent.SendStateEvent(roomID, event.StateMember, user.MXID.String(), &inviteContent)
var httpErr mautrix.HTTPError
if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") {
user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin)
ret = true
} else if err != nil {
user.log.Warnfln("Failed to invite user to %s: %v", roomID, err)
} else {
ret = true
}
if customPuppet != nil && customPuppet.CustomIntent() != nil {
err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
if err != nil {
user.log.Warnfln("Failed to auto-join %s: %v", roomID, err)
ret = false
} else {
ret = true
}
}
return ret
}
func (user *User) getDirectChats() map[id.UserID][]id.RoomID {
chats := map[id.UserID][]id.RoomID{}
privateChats := user.bridge.DB.Portal.FindPrivateChats(user.ID)
for _, portal := range privateChats {
if portal.MXID != "" {
puppetMXID := user.bridge.FormatPuppetMXID(portal.Key.Receiver)
chats[puppetMXID] = []id.RoomID{portal.MXID}
}
}
return chats
}
func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
if !user.bridge.Config.Bridge.SyncDirectChatList {
return
}
puppet := user.bridge.GetPuppetByMXID(user.MXID)
if puppet == nil {
return
}
intent := puppet.CustomIntent()
if intent == nil {
return
}
method := http.MethodPatch
if chats == nil {
chats = user.getDirectChats()
method = http.MethodPut
}
user.log.Debugln("Updating m.direct list on homeserver")
var err error
if user.bridge.Config.Homeserver.Asmux {
urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"})
_, err = intent.MakeFullRequest(mautrix.FullRequest{
Method: method,
URL: urlPath,
Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}},
RequestJSON: chats,
})
} else {
existingChats := map[id.UserID][]id.RoomID{}
err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats)
if err != nil {
user.log.Warnln("Failed to get m.direct list to update it:", err)
return
}
for userID, rooms := range existingChats {
if _, ok := user.bridge.ParsePuppetMXID(userID); !ok {
// This is not a ghost user, include it in the new list
chats[userID] = rooms
} else if _, ok := chats[userID]; !ok && method == http.MethodPatch {
// This is a ghost user, but we're not replacing the whole list, so include it too
chats[userID] = rooms
}
}
err = intent.SetAccountData(event.AccountDataDirectChats.Type, &chats)
}
if err != nil {
user.log.Warnln("Failed to update m.direct list:", err)
}
}
func (user *User) bridgeGuild(guildID string, everything bool) error {
user.guildsLock.Lock()
defer user.guildsLock.Unlock()
guild, found := user.guilds[guildID]
if !found {
return fmt.Errorf("guildID not found")
}
// Update the guild
guild.Bridge = true
guild.Upsert()
// If this is a full bridge, create portals for all the channels
if everything {
channels, err := user.Session.GuildChannels(guildID)
if err != nil {
return err
}
for _, channel := range channels {
if channelIsBridgeable(channel) {
user.createChannel(channel)
}
}
}
return nil
}
func (user *User) unbridgeGuild(guildID string) error {
user.guildsLock.Lock()
defer user.guildsLock.Unlock()
guild, exists := user.guilds[guildID]
if !exists {
return fmt.Errorf("guildID not found")
}
if !guild.Bridge {
return fmt.Errorf("guild not bridged")
}
// First update the guild so we don't have any other go routines recreating
// channels we're about to destroy.
guild.Bridge = false
guild.Upsert()
// Now run through the channels in the guild and remove any portals we
// have for them.
channels, err := user.Session.GuildChannels(guildID)
if err != nil {
return err
}
for _, channel := range channels {
if channelIsBridgeable(channel) {
key := database.PortalKey{
ChannelID: channel.ID,
Receiver: user.ID,
}
portal := user.bridge.GetPortalByID(key)
portal.leave(user)
}
}
return nil
}

View file

@ -1,16 +0,0 @@
package version
import (
"fmt"
"go.mau.fi/mautrix-discord/consts"
"go.mau.fi/mautrix-discord/globals"
)
type Cmd struct{}
func (c *Cmd) Run(g *globals.Globals) error {
fmt.Printf("%s %s\n", consts.Name, String)
return nil
}

View file

@ -1,3 +0,0 @@
package version
const String = "0.0.1"