From 86c6bab1ebc42aa91b2df7624fbeb2d27cc0b00d Mon Sep 17 00:00:00 2001 From: watsonb8 Date: Thu, 7 Sep 2023 14:01:41 -0500 Subject: [PATCH] Somewhat correctly setting up rooms and puppets --- custompuppet.go | 2 +- main.go | 331 +++++++++++- portal.go | 1352 ++++++++++++++++++++++------------------------- puppet.go | 150 +----- user.go | 759 ++++++++++++-------------- 5 files changed, 1315 insertions(+), 1279 deletions(-) diff --git a/custompuppet.go b/custompuppet.go index 847e57d..abe160e 100644 --- a/custompuppet.go +++ b/custompuppet.go @@ -228,7 +228,7 @@ func (puppet *Puppet) tryRelogin(cause error, action string) bool { } func (puppet *Puppet) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) { - puppet.log.Warnln("Sync error:", err) + puppet.log.Warnln("SyncGroup error:", err) if errors.Is(err, mautrix.MUnknownToken) { if !puppet.tryRelogin(err, "syncing") { return 0, err diff --git a/main.go b/main.go index d5fe05d..35889bb 100644 --- a/main.go +++ b/main.go @@ -18,8 +18,11 @@ package main import ( _ "embed" + "fmt" "github.com/beeper/groupme-lib" "go.mau.fi/util/configupgrade" + "maunium.net/go/mautrix/bridge/bridgeconfig" + "regexp" "sync" "maunium.net/go/mautrix" @@ -43,6 +46,8 @@ var ( //go:embed example-config.yaml var ExampleConfig string +const unstableFeatureBatchSending = "org.matrix.msc2716" + type GMBridge struct { bridge.Bridge Config *config.Config @@ -150,7 +155,331 @@ func (br *GMBridge) GetConfigPtr() interface{} { return br.Config } -const unstableFeatureBatchSending = "org.matrix.msc2716" +func (bridge *GMBridge) GetPortalByGMID(key database.PortalKey) *Portal { + bridge.portalsLock.Lock() + defer bridge.portalsLock.Unlock() + portal, ok := bridge.portalsByGMID[key] + if !ok { + return bridge.loadDBPortal(bridge.DB.Portal.GetByGMID(key), &key) + } + return portal +} + +func (br *GMBridge) GetAllPortals() []*Portal { + return br.dbPortalsToPortals(br.DB.Portal.GetAll()) +} + +func (br *GMBridge) GetAllIPortals() (iportals []bridge.Portal) { + portals := br.GetAllPortals() + iportals = make([]bridge.Portal, len(portals)) + for i, portal := range portals { + iportals[i] = portal + } + return iportals +} + +func (br *GMBridge) GetAllPortalsByGMID(gmid groupme.ID) []*Portal { + return br.dbPortalsToPortals(br.DB.Portal.GetAllByGMID(gmid)) +} + +func (bridge *GMBridge) GetPortalByMXID(mxid id.RoomID) *Portal { + bridge.portalsLock.Lock() + defer bridge.portalsLock.Unlock() + portal, ok := bridge.portalsByMXID[mxid] + if !ok { + return bridge.loadDBPortal(bridge.DB.Portal.GetByMXID(mxid), nil) + } + return portal +} + +func (br *GMBridge) GetIPortal(mxid id.RoomID) bridge.Portal { + p := br.GetPortalByMXID(mxid) + if p == nil { + return nil + } + return p +} + +func (br *GMBridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User { + _, isPuppet := br.ParsePuppetMXID(userID) + if isPuppet || userID == br.Bot.UserID { + return nil + } + br.usersLock.Lock() + defer br.usersLock.Unlock() + user, ok := br.usersByMXID[userID] + if !ok { + userIDPtr := &userID + if onlyIfExists { + userIDPtr = nil + } + return br.loadDBUser(br.DB.User.GetByMXID(userID), userIDPtr) + } + return user +} + +func (br *GMBridge) GetUserByMXID(userID id.UserID) *User { + return br.getUserByMXID(userID, false) +} + +func (br *GMBridge) GetIUser(userID id.UserID, create bool) bridge.User { + u := br.getUserByMXID(userID, !create) + if u == nil { + return nil + } + return u +} + +func (br *GMBridge) GetUserByMXIDIfExists(userID id.UserID) *User { + return br.getUserByMXID(userID, true) +} + +func (bridge *GMBridge) GetUserByGMID(gmid groupme.ID) *User { + bridge.usersLock.Lock() + defer bridge.usersLock.Unlock() + user, ok := bridge.usersByGMID[gmid] + if !ok { + return bridge.loadDBUser(bridge.DB.User.GetByGMID(gmid), nil) + } + return user +} + +func (br *GMBridge) GetAllUsers() []*User { + br.usersLock.Lock() + defer br.usersLock.Unlock() + dbUsers := br.DB.User.GetAll() + output := make([]*User, len(dbUsers)) + for index, dbUser := range dbUsers { + user, ok := br.usersByMXID[dbUser.MXID] + if !ok { + user = br.loadDBUser(dbUser, nil) + } + output[index] = user + } + return output +} + +func (br *GMBridge) loadDBUser(dbUser *database.User, mxid *id.UserID) *User { + if dbUser == nil { + if mxid == nil { + return nil + } + dbUser = br.DB.User.New() + dbUser.MXID = *mxid + dbUser.Insert() + } + user := br.NewUser(dbUser) + br.usersByMXID[user.MXID] = user + if len(user.GMID) > 0 { + br.usersByGMID[user.GMID] = user + } + if len(user.ManagementRoom) > 0 { + br.managementRooms[user.ManagementRoom] = user + } + return user +} + +func (br *GMBridge) NewUser(dbUser *database.User) *User { + user := &User{ + User: dbUser, + bridge: br, + log: br.Log.Sub("User").Sub(string(dbUser.MXID)), + + chatListReceived: make(chan struct{}, 1), + syncPortalsDone: make(chan struct{}, 1), + syncStart: make(chan struct{}, 1), + messageInput: make(chan PortalMessage), + messageOutput: make(chan PortalMessage, br.Config.Bridge.PortalMessageBuffer), + } + + user.PermissionLevel = user.bridge.Config.Bridge.Permissions.Get(user.MXID) + user.Whitelisted = user.PermissionLevel >= bridgeconfig.PermissionLevelUser + user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin + user.BridgeState = br.NewBridgeStateQueue(user) + go user.handleMessageLoop() + go user.runMessageRingBuffer() + return user +} + +func (bridge *GMBridge) GetAllPuppetsWithCustomMXID() []*Puppet { + return bridge.dbPuppetsToPuppets(bridge.DB.Puppet.GetAllWithCustomMXID()) +} + +func (bridge *GMBridge) GetAllPuppets() []*Puppet { + return bridge.dbPuppetsToPuppets(bridge.DB.Puppet.GetAll()) +} + +func (bridge *GMBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { + bridge.puppetsLock.Lock() + defer bridge.puppetsLock.Unlock() + output := make([]*Puppet, len(dbPuppets)) + for index, dbPuppet := range dbPuppets { + if dbPuppet == nil { + continue + } + puppet, ok := bridge.puppets[dbPuppet.GMID] + if !ok { + puppet = bridge.NewPuppet(dbPuppet) + bridge.puppets[dbPuppet.GMID] = puppet + if len(dbPuppet.CustomMXID) > 0 { + bridge.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet + } + } + output[index] = puppet + } + return output +} + +func (bridge *GMBridge) FormatPuppetMXID(gmid groupme.ID) id.UserID { + return id.NewUserID( + bridge.Config.Bridge.FormatUsername(gmid.String()), + bridge.Config.Homeserver.Domain) +} + +func (bridge *GMBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { + return &Puppet{ + Puppet: dbPuppet, + bridge: bridge, + log: bridge.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.GMID)), + + MXID: bridge.FormatPuppetMXID(dbPuppet.GMID), + } +} + +func (br *GMBridge) IsGhost(id id.UserID) bool { + _, ok := br.ParsePuppetMXID(id) + return ok +} + +func (br *GMBridge) GetIGhost(id id.UserID) bridge.Ghost { + p := br.GetPuppetByMXID(id) + if p == nil { + return nil + } + return p +} + +func (bridge *GMBridge) ParsePuppetMXID(mxid id.UserID) (groupme.ID, bool) { + if userIDRegex == nil { + userIDRegex = regexp.MustCompile(fmt.Sprintf("^@%s:%s$", + bridge.Config.Bridge.FormatUsername("([0-9]+)"), + bridge.Config.Homeserver.Domain)) + } + match := userIDRegex.FindStringSubmatch(string(mxid)) + if match == nil || len(match) != 2 { + return "", false + } + + return groupme.ID(match[1]), true +} + +func (bridge *GMBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { + gmid, ok := bridge.ParsePuppetMXID(mxid) + if !ok { + return nil + } + + return bridge.GetPuppetByGMID(gmid) +} + +func (bridge *GMBridge) GetPuppetByGMID(gmid groupme.ID) *Puppet { + bridge.puppetsLock.Lock() + defer bridge.puppetsLock.Unlock() + puppet, ok := bridge.puppets[gmid] + if !ok { + dbPuppet := bridge.DB.Puppet.Get(gmid) + if dbPuppet == nil { + dbPuppet = bridge.DB.Puppet.New() + dbPuppet.GMID = gmid + dbPuppet.Insert() + } + puppet = bridge.NewPuppet(dbPuppet) + bridge.puppets[puppet.GMID] = puppet + if len(puppet.CustomMXID) > 0 { + bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet + } + } + return puppet +} + +func (bridge *GMBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet { + bridge.puppetsLock.Lock() + defer bridge.puppetsLock.Unlock() + puppet, ok := bridge.puppetsByCustomMXID[mxid] + if !ok { + dbPuppet := bridge.DB.Puppet.GetByCustomMXID(mxid) + if dbPuppet == nil { + return nil + } + puppet = bridge.NewPuppet(dbPuppet) + bridge.puppets[puppet.GMID] = puppet + bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet + } + return puppet +} + +func (bridge *GMBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal { + bridge.portalsLock.Lock() + defer bridge.portalsLock.Unlock() + output := make([]*Portal, len(dbPortals)) + for index, dbPortal := range dbPortals { + if dbPortal == nil { + continue + } + portal, ok := bridge.portalsByGMID[dbPortal.Key] + if !ok { + portal = bridge.loadDBPortal(dbPortal, nil) + } + output[index] = portal + } + return output +} + +func (bridge *GMBridge) loadDBPortal(dbPortal *database.Portal, key *database.PortalKey) *Portal { + if dbPortal == nil { + if key == nil { + return nil + } + dbPortal = bridge.DB.Portal.New() + dbPortal.Key = *key + dbPortal.Insert() + } + portal := bridge.NewPortal(dbPortal) + bridge.portalsByGMID[portal.Key] = portal + if len(portal.MXID) > 0 { + bridge.portalsByMXID[portal.MXID] = portal + } + return portal +} + +func (bridge *GMBridge) NewManualPortal(key database.PortalKey) *Portal { + portal := &Portal{ + Portal: bridge.DB.Portal.New(), + bridge: bridge, + log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", key)), + + recentlyHandled: make([]string, recentlyHandledLength), + + messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), + } + portal.Key = key + go portal.handleMessageLoop() + return portal +} + +func (bridge *GMBridge) NewPortal(dbPortal *database.Portal) *Portal { + portal := &Portal{ + Portal: dbPortal, + bridge: bridge, + log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)), + + recentlyHandled: make([]string, recentlyHandledLength), + + messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), + } + go portal.handleMessageLoop() + return portal +} func (br *GMBridge) CheckFeatures(versions *mautrix.RespVersions) (string, bool) { if br.Config.Bridge.HistorySync.Backfill { diff --git a/portal.go b/portal.go index 8d0024c..6360ee2 100644 --- a/portal.go +++ b/portal.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "github.com/gabriel-vasile/mimetype" "image" "io/ioutil" "math" @@ -37,8 +38,6 @@ import ( "maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/crypto/attachment" - "github.com/gabriel-vasile/mimetype" - "github.com/beeper/groupme-lib" "maunium.net/go/mautrix" @@ -50,135 +49,14 @@ import ( "github.com/beeper/groupme/groupmeext" ) -func (bridge *GMBridge) GetPortalByMXID(mxid id.RoomID) *Portal { - bridge.portalsLock.Lock() - defer bridge.portalsLock.Unlock() - portal, ok := bridge.portalsByMXID[mxid] - if !ok { - return bridge.loadDBPortal(bridge.DB.Portal.GetByMXID(mxid), nil) - } - return portal -} - -func (br *GMBridge) GetIPortal(mxid id.RoomID) bridge.Portal { - p := br.GetPortalByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (portal *Portal) IsEncrypted() bool { - return portal.Encrypted -} - -func (portal *Portal) MarkEncrypted() { - portal.Encrypted = true - portal.Update(nil) -} - -func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { - if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser { - portal.matrixMessages <- PortalMatrixMessage{user: user.(*User), evt: evt, receivedAt: time.Now()} - } -} - -func (bridge *GMBridge) GetPortalByGMID(key database.PortalKey) *Portal { - bridge.portalsLock.Lock() - defer bridge.portalsLock.Unlock() - portal, ok := bridge.portalsByGMID[key] - if !ok { - return bridge.loadDBPortal(bridge.DB.Portal.GetByGMID(key), &key) - } - return portal -} - -func (br *GMBridge) GetAllPortals() []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAll()) -} - -func (br *GMBridge) GetAllIPortals() (iportals []bridge.Portal) { - portals := br.GetAllPortals() - iportals = make([]bridge.Portal, len(portals)) - for i, portal := range portals { - iportals[i] = portal - } - return iportals -} - -func (br *GMBridge) GetAllPortalsByGMID(gmid groupme.ID) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAllByGMID(gmid)) -} - -func (bridge *GMBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal { - bridge.portalsLock.Lock() - defer bridge.portalsLock.Unlock() - output := make([]*Portal, len(dbPortals)) - for index, dbPortal := range dbPortals { - if dbPortal == nil { - continue - } - portal, ok := bridge.portalsByGMID[dbPortal.Key] - if !ok { - portal = bridge.loadDBPortal(dbPortal, nil) - } - output[index] = portal - } - return output -} - -func (bridge *GMBridge) loadDBPortal(dbPortal *database.Portal, key *database.PortalKey) *Portal { - if dbPortal == nil { - if key == nil { - return nil - } - dbPortal = bridge.DB.Portal.New() - dbPortal.Key = *key - dbPortal.Insert() - } - portal := bridge.NewPortal(dbPortal) - bridge.portalsByGMID[portal.Key] = portal - if len(portal.MXID) > 0 { - bridge.portalsByMXID[portal.MXID] = portal - } - return portal -} - -func (portal *Portal) GetUsers() []*User { - return nil -} - -func (bridge *GMBridge) NewManualPortal(key database.PortalKey) *Portal { - portal := &Portal{ - Portal: bridge.DB.Portal.New(), - bridge: bridge, - log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", key)), - - recentlyHandled: make([]string, recentlyHandledLength), - - messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), - } - portal.Key = key - go portal.handleMessageLoop() - return portal -} - -func (bridge *GMBridge) NewPortal(dbPortal *database.Portal) *Portal { - portal := &Portal{ - Portal: dbPortal, - bridge: bridge, - log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)), - - recentlyHandled: make([]string, recentlyHandledLength), - - messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), - } - go portal.handleMessageLoop() - return portal -} - +const MessageSendRetries = 5 +const MediaUploadRetries = 5 +const BadGatewaySleep = 5 * time.Second +const MaxMessageAgeToCreatePortal = 5 * 60 // 5 minutes const recentlyHandledLength = 100 +//var timeout = errors.New("message sending timed out") + type PortalMessage struct { chat database.PortalKey source *User @@ -216,35 +94,44 @@ type Portal struct { hasRelaybot *bool } -const MaxMessageAgeToCreatePortal = 5 * 60 // 5 minutes - -func (portal *Portal) handleMessageLoop() { - for msg := range portal.messages { - if len(portal.MXID) == 0 { - if msg.timestamp+MaxMessageAgeToCreatePortal < uint64(time.Now().Unix()) { - portal.log.Debugln("Not creating portal room for incoming message: message is too old") - continue - } - portal.log.Debugln("Creating Matrix room from incoming message") - err := portal.CreateMatrixRoom(msg.source) - if err != nil { - portal.log.Errorln("Failed to create portal room:", err) - continue - } - } - portal.handleMessage(msg) - } +type BridgeInfoSection struct { + ID string `json:"id"` + DisplayName string `json:"displayname,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + ExternalURL string `json:"external_url,omitempty"` } -func (portal *Portal) handleMessage(msg PortalMessage) { - if len(portal.MXID) == 0 { - portal.log.Warnln("handleMessage called even though portal.MXID is empty") - return - } - portal.HandleTextMessage(msg.source, msg.data) - // portal.handleReaction(msg.data.ID.String(), msg.data.FavoritedBy) +type BridgeInfoContent struct { + BridgeBot id.UserID `json:"bridgebot"` + Creator id.UserID `json:"creator,omitempty"` + Protocol BridgeInfoSection `json:"protocol"` + Network *BridgeInfoSection `json:"network,omitempty"` + Channel BridgeInfoSection `json:"channel"` } +// Public Properties + +func (portal *Portal) IsEncrypted() bool { + return portal.Encrypted +} + +func (portal *Portal) IsPrivateChat() bool { + return portal.Key.IsPrivate() +} + +func (portal *Portal) IsStatusBroadcastRoom() bool { + return portal.Key.GMID == "status@broadcast" +} + +func (portal *Portal) MainIntent() *appservice.IntentAPI { + if portal.IsPrivateChat() { + return portal.bridge.GetPuppetByGMID(portal.Key.GMID).DefaultIntent() + } + return portal.bridge.Bot +} + +// Private Properties + func (portal *Portal) isRecentlyHandled(id groupme.ID) bool { idStr := id.String() for i := recentlyHandledLength - 1; i >= 0; i-- { @@ -264,139 +151,122 @@ func (portal *Portal) isDuplicate(id groupme.ID) bool { return false } -func init() { +func (portal *Portal) wasMessageSent(sender *User, id string) bool { + return true } -func (portal *Portal) markHandled(source *User, message *groupme.Message, mxid id.EventID) { - msg := portal.bridge.DB.Message.New() - msg.Chat = portal.Key - msg.GMID = message.ID - msg.MXID = mxid - msg.Timestamp = message.CreatedAt.ToTime() - if message.UserID == source.GMID { - msg.Sender = source.GMID - } else if portal.IsPrivateChat() { - msg.Sender = portal.Key.GMID - } else { - msg.Sender = message.SenderID +// Public Methods + +func (portal *Portal) SyncDM(user *User, dm *groupme.Chat) { + if !portal.IsPrivateChat() { + return } - // msg.Insert() + portal.log.Infoln("Syncing portal for", user.MXID) - portal.recentlyHandledLock.Lock() - portal.recentlyHandled[0] = "" //FIFO queue being implemented here //TODO: is this efficent - portal.recentlyHandled = portal.recentlyHandled[1:] - portal.recentlyHandled = append(portal.recentlyHandled, message.ID.String()) - portal.recentlyHandledLock.Unlock() -} - -func (portal *Portal) getMessageIntent(user *User, info *groupme.Message) *appservice.IntentAPI { - if portal.IsPrivateChat() { - if info.UserID == user.GetGMID() { //from me - return portal.bridge.GetPuppetByGMID(user.GMID).DefaultIntent() - } - return portal.MainIntent() - } else if len(info.UserID.String()) == 0 { - println("TODO weird uid stuff") - } else if info.UserID == user.GetGMID() { //from me - return portal.bridge.GetPuppetByGMID(user.GMID).IntentFor(portal) + err := user.Conn.SubscribeToDM(context.TODO(), dm.LastMessage.ConversationID, user.Token) + if err != nil { + portal.log.Errorln("Subscribing failed, live metadata updates won't work", err) } - return portal.bridge.GetPuppetByGMID(info.UserID).IntentFor(portal) -} -func (portal *Portal) getReactionIntent(jid groupme.ID) *appservice.IntentAPI { - return portal.bridge.GetPuppetByGMID(jid).IntentFor(portal) -} - -func (portal *Portal) startHandling(source *User, info *groupme.Message) *appservice.IntentAPI { - // TODO these should all be trace logs - if portal.lastMessageTs > uint64(info.CreatedAt.ToTime().Unix()+1) { - portal.log.Debugfln("Not handling %s: message is older (%d) than last bridge message (%d)", info.ID, info.CreatedAt, portal.lastMessageTs) - } else if portal.isRecentlyHandled(info.ID) { - portal.log.Debugfln("Not handling %s: message was recently handled", info.ID) - } else if portal.isDuplicate(info.ID) { - portal.log.Debugfln("Not handling %s: message is duplicate", info.ID) - } else if info.System { - portal.log.Debugfln("Not handling %s: message is from system: %s", info.ID, info.Text) - } else { - portal.lastMessageTs = uint64(info.CreatedAt.ToTime().Unix()) - intent := portal.getMessageIntent(source, info) - if intent != nil { - portal.log.Debugfln("Starting handling of %s (ts: %d)", info.ID, info.CreatedAt) + if len(portal.MXID) == 0 { + puppet := user.bridge.GetPuppetByGMID(portal.Key.GMID) + // "" for overall user not related to one group + puppet.Sync(user, &groupme.Member{ + UserID: dm.OtherUser.ID, + Nickname: dm.OtherUser.Name, + ImageURL: dm.OtherUser.AvatarURL, + }, false, false) + if portal.bridge.Config.Bridge.PrivateChatPortalMeta || portal.bridge.Config.Bridge.Encryption.Default { + portal.Name = puppet.Displayname + portal.AvatarURL = puppet.AvatarURL + portal.Avatar = puppet.Avatar } else { - portal.log.Debugfln("Not handling %s: sender is not known", info.ID.String()) + portal.Name = "" } - return intent - } - return nil -} - -func (portal *Portal) finishHandling(source *User, message *groupme.Message, mxid id.EventID) { - portal.markHandled(source, message, mxid) - portal.sendDeliveryReceipt(mxid) - portal.log.Debugln("Handled message", message.ID.String(), "->", mxid) -} - -func (portal *Portal) SyncParticipants(group *groupme.Group) { - changed := false - levels, err := portal.MainIntent().PowerLevels(portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - changed = true - } - participantMap := make(map[groupme.ID]bool) - for _, participant := range group.Members { - participantMap[participant.UserID] = true - user := portal.bridge.GetUserByGMID(participant.UserID) - portal.userMXIDAction(user, portal.ensureMXIDInvited) - - puppet := portal.bridge.GetPuppetByGMID(participant.UserID) - err := puppet.IntentFor(portal).EnsureJoined(portal.MXID) + portal.Topic = "GroupMe private chat" + err := portal.createMatrixRoom(user) if err != nil { - portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.ID.String(), portal.MXID, err) + portal.log.Errorln("Failed to create portal room:", err) + return } - expectedLevel := 0 - // if participant.IsSuperAdmin { - // expectedLevel = 95 - // } else if participant.IsAdmin { - // expectedLevel = 50 - // } - changed = levels.EnsureUserLevel(puppet.MXID, expectedLevel) || changed - if user != nil { - changed = levels.EnsureUserLevel(user.MXID, expectedLevel) || changed + customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) + if customPuppet != nil && customPuppet.CustomIntent() != nil { + _ = customPuppet.CustomIntent().EnsureJoined(portal.MXID) } - puppet.Sync(nil, participant, false, false) - } - if changed { - _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) - if err != nil { - portal.log.Errorln("Failed to change power levels:", err) - } - } - members, err := portal.MainIntent().JoinedMembers(portal.MXID) - if err != nil { - portal.log.Warnln("Failed to get member list:", err) - } else { - for member := range members.Joined { - jid, ok := portal.bridge.ParsePuppetMXID(member) - if ok { - _, shouldBePresent := participantMap[jid] - if !shouldBePresent { - _, err := portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ - UserID: member, - Reason: "User had left this GroupMe chat", - }) - if err != nil { - portal.log.Warnfln("Failed to kick user %s who had left: %v", member, err) - } - } + + if portal.bridge.Config.Bridge.Encryption.Default { + err = portal.bridge.Bot.EnsureJoined(portal.MXID) + if err != nil { + portal.log.Errorln("Failed to join created portal with bridge bot for e2be:", err) } } + + user.UpdateDirectChats(map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}) + + } else { + portal.ensureUserInvited(user) + } + + if portal.IsPrivateChat() { + return + } + + update := false + update = portal.UpdateName(dm.OtherUser.Name, "", false) || update + update = portal.UpdateAvatar(user, dm.OtherUser.AvatarURL, false) || update + + if update { + portal.Update(nil) + portal.UpdateBridgeInfo() + } + +} + +func (portal *Portal) SyncGroup(user *User, group *groupme.Group) { + if portal.IsPrivateChat() { + return + } + portal.log.Infoln("Syncing portal for", user.MXID) + + err := user.Conn.SubscribeToGroup(context.TODO(), portal.Key.GMID, user.Token) + if err != nil { + portal.log.Errorln("Subscribing failed, live metadata updates won't work", err) + } + + if len(portal.MXID) == 0 { + portal.Name = group.Name + portal.Topic = group.Description + + err := portal.createMatrixRoom(user) + if err != nil { + portal.log.Errorln("Failed to create portal room:", err) + return + } + portal.syncParticipants(group) + } else { + portal.ensureUserInvited(user) + } + + update := false + update = portal.updateMetadata(user) || update + update = portal.UpdateAvatar(user, group.ImageURL, false) || update + + if update { + portal.Update(nil) + portal.UpdateBridgeInfo() } } -func (user *User) updateAvatar(gmdi groupme.ID, avatarID *string, avatarURL *id.ContentURI, avatarSet *bool, log log.Logger, intent *appservice.IntentAPI) bool { - return false +func (portal *Portal) MarkEncrypted() { + portal.Encrypted = true + portal.Update(nil) +} + +func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { + if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser { + portal.matrixMessages <- PortalMatrixMessage{user: user.(*User), evt: evt, receivedAt: time.Now()} + } } func (portal *Portal) UpdateAvatar(user *User, avatar string, updateInfo bool) bool { @@ -503,7 +373,7 @@ func (portal *Portal) UpdateTopic(topic string, setBy groupme.ID, updateInfo boo return false } -func (portal *Portal) UpdateMetadata(user *User) bool { +func (portal *Portal) updateMetadata(user *User) bool { if portal.IsPrivateChat() { return false } @@ -522,7 +392,7 @@ func (portal *Portal) UpdateMetadata(user *User) bool { //return false // } - portal.SyncParticipants(group) + portal.syncParticipants(group) update := false update = portal.UpdateName(group.Name, "", false) || update update = portal.UpdateTopic(group.Description, "", false) || update @@ -532,170 +402,6 @@ func (portal *Portal) UpdateMetadata(user *User) bool { return update } -func (portal *Portal) userMXIDAction(user *User, fn func(mxid id.UserID)) { - if user == nil { - return - } - - fn(user.MXID) -} - -func (portal *Portal) ensureMXIDInvited(mxid id.UserID) { - err := portal.MainIntent().EnsureInvited(portal.MXID, mxid) - if err != nil { - portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", mxid, portal.MXID, err) - } -} - -func (portal *Portal) ensureUserInvited(user *User) bool { - return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) -} - -func (portal *Portal) Sync(user *User, group *groupme.Group) { - portal.log.Infoln("Syncing portal for", user.MXID) - - sub := user.Conn.SubscribeToGroup - if portal.IsPrivateChat() { - sub = user.Conn.SubscribeToDM - } - err := sub(context.TODO(), portal.Key.GMID, user.Token) - if err != nil { - portal.log.Errorln("Subscribing failed, live metadata updates won't work", err) - } - - if len(portal.MXID) == 0 { - if !portal.IsPrivateChat() { - portal.Name = group.Name - } - err := portal.CreateMatrixRoom(user) - if err != nil { - portal.log.Errorln("Failed to create portal room:", err) - return - } - } else { - portal.ensureUserInvited(user) - } - - if portal.IsPrivateChat() { - return - } - - update := false - update = portal.UpdateMetadata(user) || update - update = portal.UpdateAvatar(user, group.ImageURL, false) || update - - if update { - portal.Update(nil) - portal.UpdateBridgeInfo() - } -} - -func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { - anyone := 0 - nope := 99 - invite := 50 - if portal.bridge.Config.Bridge.AllowUserInvite { - invite = 0 - } - return &event.PowerLevelsEventContent{ - UsersDefault: anyone, - EventsDefault: anyone, - RedactPtr: &anyone, - StateDefaultPtr: &nope, - BanPtr: &nope, - InvitePtr: &invite, - Users: map[id.UserID]int{ - portal.MainIntent().UserID: 100, - }, - Events: map[string]int{ - event.StateRoomName.Type: anyone, - event.StateRoomAvatar.Type: anyone, - event.StateTopic.Type: anyone, - }, - } -} - -func (portal *Portal) RestrictMessageSending(restrict bool) { - levels, err := portal.MainIntent().PowerLevels(portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - } - - newLevel := 0 - if restrict { - newLevel = 50 - } - - if levels.EventsDefault == newLevel { - return - } - - levels.EventsDefault = newLevel - _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) - if err != nil { - portal.log.Errorln("Failed to change power levels:", err) - } -} - -func (portal *Portal) RestrictMetadataChanges(restrict bool) { - levels, err := portal.MainIntent().PowerLevels(portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - } - newLevel := 0 - if restrict { - newLevel = 50 - } - changed := false - changed = levels.EnsureEventLevel(event.StateRoomName, newLevel) || changed - changed = levels.EnsureEventLevel(event.StateRoomAvatar, newLevel) || changed - changed = levels.EnsureEventLevel(event.StateTopic, newLevel) || changed - if changed { - _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) - if err != nil { - portal.log.Errorln("Failed to change power levels:", err) - } - } -} - -type BridgeInfoSection struct { - ID string `json:"id"` - DisplayName string `json:"displayname,omitempty"` - AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` - ExternalURL string `json:"external_url,omitempty"` -} - -type BridgeInfoContent struct { - BridgeBot id.UserID `json:"bridgebot"` - Creator id.UserID `json:"creator,omitempty"` - Protocol BridgeInfoSection `json:"protocol"` - Network *BridgeInfoSection `json:"network,omitempty"` - Channel BridgeInfoSection `json:"channel"` -} - -func (portal *Portal) getBridgeInfoStateKey() string { - return fmt.Sprintf("com.beeper.groupme://groupme/%s", portal.Key.GMID) -} - -func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { - bridgeInfo := event.BridgeEventContent{ - BridgeBot: portal.bridge.Bot.UserID, - Creator: portal.MainIntent().UserID, - Protocol: event.BridgeInfoSection{ - ID: "groupme", - DisplayName: "GroupMe", - AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - ExternalURL: "https://www.groupme.com/", - }, - Channel: event.BridgeInfoSection{ - ID: portal.Key.GMID.String(), - DisplayName: portal.Name, - AvatarURL: portal.AvatarURL.CUString(), - }, - } - return portal.getBridgeInfoStateKey(), bridgeInfo -} - func (portal *Portal) UpdateBridgeInfo() { if len(portal.MXID) == 0 { portal.log.Debugln("Not updating bridge info: no Matrix room created") @@ -723,148 +429,6 @@ func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventCon return } -func (portal *Portal) CreateMatrixRoom(user *User) error { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if len(portal.MXID) > 0 { - return nil - } - - intent := portal.MainIntent() - if err := intent.EnsureRegistered(); err != nil { - return err - } - - portal.log.Infoln("Creating Matrix room. Info source:", user.MXID) - - var metadata *groupme.Group - if portal.IsPrivateChat() { - puppet := portal.bridge.GetPuppetByGMID(portal.Key.GMID) - if portal.bridge.Config.Bridge.PrivateChatPortalMeta || portal.bridge.Config.Bridge.Encryption.Default { - portal.Name = puppet.Displayname - portal.AvatarURL = puppet.AvatarURL - portal.Avatar = puppet.Avatar - } else { - portal.Name = "" - } - portal.Topic = "GroupMe private chat" - } else { - var err error - metadata, err = user.Client.ShowGroup(context.TODO(), portal.Key.GMID) - if err == nil { - portal.Name = metadata.Name - portal.Topic = metadata.Description - } - portal.UpdateAvatar(user, metadata.ImageURL, false) - } - - bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - - initialState := []*event.Event{{ - Type: event.StatePowerLevels, - Content: event.Content{ - Parsed: portal.GetBasePowerLevels(), - }, - }, { - Type: event.StateBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }, { - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - Type: event.StateHalfShotBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }} - if !portal.AvatarURL.IsEmpty() { - initialState = append(initialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: event.RoomAvatarEventContent{URL: portal.AvatarURL}, - }, - }) - } - - invite := []id.UserID{user.MXID} - - if portal.bridge.Config.Bridge.Encryption.Default { - initialState = append(initialState, &event.Event{ - Type: event.StateEncryption, - Content: event.Content{ - Parsed: event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}, - }, - }) - portal.Encrypted = true - if portal.IsPrivateChat() { - invite = append(invite, portal.bridge.Bot.UserID) - } - } - - resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{ - Visibility: "private", - Name: portal.Name, - Topic: portal.Topic, - Invite: invite, - Preset: "private_chat", - IsDirect: portal.IsPrivateChat(), - InitialState: initialState, - }) - if err != nil { - return err - } else if len(resp.RoomID) == 0 { - return errors.New("Empty room ID") - } - portal.MXID = resp.RoomID - portal.Update(nil) - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - - // We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here. - for _, user := range invite { - portal.bridge.StateStore.SetMembership(portal.MXID, user, event.MembershipInvite) - } - - if metadata != nil { - portal.SyncParticipants(metadata) - // if metadata.Announce { - // portal.RestrictMessageSending(metadata.Announce) - // } - } else { - customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - _ = customPuppet.CustomIntent().EnsureJoined(portal.MXID) - } - } - if portal.IsPrivateChat() { - puppet := user.bridge.GetPuppetByGMID(portal.Key.GMID) - - if portal.bridge.Config.Bridge.Encryption.Default { - err = portal.bridge.Bot.EnsureJoined(portal.MXID) - if err != nil { - portal.log.Errorln("Failed to join created portal with bridge bot for e2be:", err) - } - } - - user.UpdateDirectChats(map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}) - } - return nil -} - -func (portal *Portal) IsPrivateChat() bool { - return portal.Key.IsPrivate() -} - -func (portal *Portal) IsStatusBroadcastRoom() bool { - return portal.Key.GMID == "status@broadcast" -} - -func (portal *Portal) MainIntent() *appservice.IntentAPI { - if portal.IsPrivateChat() { - return portal.bridge.GetPuppetByGMID(portal.Key.GMID).DefaultIntent() - } - return portal.bridge.Bot -} - func (portal *Portal) SetReply(content *event.MessageEventContent, msgID groupme.ID) { if len(msgID) == 0 { return @@ -891,61 +455,184 @@ func (portal *Portal) SetReply(content *event.MessageEventContent, msgID groupme return } -const MessageSendRetries = 5 -const MediaUploadRetries = 5 -const BadGatewaySleep = 5 * time.Second - -func (portal *Portal) sendReaction(intent *appservice.IntentAPI, eventID id.EventID, reaction string) (*mautrix.RespSendEvent, error) { - var content event.ReactionEventContent - content.RelatesTo = event.RelatesTo{ - Type: event.RelAnnotation, - EventID: eventID, - Key: reaction, - } - return intent.SendMassagedMessageEvent(portal.MXID, event.EventReaction, &content, time.Now().UnixMilli()) -} - -func isGatewayError(err error) bool { - if err == nil { - return false - } - var httpErr mautrix.HTTPError - return errors.As(err, &httpErr) && (httpErr.IsStatus(http.StatusBadGateway) || httpErr.IsStatus(http.StatusGatewayTimeout)) -} - -func (portal *Portal) sendMainIntentMessage(content *event.MessageEventContent) (*mautrix.RespSendEvent, error) { - return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, nil, 0) -} - -func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { - if !portal.Encrypted || portal.bridge.Crypto == nil { - return eventType, nil - } - intent.AddDoublePuppetValue(content) - // TODO maybe the locking should be inside mautrix-go? - portal.encryptLock.Lock() - defer portal.encryptLock.Unlock() - err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content) +func (portal *Portal) RestrictMessageSending(restrict bool) { + levels, err := portal.MainIntent().PowerLevels(portal.MXID) if err != nil { - return eventType, fmt.Errorf("failed to encrypt event: %w", err) + levels = portal.getBasePowerLevels() + } + + newLevel := 0 + if restrict { + newLevel = 50 + } + + if levels.EventsDefault == newLevel { + return + } + + levels.EventsDefault = newLevel + _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) + if err != nil { + portal.log.Errorln("Failed to change power levels:", err) } - return event.EventEncrypted, nil } -func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]any, timestamp int64) (*mautrix.RespSendEvent, error) { - wrappedContent := event.Content{Parsed: content, Raw: extraContent} - var err error - eventType, err = portal.encrypt(intent, &wrappedContent, eventType) +func (portal *Portal) RestrictMetadataChanges(restrict bool) { + levels, err := portal.MainIntent().PowerLevels(portal.MXID) if err != nil { - return nil, err + levels = portal.getBasePowerLevels() + } + newLevel := 0 + if restrict { + newLevel = 50 + } + changed := false + changed = levels.EnsureEventLevel(event.StateRoomName, newLevel) || changed + changed = levels.EnsureEventLevel(event.StateRoomAvatar, newLevel) || changed + changed = levels.EnsureEventLevel(event.StateTopic, newLevel) || changed + if changed { + _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) + if err != nil { + portal.log.Errorln("Failed to change power levels:", err) + } + } +} + +func (portal *Portal) Delete() { + portal.Portal.Delete() + portal.bridge.portalsLock.Lock() + delete(portal.bridge.portalsByGMID, portal.Key) + if len(portal.MXID) > 0 { + delete(portal.bridge.portalsByMXID, portal.MXID) + } + portal.bridge.portalsLock.Unlock() +} + +// Handlers + +func (portal *Portal) HandleTextMessage(source *User, message *groupme.Message) { + intent := portal.startHandling(source, message) + if intent == nil { + return + } + + sendText := true + var sentID id.EventID + for _, a := range message.Attachments { + msg, text, err := portal.handleAttachment(intent, a, source, message) + + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", "TODOID", err) + portal.sendMediaBridgeFailure(source, intent, *message, err) + continue + } + if msg == nil { + continue + } + resp, err := portal.sendMessage(intent, event.EventMessage, msg, nil, message.CreatedAt.ToTime().Unix()) + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", "TODOID", err) + portal.sendMediaBridgeFailure(source, intent, *message, err) + continue + } + sentID = resp.EventID + + sendText = sendText && text + } + + // portal.SetReply(content, message.ContextInfo) + //TODO: mentions + content := &event.MessageEventContent{ + Body: message.Text, + MsgType: event.MsgText, } _, _ = intent.UserTyping(portal.MXID, false, 0) - if timestamp == 0 { - return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) - } else { - return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp) + if sendText { + resp, err := portal.sendMessage(intent, event.EventMessage, content, nil, message.CreatedAt.ToTime().Unix()) + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", message.ID, err) + return + } + sentID = resp.EventID + } + portal.finishHandling(source, message, sentID) +} + +func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) { + portal.log.Debugfln("Received event %s", evt.ID) + info, sender := portal.convertMatrixMessage(sender, evt) + if info == nil { + return + } + for _, i := range info { + portal.log.Debugln("Sending event", evt.ID, "to GroupMe", info[0].ID) + + var err error + i, err = portal.sendRaw(sender, evt, info[0], -1) //TODO deal with multiple messages for longer messages + if err != nil { + portal.log.Warnln("Unable to handle message from Matrix", evt.ID) + //TODO handle deleted room and such + } else { + portal.markHandled(sender, i, evt.ID) + } + } +} + +func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) { +} + +func (portal *Portal) HandleMatrixLeave(sender *User) { + if portal.IsPrivateChat() { + portal.log.Debugln("User left private chat portal, cleaning up and deleting...") + portal.Delete() + portal.cleanup(false) + return + } else { + // TODO should we somehow deduplicate this call if this leave was sent by the bridge? + err := sender.Client.RemoveFromGroup(sender.GMID, portal.Key.GMID) + if err != nil { + portal.log.Errorfln("Failed to leave group as %s: %v", sender.MXID, err) + return + } + portal.cleanupIfEmpty() + } +} + +func (portal *Portal) HandleMatrixKick(sender *User, evt *event.Event) { + portal.log.Debug("HandleMatrixKick") +} + +func (portal *Portal) HandleMatrixInvite(sender *User, evt *event.Event) { + portal.log.Debug("HandleMatrixInvite") +} + +func (portal *Portal) handleMessageLoop() { + for msg := range portal.messages { + if len(portal.MXID) == 0 { + if msg.timestamp+MaxMessageAgeToCreatePortal < uint64(time.Now().Unix()) { + portal.log.Debugln("Not creating portal room for incoming message: message is too old") + continue + } + portal.log.Debugln("Creating Matrix room from incoming message") + err := portal.createMatrixRoom(msg.source) + if err != nil { + portal.log.Errorln("Failed to create portal room:", err) + continue + } + } + portal.handleMessage(msg) + } +} + +func (portal *Portal) handleMessage(msg PortalMessage) { + if len(portal.MXID) == 0 { + portal.log.Warnln("handleMessage called even though portal.MXID is empty") + return + } + portal.HandleTextMessage(msg.source, msg.data) + // portal.handleReaction(msg.data.ID.String(), msg.data.FavoritedBy) } func (portal *Portal) handleAttachment(intent *appservice.IntentAPI, attachment *groupme.Attachment, source *User, message *groupme.Message) (msg *event.MessageEventContent, sendText bool, err error) { @@ -1127,54 +814,249 @@ func (portal *Portal) handleAttachment(intent *appservice.IntentAPI, attachment // return nil, true, errors.New("Unknown type") } -func (portal *Portal) HandleTextMessage(source *User, message *groupme.Message) { - intent := portal.startHandling(source, message) - if intent == nil { +// Private Methods + +func (portal *Portal) createMatrixRoomForDM(user *User, puppet *Puppet) { + +} + +func (portal *Portal) createMatrixRoom(user *User) error { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + if len(portal.MXID) > 0 { + return nil + } + + intent := portal.MainIntent() + if err := intent.EnsureRegistered(); err != nil { + return err + } + + portal.log.Infoln("Creating Matrix room. Info source:", user.MXID) + + bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() + + initialState := []*event.Event{{ + Type: event.StatePowerLevels, + Content: event.Content{ + Parsed: portal.getBasePowerLevels(), + }, + }, { + Type: event.StateBridge, + Content: event.Content{Parsed: bridgeInfo}, + StateKey: &bridgeInfoStateKey, + }, { + // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec + Type: event.StateHalfShotBridge, + Content: event.Content{Parsed: bridgeInfo}, + StateKey: &bridgeInfoStateKey, + }} + if !portal.AvatarURL.IsEmpty() { + initialState = append(initialState, &event.Event{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: event.RoomAvatarEventContent{URL: portal.AvatarURL}, + }, + }) + } + + invite := []id.UserID{user.MXID} + + if portal.bridge.Config.Bridge.Encryption.Default { + initialState = append(initialState, &event.Event{ + Type: event.StateEncryption, + Content: event.Content{ + Parsed: event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}, + }, + }) + portal.Encrypted = true + if portal.IsPrivateChat() { + invite = append(invite, portal.bridge.Bot.UserID) + } + } + + resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{ + Visibility: "private", + Name: portal.Name, + Topic: portal.Topic, + Invite: invite, + Preset: "private_chat", + IsDirect: portal.IsPrivateChat(), + InitialState: initialState, + }) + if err != nil { + return err + } else if len(resp.RoomID) == 0 { + return errors.New("Empty room ID") + } + portal.MXID = resp.RoomID + portal.Update(nil) + portal.bridge.portalsLock.Lock() + portal.bridge.portalsByMXID[portal.MXID] = portal + portal.bridge.portalsLock.Unlock() + + // We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here. + for _, user := range invite { + portal.bridge.StateStore.SetMembership(portal.MXID, user, event.MembershipInvite) + } + + return nil +} + +func (portal *Portal) markHandled(source *User, message *groupme.Message, mxid id.EventID) { + msg := portal.bridge.DB.Message.New() + msg.Chat = portal.Key + msg.GMID = message.ID + msg.MXID = mxid + msg.Timestamp = message.CreatedAt.ToTime() + if message.UserID == source.GMID { + msg.Sender = source.GMID + } else if portal.IsPrivateChat() { + msg.Sender = portal.Key.GMID + } else { + msg.Sender = message.SenderID + } + // msg.Insert() + + portal.recentlyHandledLock.Lock() + portal.recentlyHandled[0] = "" //FIFO queue being implemented here //TODO: is this efficent + portal.recentlyHandled = portal.recentlyHandled[1:] + portal.recentlyHandled = append(portal.recentlyHandled, message.ID.String()) + portal.recentlyHandledLock.Unlock() +} + +func (portal *Portal) getMessageIntent(user *User, info *groupme.Message) *appservice.IntentAPI { + if portal.IsPrivateChat() { + if info.UserID == user.GetGMID() { //from me + return portal.bridge.GetPuppetByGMID(user.GMID).DefaultIntent() + } + return portal.MainIntent() + } else if len(info.UserID.String()) == 0 { + println("TODO weird uid stuff") + } else if info.UserID == user.GetGMID() { //from me + return portal.bridge.GetPuppetByGMID(user.GMID).IntentFor(portal) + } + return portal.bridge.GetPuppetByGMID(info.UserID).IntentFor(portal) +} + +func (portal *Portal) getReactionIntent(jid groupme.ID) *appservice.IntentAPI { + return portal.bridge.GetPuppetByGMID(jid).IntentFor(portal) +} + +func (portal *Portal) startHandling(source *User, info *groupme.Message) *appservice.IntentAPI { + // TODO these should all be trace logs + if portal.lastMessageTs > uint64(info.CreatedAt.ToTime().Unix()+1) { + portal.log.Debugfln("Not handling %s: message is older (%d) than last bridge message (%d)", info.ID, info.CreatedAt, portal.lastMessageTs) + } else if portal.isRecentlyHandled(info.ID) { + portal.log.Debugfln("Not handling %s: message was recently handled", info.ID) + } else if portal.isDuplicate(info.ID) { + portal.log.Debugfln("Not handling %s: message is duplicate", info.ID) + } else if info.System { + portal.log.Debugfln("Not handling %s: message is from system: %s", info.ID, info.Text) + } else { + portal.lastMessageTs = uint64(info.CreatedAt.ToTime().Unix()) + intent := portal.getMessageIntent(source, info) + if intent != nil { + portal.log.Debugfln("Starting handling of %s (ts: %d)", info.ID, info.CreatedAt) + } else { + portal.log.Debugfln("Not handling %s: sender is not known", info.ID.String()) + } + return intent + } + return nil +} + +func (portal *Portal) finishHandling(source *User, message *groupme.Message, mxid id.EventID) { + portal.markHandled(source, message, mxid) + portal.sendDeliveryReceipt(mxid) + portal.log.Debugln("Handled message", message.ID.String(), "->", mxid) +} + +func (portal *Portal) userMXIDAction(user *User, fn func(mxid id.UserID)) { + if user == nil { return } - sendText := true - var sentID id.EventID - for _, a := range message.Attachments { - msg, text, err := portal.handleAttachment(intent, a, source, message) + fn(user.MXID) +} - if err != nil { - portal.log.Errorfln("Failed to handle message %s: %v", "TODOID", err) - portal.sendMediaBridgeFailure(source, intent, *message, err) - continue - } - if msg == nil { - continue - } - resp, err := portal.sendMessage(intent, event.EventMessage, msg, nil, message.CreatedAt.ToTime().Unix()) - if err != nil { - portal.log.Errorfln("Failed to handle message %s: %v", "TODOID", err) - portal.sendMediaBridgeFailure(source, intent, *message, err) - continue - } - sentID = resp.EventID - - sendText = sendText && text +func (portal *Portal) ensureMXIDInvited(mxid id.UserID) { + err := portal.MainIntent().EnsureInvited(portal.MXID, mxid) + if err != nil { + portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", mxid, portal.MXID, err) } +} - // portal.SetReply(content, message.ContextInfo) - //TODO: mentions - content := &event.MessageEventContent{ - Body: message.Text, - MsgType: event.MsgText, +func (portal *Portal) ensureUserInvited(user *User) bool { + return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) +} + +func (portal *Portal) getBridgeInfoStateKey() string { + return fmt.Sprintf("com.beeper.groupme://groupme/%s", portal.Key.GMID) +} + +func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { + bridgeInfo := event.BridgeEventContent{ + BridgeBot: portal.bridge.Bot.UserID, + Creator: portal.MainIntent().UserID, + Protocol: event.BridgeInfoSection{ + ID: "groupme", + DisplayName: "GroupMe", + AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), + ExternalURL: "https://www.groupme.com/", + }, + Channel: event.BridgeInfoSection{ + ID: portal.Key.GMID.String(), + DisplayName: portal.Name, + AvatarURL: portal.AvatarURL.CUString(), + }, + } + return portal.getBridgeInfoStateKey(), bridgeInfo +} + +func (portal *Portal) sendReaction(intent *appservice.IntentAPI, eventID id.EventID, reaction string) (*mautrix.RespSendEvent, error) { + var content event.ReactionEventContent + content.RelatesTo = event.RelatesTo{ + Type: event.RelAnnotation, + EventID: eventID, + Key: reaction, + } + return intent.SendMassagedMessageEvent(portal.MXID, event.EventReaction, &content, time.Now().UnixMilli()) +} + +func (portal *Portal) sendMainIntentMessage(content *event.MessageEventContent) (*mautrix.RespSendEvent, error) { + return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, nil, 0) +} + +func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { + if !portal.Encrypted || portal.bridge.Crypto == nil { + return eventType, nil + } + intent.AddDoublePuppetValue(content) + // TODO maybe the locking should be inside mautrix-go? + portal.encryptLock.Lock() + defer portal.encryptLock.Unlock() + err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content) + if err != nil { + return eventType, fmt.Errorf("failed to encrypt event: %w", err) + } + return event.EventEncrypted, nil +} + +func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]any, timestamp int64) (*mautrix.RespSendEvent, error) { + wrappedContent := event.Content{Parsed: content, Raw: extraContent} + var err error + eventType, err = portal.encrypt(intent, &wrappedContent, eventType) + if err != nil { + return nil, err } _, _ = intent.UserTyping(portal.MXID, false, 0) - if sendText { - resp, err := portal.sendMessage(intent, event.EventMessage, content, nil, message.CreatedAt.ToTime().Unix()) - if err != nil { - portal.log.Errorfln("Failed to handle message %s: %v", message.ID, err) - return - } - sentID = resp.EventID - + if timestamp == 0 { + return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) + } else { + return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp) } - portal.finishHandling(source, message, sentID) } // func (portal *Portal) handleReaction(msgID groupme.ID, ppl []groupme.ID) { @@ -1377,32 +1259,6 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) ([]*g return []*groupme.Message{&info}, sender } -func (portal *Portal) wasMessageSent(sender *User, id string) bool { - return true -} - -var timeout = errors.New("message sending timed out") - -func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) { - portal.log.Debugfln("Received event %s", evt.ID) - info, sender := portal.convertMatrixMessage(sender, evt) - if info == nil { - return - } - for _, i := range info { - portal.log.Debugln("Sending event", evt.ID, "to GroupMe", info[0].ID) - - var err error - i, err = portal.sendRaw(sender, evt, info[0], -1) //TODO deal with multiple messages for longer messages - if err != nil { - portal.log.Warnln("Unable to handle message from Matrix", evt.ID) - //TODO handle deleted room and such - } else { - portal.markHandled(sender, i, evt.ID) - } - } -} - func (portal *Portal) sendRaw(sender *User, evt *event.Event, info *groupme.Message, retries int) (*groupme.Message, error) { if retries == -1 { retries = 2 @@ -1431,20 +1287,7 @@ func (portal *Portal) sendRaw(sender *User, evt *event.Event, info *groupme.Mess return m, nil } -func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) { -} - -func (portal *Portal) Delete() { - portal.Portal.Delete() - portal.bridge.portalsLock.Lock() - delete(portal.bridge.portalsByGMID, portal.Key) - if len(portal.MXID) > 0 { - delete(portal.bridge.portalsByMXID, portal.MXID) - } - portal.bridge.portalsLock.Unlock() -} - -func (portal *Portal) GetMatrixUsers() ([]id.UserID, error) { +func (portal *Portal) getMatrixUsers() ([]id.UserID, error) { members, err := portal.MainIntent().JoinedMembers(portal.MXID) if err != nil { return nil, fmt.Errorf("failed to get member list: %w", err) @@ -1459,8 +1302,8 @@ func (portal *Portal) GetMatrixUsers() ([]id.UserID, error) { return users, nil } -func (portal *Portal) CleanupIfEmpty() { - users, err := portal.GetMatrixUsers() +func (portal *Portal) cleanupIfEmpty() { + users, err := portal.getMatrixUsers() if err != nil { portal.log.Errorfln("Failed to get Matrix user list to determine if portal needs to be cleaned up: %v", err) return @@ -1469,11 +1312,11 @@ func (portal *Portal) CleanupIfEmpty() { if len(users) == 0 { portal.log.Infoln("Room seems to be empty, cleaning up...") portal.Delete() - portal.Cleanup(false) + portal.cleanup(false) } } -func (portal *Portal) Cleanup(puppetsOnly bool) { +func (portal *Portal) cleanup(puppetsOnly bool) { if len(portal.MXID) == 0 { return } @@ -1513,27 +1356,86 @@ func (portal *Portal) Cleanup(puppetsOnly bool) { } } -func (portal *Portal) HandleMatrixLeave(sender *User) { - if portal.IsPrivateChat() { - portal.log.Debugln("User left private chat portal, cleaning up and deleting...") - portal.Delete() - portal.Cleanup(false) - return - } else { - // TODO should we somehow deduplicate this call if this leave was sent by the bridge? - err := sender.Client.RemoveFromGroup(sender.GMID, portal.Key.GMID) - if err != nil { - portal.log.Errorfln("Failed to leave group as %s: %v", sender.MXID, err) - return - } - portal.CleanupIfEmpty() +func (portal *Portal) getBasePowerLevels() *event.PowerLevelsEventContent { + anyone := 0 + nope := 99 + invite := 50 + if portal.bridge.Config.Bridge.AllowUserInvite { + invite = 0 + } + return &event.PowerLevelsEventContent{ + UsersDefault: anyone, + EventsDefault: anyone, + RedactPtr: &anyone, + StateDefaultPtr: &nope, + BanPtr: &nope, + InvitePtr: &invite, + Users: map[id.UserID]int{ + portal.MainIntent().UserID: 100, + }, + Events: map[string]int{ + event.StateRoomName.Type: anyone, + event.StateRoomAvatar.Type: anyone, + event.StateTopic.Type: anyone, + }, } } -func (portal *Portal) HandleMatrixKick(sender *User, evt *event.Event) { - portal.log.Debug("HandleMatrixKick") -} +func (portal *Portal) syncParticipants(group *groupme.Group) { + changed := false + levels, err := portal.MainIntent().PowerLevels(portal.MXID) + if err != nil { + levels = portal.getBasePowerLevels() + changed = true + } + participantMap := make(map[groupme.ID]bool) + for _, participant := range group.Members { + participantMap[participant.UserID] = true + user := portal.bridge.GetUserByGMID(participant.UserID) + portal.userMXIDAction(user, portal.ensureMXIDInvited) -func (portal *Portal) HandleMatrixInvite(sender *User, evt *event.Event) { - portal.log.Debug("HandleMatrixInvite") + puppet := portal.bridge.GetPuppetByGMID(participant.UserID) + err := puppet.IntentFor(portal).EnsureJoined(portal.MXID) + if err != nil { + portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.ID.String(), portal.MXID, err) + } + + expectedLevel := 0 + // if participant.IsSuperAdmin { + // expectedLevel = 95 + // } else if participant.IsAdmin { + // expectedLevel = 50 + // } + changed = levels.EnsureUserLevel(puppet.MXID, expectedLevel) || changed + if user != nil { + changed = levels.EnsureUserLevel(user.MXID, expectedLevel) || changed + } + puppet.Sync(nil, participant, false, false) + } + if changed { + _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) + if err != nil { + portal.log.Errorln("Failed to change power levels:", err) + } + } + members, err := portal.MainIntent().JoinedMembers(portal.MXID) + if err != nil { + portal.log.Warnln("Failed to get member list:", err) + } else { + for member := range members.Joined { + jid, ok := portal.bridge.ParsePuppetMXID(member) + if ok { + _, shouldBePresent := participantMap[jid] + if !shouldBePresent { + _, err := portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ + UserID: member, + Reason: "User had left this GroupMe chat", + }) + if err != nil { + portal.log.Warnfln("Failed to kick user %s who had left: %v", member, err) + } + } + } + } + } } diff --git a/puppet.go b/puppet.go index f1984db..8fb3998 100644 --- a/puppet.go +++ b/puppet.go @@ -17,7 +17,6 @@ package main import ( - "fmt" "regexp" "sync" @@ -26,7 +25,6 @@ import ( "github.com/beeper/groupme-lib" "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/id" "github.com/beeper/groupme/database" @@ -34,146 +32,6 @@ import ( var userIDRegex *regexp.Regexp -func (bridge *GMBridge) ParsePuppetMXID(mxid id.UserID) (groupme.ID, bool) { - if userIDRegex == nil { - userIDRegex = regexp.MustCompile(fmt.Sprintf("^@%s:%s$", - bridge.Config.Bridge.FormatUsername("([0-9]+)"), - bridge.Config.Homeserver.Domain)) - } - match := userIDRegex.FindStringSubmatch(string(mxid)) - if match == nil || len(match) != 2 { - return "", false - } - - return groupme.ID(match[1]), true -} - -func (bridge *GMBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { - gmid, ok := bridge.ParsePuppetMXID(mxid) - if !ok { - return nil - } - - return bridge.GetPuppetByGMID(gmid) -} - -func (bridge *GMBridge) GetPuppetByGMID(gmid groupme.ID) *Puppet { - bridge.puppetsLock.Lock() - defer bridge.puppetsLock.Unlock() - puppet, ok := bridge.puppets[gmid] - if !ok { - dbPuppet := bridge.DB.Puppet.Get(gmid) - if dbPuppet == nil { - dbPuppet = bridge.DB.Puppet.New() - dbPuppet.GMID = gmid - dbPuppet.Insert() - } - puppet = bridge.NewPuppet(dbPuppet) - bridge.puppets[puppet.GMID] = puppet - if len(puppet.CustomMXID) > 0 { - bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet - } - } - return puppet -} - -func (bridge *GMBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet { - bridge.puppetsLock.Lock() - defer bridge.puppetsLock.Unlock() - puppet, ok := bridge.puppetsByCustomMXID[mxid] - if !ok { - dbPuppet := bridge.DB.Puppet.GetByCustomMXID(mxid) - if dbPuppet == nil { - return nil - } - puppet = bridge.NewPuppet(dbPuppet) - bridge.puppets[puppet.GMID] = puppet - bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet - } - return puppet -} - -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.GMID.String() == "" { - return nil - } - p := user.bridge.GetPuppetByGMID(user.GMID) - if p == nil { - return nil - } - return p -} - -func (br *GMBridge) IsGhost(id id.UserID) bool { - _, ok := br.ParsePuppetMXID(id) - return ok -} - -func (br *GMBridge) GetIGhost(id id.UserID) bridge.Ghost { - p := br.GetPuppetByMXID(id) - if p == nil { - return nil - } - return p -} - -func (puppet *Puppet) GetMXID() id.UserID { - return puppet.MXID -} - -func (bridge *GMBridge) GetAllPuppetsWithCustomMXID() []*Puppet { - return bridge.dbPuppetsToPuppets(bridge.DB.Puppet.GetAllWithCustomMXID()) -} - -func (bridge *GMBridge) GetAllPuppets() []*Puppet { - return bridge.dbPuppetsToPuppets(bridge.DB.Puppet.GetAll()) -} - -func (bridge *GMBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { - bridge.puppetsLock.Lock() - defer bridge.puppetsLock.Unlock() - output := make([]*Puppet, len(dbPuppets)) - for index, dbPuppet := range dbPuppets { - if dbPuppet == nil { - continue - } - puppet, ok := bridge.puppets[dbPuppet.GMID] - if !ok { - puppet = bridge.NewPuppet(dbPuppet) - bridge.puppets[dbPuppet.GMID] = puppet - if len(dbPuppet.CustomMXID) > 0 { - bridge.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet - } - } - output[index] = puppet - } - return output -} - -func (bridge *GMBridge) FormatPuppetMXID(gmid groupme.ID) id.UserID { - return id.NewUserID( - bridge.Config.Bridge.FormatUsername(gmid.String()), - bridge.Config.Homeserver.Domain) -} - -func (bridge *GMBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { - return &Puppet{ - Puppet: dbPuppet, - bridge: bridge, - log: bridge.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.GMID)), - - MXID: bridge.FormatPuppetMXID(dbPuppet.GMID), - } -} - type Puppet struct { *database.Puppet @@ -192,10 +50,18 @@ type Puppet struct { syncLock sync.Mutex } +// Public Properties + +func (puppet *Puppet) GetMXID() id.UserID { + return puppet.MXID +} + func (puppet *Puppet) PhoneNumber() string { return puppet.GMID.String() } +// Public Methods + func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { if puppet.customIntent == nil || portal.Key.GMID == puppet.GMID { return puppet.DefaultIntent() diff --git a/user.go b/user.go index 141117d..4860fab 100644 --- a/user.go +++ b/user.go @@ -58,9 +58,9 @@ type User struct { ConnectionErrors int CommunityID string - ChatList map[groupme.ID]groupme.Chat - GroupList map[groupme.ID]groupme.Group - RelationList map[groupme.ID]groupme.User + ChatList map[groupme.ID]*groupme.Chat + GroupList map[groupme.ID]*groupme.Group + RelationList map[groupme.ID]*groupme.User cleanDisconnection bool batteryWarningsSent int @@ -81,36 +81,35 @@ type User struct { syncWait sync.WaitGroup } -func (br *GMBridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User { - _, isPuppet := br.ParsePuppetMXID(userID) - if isPuppet || userID == br.Bot.UserID { - return nil - } - br.usersLock.Lock() - defer br.usersLock.Unlock() - user, ok := br.usersByMXID[userID] - if !ok { - userIDPtr := &userID - if onlyIfExists { - userIDPtr = nil - } - return br.loadDBUser(br.DB.User.GetByMXID(userID), userIDPtr) - } - return user +type Chat struct { + Portal *Portal + LastMessageTime uint64 + Group *groupme.Group + DM *groupme.Chat } -func (br *GMBridge) GetUserByMXID(userID id.UserID) *User { - return br.getUserByMXID(userID, false) +type ChatList []Chat + +func (cl ChatList) Len() int { + return len(cl) } -func (br *GMBridge) GetIUser(userID id.UserID, create bool) bridge.User { - u := br.getUserByMXID(userID, !create) - if u == nil { - return nil - } - return u +func (cl ChatList) Less(i, j int) bool { + return cl[i].LastMessageTime > cl[j].LastMessageTime } +func (cl ChatList) Swap(i, j int) { + cl[i], cl[j] = cl[j], cl[i] +} + +type FakeMessage struct { + Text string + ID string + Alert bool +} + +// Public Properties + func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { return user.PermissionLevel } @@ -123,128 +122,39 @@ func (user *User) GetMXID() id.UserID { return user.MXID } +func (user *User) GetGMID() groupme.ID { + if len(user.GMID) == 0 { + u, err := user.Client.MyUser(context.TODO()) + if err != nil { + user.log.Errorln("Failed to get own GroupMe ID:", err) + return "" + } + user.GMID = u.ID + } + return user.GMID +} + func (user *User) GetCommandState() map[string]interface{} { return nil } -func (br *GMBridge) GetUserByMXIDIfExists(userID id.UserID) *User { - return br.getUserByMXID(userID, true) +func (user *User) GetIDoublePuppet() bridge.DoublePuppet { + p := user.bridge.GetPuppetByCustomMXID(user.MXID) + if p == nil || p.CustomIntent() == nil { + return nil + } + return p } -func (bridge *GMBridge) GetUserByGMID(gmid groupme.ID) *User { - bridge.usersLock.Lock() - defer bridge.usersLock.Unlock() - user, ok := bridge.usersByGMID[gmid] - if !ok { - return bridge.loadDBUser(bridge.DB.User.GetByGMID(gmid), nil) +func (user *User) GetIGhost() bridge.Ghost { + if user.GMID.String() == "" { + return nil } - return user -} - -func (user *User) addToGMIDMap() { - user.bridge.usersLock.Lock() - user.bridge.usersByGMID[user.GMID] = user - user.bridge.usersLock.Unlock() -} - -func (user *User) removeFromGMIDMap() { - user.bridge.usersLock.Lock() - jidUser, ok := user.bridge.usersByGMID[user.GMID] - if ok && user == jidUser { - delete(user.bridge.usersByGMID, user.GMID) + p := user.bridge.GetPuppetByGMID(user.GMID) + if p == nil { + return nil } - user.bridge.usersLock.Unlock() - user.bridge.Metrics.TrackLoginState(user.GMID, false) -} - -func (br *GMBridge) GetAllUsers() []*User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - dbUsers := br.DB.User.GetAll() - output := make([]*User, len(dbUsers)) - for index, dbUser := range dbUsers { - user, ok := br.usersByMXID[dbUser.MXID] - if !ok { - user = br.loadDBUser(dbUser, nil) - } - output[index] = user - } - return output -} - -func (br *GMBridge) loadDBUser(dbUser *database.User, mxid *id.UserID) *User { - if dbUser == nil { - if mxid == nil { - return nil - } - dbUser = br.DB.User.New() - dbUser.MXID = *mxid - dbUser.Insert() - } - user := br.NewUser(dbUser) - br.usersByMXID[user.MXID] = user - if len(user.GMID) > 0 { - br.usersByGMID[user.GMID] = user - } - if len(user.ManagementRoom) > 0 { - br.managementRooms[user.ManagementRoom] = user - } - return user -} - -func (br *GMBridge) NewUser(dbUser *database.User) *User { - user := &User{ - User: dbUser, - bridge: br, - log: br.Log.Sub("User").Sub(string(dbUser.MXID)), - - chatListReceived: make(chan struct{}, 1), - syncPortalsDone: make(chan struct{}, 1), - syncStart: make(chan struct{}, 1), - messageInput: make(chan PortalMessage), - messageOutput: make(chan PortalMessage, br.Config.Bridge.PortalMessageBuffer), - } - - user.PermissionLevel = user.bridge.Config.Bridge.Permissions.Get(user.MXID) - user.Whitelisted = user.PermissionLevel >= bridgeconfig.PermissionLevelUser - user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin - user.BridgeState = br.NewBridgeStateQueue(user) - go user.handleMessageLoop() - go user.runMessageRingBuffer() - return user -} - -func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { - extraContent := make(map[string]interface{}) - if isDirect { - extraContent["is_direct"] = true - } - customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - extraContent["fi.mau.will_auto_accept"] = true - } - _, err := intent.InviteUser(roomID, &mautrix.ReqInviteUser{UserID: user.MXID}, extraContent) - 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) - ok = true - return - } else if err != nil { - user.log.Warnfln("Failed to invite user to %s: %v", roomID, err) - } else { - ok = 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) - ok = false - } else { - ok = true - } - } - return + return p } func (user *User) GetSpaceRoom() id.RoomID { @@ -334,6 +244,55 @@ func (user *User) SetManagementRoom(roomID id.RoomID) { user.Update() } +func (user *User) HasSession() bool { + return len(user.Token) > 0 +} + +func (user *User) IsConnected() bool { + // TODO: better connection check + return user.Conn != nil +} + +func (user *User) IsLoggedIn() bool { + return true +} + +func (user *User) IsLoginInProgress() bool { + // return user.Conn != nil && user.Conn.IsLoginInProgress() + return false +} + +func (user *User) ShouldCallSynchronously() bool { + return true +} + +func (user *User) GetPortalByGMID(gmid groupme.ID) *Portal { + return user.bridge.GetPortalByGMID(user.PortalKey(gmid)) +} + +// Public Methods + +func (user *User) Login(token string) error { + user.Token = token + + user.addToGMIDMap() + user.PostLogin() + if user.Connect() { + return nil + } + return errors.New("failed to connect") +} + +func (user *User) PostLogin() { + user.bridge.Metrics.TrackConnectionState(user.GMID, true) + user.bridge.Metrics.TrackLoginState(user.GMID, true) + user.bridge.Metrics.TrackBufferLength(user.MXID, 0) + user.log.Debugln("Locking processing of incoming messages and starting post-login sync") + user.syncWait.Add(1) + user.syncStart <- struct{}{} + go user.intPostLogin() +} + func (user *User) Connect() bool { if user.Conn != nil { return true @@ -375,76 +334,257 @@ func (user *User) RestoreSession() bool { } } -func (user *User) HasSession() bool { - return len(user.Token) > 0 -} - -func (user *User) IsConnected() bool { - // TODO: better connection check - return user.Conn != nil -} - -func (user *User) IsLoggedIn() bool { - return true -} - -func (user *User) IsLoginInProgress() bool { - // return user.Conn != nil && user.Conn.IsLoginInProgress() - return false -} - -func (user *User) GetGMID() groupme.ID { - if len(user.GMID) == 0 { - u, err := user.Client.MyUser(context.TODO()) +func (user *User) UpdateDirectChats(chats map[id.UserID][]id.RoomID) { + if !user.bridge.Config.Bridge.SyncDirectChatList { + return + } + puppet := user.bridge.GetPuppetByCustomMXID(user.MXID) + if puppet == nil || puppet.CustomIntent() == nil { + return + } + intent := puppet.CustomIntent() + 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.Software == bridgeconfig.SoftwareAsmux { + urlPath := intent.BuildClientURL("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 := make(map[id.UserID][]id.RoomID) + err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats) if err != nil { - user.log.Errorln("Failed to get own GroupMe ID:", err) - return "" + user.log.Warnln("Failed to get m.direct list to update it:", err) + return } - user.GMID = u.ID + 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) } - return user.GMID -} - -func (user *User) Login(token string) error { - user.Token = token - - user.addToGMIDMap() - user.PostLogin() - if user.Connect() { - return nil + if err != nil { + user.log.Warnln("Failed to update m.direct list:", err) } - return errors.New("failed to connect") } -type Chat struct { - Portal *Portal - LastMessageTime uint64 - Group *groupme.Group - DM *groupme.Chat +func (user *User) PortalKey(gmid groupme.ID) database.PortalKey { + return database.NewPortalKey(gmid, user.GMID) } -type ChatList []Chat +// Handlers -func (cl ChatList) Len() int { - return len(cl) +func (user *User) HandleTextMessage(message groupme.Message) { + id := database.ParsePortalKey(message.GroupID.String()) + + if id == nil { + id = database.ParsePortalKey(message.ConversationID.String()) + } + if id == nil { + user.log.Errorln("Error parsing conversationid/portalkey", message.ConversationID.String(), "ignoring message") + return + } + + user.messageInput <- PortalMessage{*id, user, &message, uint64(message.CreatedAt.ToTime().Unix())} } -func (cl ChatList) Less(i, j int) bool { - return cl[i].LastMessageTime > cl[j].LastMessageTime +func (user *User) HandleLike(msg groupme.Message) { + user.HandleTextMessage(msg) } -func (cl ChatList) Swap(i, j int) { - cl[i], cl[j] = cl[j], cl[i] +func (user *User) HandleJoin(id groupme.ID) { + user.HandleChatList() + //TODO: efficient } -func (user *User) PostLogin() { - user.bridge.Metrics.TrackConnectionState(user.GMID, true) - user.bridge.Metrics.TrackLoginState(user.GMID, true) - user.bridge.Metrics.TrackBufferLength(user.MXID, 0) - user.log.Debugln("Locking processing of incoming messages and starting post-login sync") - user.syncWait.Add(1) - user.syncStart <- struct{}{} - go user.intPostLogin() +func (user *User) HandleGroupName(group groupme.ID, newName string) { + p := user.GetPortalByGMID(group) + if p != nil { + p.UpdateName(newName, "", false) + //get more info abt actual user TODO + } + //bugs atm with above? + user.HandleChatList() + +} + +func (user *User) HandleGroupTopic(_ groupme.ID, _ string) { + user.HandleChatList() +} + +func (user *User) HandleGroupMembership(_ groupme.ID, _ string) { + user.HandleChatList() + //TODO +} + +func (user *User) HandleGroupAvatar(_ groupme.ID, _ string) { + user.HandleChatList() +} + +func (user *User) HandleLikeIcon(_ groupme.ID, _, _ int, _ string) { + //TODO +} + +func (user *User) HandleNewNickname(groupID, userID groupme.ID, name string) { + puppet := user.bridge.GetPuppetByGMID(userID) + if puppet != nil { + puppet.UpdateName(groupme.Member{ + Nickname: name, + UserID: userID, + }, false) + } +} + +func (user *User) HandleNewAvatarInGroup(groupID, userID groupme.ID, url string) { + puppet := user.bridge.GetPuppetByGMID(userID) + puppet.UpdateAvatar(user, false) +} + +func (user *User) HandleMembers(_ groupme.ID, _ []groupme.Member, _ bool) { + user.HandleChatList() +} + +func (user *User) HandleError(err error) { +} + +func (user *User) HandleJSONParseError(err error) { + user.log.Errorln("GroupMe JSON parse error:", err) +} + +func (user *User) HandleChatList() { + chatMap := map[groupme.ID]*groupme.Group{} + chats, err := user.Client.IndexAllGroups() + if err != nil { + user.log.Errorln("chat sync error", err) //TODO: handle + return + } + for _, chat := range chats { + chatMap[chat.ID] = chat + } + user.GroupList = chatMap + + dmMap := map[groupme.ID]*groupme.Chat{} + dms, err := user.Client.IndexAllChats() + if err != nil { + user.log.Errorln("chat sync error", err) //TODO: handle + return + } + for _, dm := range dms { + dmMap[dm.OtherUser.ID] = dm + } + user.ChatList = dmMap + + //userMap := map[groupme.ID]groupme.User{} + //users, err := user.Client.IndexAllRelations() + //if err != nil { + // user.log.Errorln("Error syncing user list, continuing sync", err) + //} + //fmt.Println("Relations:") + //for _, u := range users { + // fmt.Println(" " + u.ID.String() + " " + u.Name) + // puppet := user.bridge.GetPuppetByGMID(u.ID) + // // "" for overall user not related to one group + // puppet.Sync(user, &groupme.Member{ + // UserID: u.ID, + // Nickname: u.Name, + // ImageURL: u.AvatarURL, + // }, false, false) + // userMap[u.ID] = *u + //} + //user.RelationList = userMap + + user.log.Infoln("Chat list received") + user.chatListReceived <- struct{}{} + go user.syncPortals(false) +} + +// Private Methods + +func (user *User) handleMessageLoop() { + for { + select { + case msg := <-user.messageOutput: + user.bridge.Metrics.TrackBufferLength(user.MXID, len(user.messageOutput)) + puppet := user.bridge.GetPuppetByGMID(msg.data.UserID) + portal := user.bridge.GetPortalByGMID(msg.chat) + if puppet != nil { + puppet.Sync(nil, &groupme.Member{ + UserID: msg.data.UserID, + Nickname: msg.data.Name, + ImageURL: msg.data.AvatarURL, + }, false, false) + } + portal.messages <- msg + case <-user.syncStart: + user.log.Debugln("Processing of incoming messages is locked") + user.bridge.Metrics.TrackSyncLock(user.GMID, true) + user.syncWait.Wait() + user.bridge.Metrics.TrackSyncLock(user.GMID, false) + user.log.Debugln("Processing of incoming messages unlocked") + } + } +} + +func (user *User) addToGMIDMap() { + user.bridge.usersLock.Lock() + user.bridge.usersByGMID[user.GMID] = user + user.bridge.usersLock.Unlock() +} + +func (user *User) removeFromGMIDMap() { + user.bridge.usersLock.Lock() + jidUser, ok := user.bridge.usersByGMID[user.GMID] + if ok && user == jidUser { + delete(user.bridge.usersByGMID, user.GMID) + } + user.bridge.usersLock.Unlock() + user.bridge.Metrics.TrackLoginState(user.GMID, false) +} + +func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { + extraContent := make(map[string]interface{}) + if isDirect { + extraContent["is_direct"] = true + } + customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) + if customPuppet != nil && customPuppet.CustomIntent() != nil { + extraContent["fi.mau.will_auto_accept"] = true + } + _, err := intent.InviteUser(roomID, &mautrix.ReqInviteUser{UserID: user.MXID}, extraContent) + 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) + ok = true + return + } else if err != nil { + user.log.Warnfln("Failed to invite user to %s: %v", roomID, err) + } else { + ok = 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) + ok = false + } else { + ok = true + } + } + return } func (user *User) tryAutomaticDoublePuppeting() { @@ -553,51 +693,6 @@ func (user *User) intPostLogin() { } } -func (user *User) HandleChatList() { - chatMap := map[groupme.ID]groupme.Group{} - chats, err := user.Client.IndexAllGroups() - if err != nil { - user.log.Errorln("chat sync error", err) //TODO: handle - return - } - for _, chat := range chats { - chatMap[chat.ID] = *chat - } - user.GroupList = chatMap - - dmMap := map[groupme.ID]groupme.Chat{} - dms, err := user.Client.IndexAllChats() - if err != nil { - user.log.Errorln("chat sync error", err) //TODO: handle - return - } - for _, dm := range dms { - dmMap[dm.OtherUser.ID] = *dm - } - user.ChatList = dmMap - - userMap := map[groupme.ID]groupme.User{} - users, err := user.Client.IndexAllRelations() - if err != nil { - user.log.Errorln("Error syncing user list, continuing sync", err) - } - for _, u := range users { - puppet := user.bridge.GetPuppetByGMID(u.ID) - // "" for overall user not related to one group - puppet.Sync(user, &groupme.Member{ - UserID: u.ID, - Nickname: u.Name, - ImageURL: u.AvatarURL, - }, false, false) - userMap[u.ID] = *u - } - user.RelationList = userMap - - user.log.Infoln("Chat list received") - user.chatListReceived <- struct{}{} - go user.syncPortals(false) -} - // Syncs chats & group messages func (user *User) syncPortals(createAll bool) { user.log.Infoln("Reading chat list") @@ -612,15 +707,18 @@ func (user *User) syncPortals(createAll bool) { chats = append(chats, Chat{ Portal: portal, LastMessageTime: uint64(group.UpdatedAt.ToTime().Unix()), - Group: &group, + Group: group, }) } for _, dm := range user.ChatList { - portal := user.bridge.GetPortalByGMID(database.NewPortalKey(dm.LastMessage.ConversationID, user.GMID)) + portal := user.bridge.GetPortalByGMID(database.NewPortalKey(dm.OtherUser.ID, user.GMID)) + portal.Name = dm.OtherUser.Name + portal.NameSet = true + chats = append(chats, Chat{ Portal: portal, LastMessageTime: uint64(dm.UpdatedAt.ToTime().Unix()), - DM: &dm, + DM: dm, }) } @@ -655,13 +753,16 @@ func (user *User) syncPortals(createAll bool) { go func(chat Chat, i int) { create := (int64(chat.LastMessageTime) >= user.lastReconnection && user.lastReconnection > 0) || i < limit if len(chat.Portal.MXID) > 0 || create || createAll { - chat.Portal.Sync(user, chat.Group) + if chat.Group != nil { + chat.Portal.SyncGroup(user, chat.Group) + } else { + chat.Portal.SyncDM(user, chat.DM) + } //err := chat.Portal.BackfillHistory(user, chat.LastMessageTime) if err != nil { chat.Portal.log.Errorln("Error backfilling history:", err) } } - wg.Done() }(chat, i) @@ -687,70 +788,8 @@ func (user *User) getDirectChats() map[id.UserID][]id.RoomID { return res } -func (user *User) UpdateDirectChats(chats map[id.UserID][]id.RoomID) { - if !user.bridge.Config.Bridge.SyncDirectChatList { - return - } - puppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if puppet == nil || puppet.CustomIntent() == nil { - return - } - intent := puppet.CustomIntent() - 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.Software == bridgeconfig.SoftwareAsmux { - urlPath := intent.BuildClientURL("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 := make(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) HandleError(err error) { -} - -func (user *User) ShouldCallSynchronously() bool { - return true -} - -func (user *User) HandleJSONParseError(err error) { - user.log.Errorln("GroupMe JSON parse error:", err) -} - -func (user *User) PortalKey(gmid groupme.ID) database.PortalKey { - return database.NewPortalKey(gmid, user.GMID) -} - -func (user *User) GetPortalByGMID(gmid groupme.ID) *Portal { - return user.bridge.GetPortalByGMID(user.PortalKey(gmid)) +func (user *User) updateAvatar(gmdi groupme.ID, avatarID *string, avatarURL *id.ContentURI, avatarSet *bool, log log.Logger, intent *appservice.IntentAPI) bool { + return false } func (user *User) runMessageRingBuffer() { @@ -765,103 +804,3 @@ func (user *User) runMessageRingBuffer() { } } } - -func (user *User) handleMessageLoop() { - for { - select { - case msg := <-user.messageOutput: - user.bridge.Metrics.TrackBufferLength(user.MXID, len(user.messageOutput)) - puppet := user.bridge.GetPuppetByGMID(msg.data.UserID) - portal := user.bridge.GetPortalByGMID(msg.chat) - if puppet != nil { - puppet.Sync(nil, &groupme.Member{ - UserID: msg.data.UserID, - Nickname: msg.data.Name, - ImageURL: msg.data.AvatarURL, - }, false, false) - } - portal.messages <- msg - case <-user.syncStart: - user.log.Debugln("Processing of incoming messages is locked") - user.bridge.Metrics.TrackSyncLock(user.GMID, true) - user.syncWait.Wait() - user.bridge.Metrics.TrackSyncLock(user.GMID, false) - user.log.Debugln("Processing of incoming messages unlocked") - } - } -} - -func (user *User) HandleTextMessage(message groupme.Message) { - id := database.ParsePortalKey(message.GroupID.String()) - - if id == nil { - id = database.ParsePortalKey(message.ConversationID.String()) - } - if id == nil { - user.log.Errorln("Error parsing conversationid/portalkey", message.ConversationID.String(), "ignoring message") - return - } - - user.messageInput <- PortalMessage{*id, user, &message, uint64(message.CreatedAt.ToTime().Unix())} -} - -func (user *User) HandleLike(msg groupme.Message) { - user.HandleTextMessage(msg) -} - -func (user *User) HandleJoin(id groupme.ID) { - user.HandleChatList() - //TODO: efficient -} - -func (user *User) HandleGroupName(group groupme.ID, newName string) { - p := user.GetPortalByGMID(group) - if p != nil { - p.UpdateName(newName, "", false) - //get more info abt actual user TODO - } - //bugs atm with above? - user.HandleChatList() - -} - -func (user *User) HandleGroupTopic(_ groupme.ID, _ string) { - user.HandleChatList() -} -func (user *User) HandleGroupMembership(_ groupme.ID, _ string) { - user.HandleChatList() - //TODO -} - -func (user *User) HandleGroupAvatar(_ groupme.ID, _ string) { - user.HandleChatList() -} - -func (user *User) HandleLikeIcon(_ groupme.ID, _, _ int, _ string) { - //TODO -} - -func (user *User) HandleNewNickname(groupID, userID groupme.ID, name string) { - puppet := user.bridge.GetPuppetByGMID(userID) - if puppet != nil { - puppet.UpdateName(groupme.Member{ - Nickname: name, - UserID: userID, - }, false) - } -} - -func (user *User) HandleNewAvatarInGroup(groupID, userID groupme.ID, url string) { - puppet := user.bridge.GetPuppetByGMID(userID) - puppet.UpdateAvatar(user, false) -} - -func (user *User) HandleMembers(_ groupme.ID, _ []groupme.Member, _ bool) { - user.HandleChatList() -} - -type FakeMessage struct { - Text string - ID string - Alert bool -}