Add initial compiling version of bridge
This commit is contained in:
parent
28629b8229
commit
63284481d7
25 changed files with 4547 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,5 +8,6 @@
|
|||
*.json
|
||||
*.db
|
||||
*.log
|
||||
*.log.gz
|
||||
|
||||
/mautrix-gmessages
|
||||
|
|
|
@ -14,7 +14,6 @@ ENV UID=1337 \
|
|||
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
|
||||
|
||||
COPY --from=builder /usr/bin/mautrix-gmessages /usr/bin/mautrix-gmessages
|
||||
COPY --from=builder /build/example-config.yaml /opt/mautrix-whatsapp/example-config.yaml
|
||||
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
||||
VOLUME /data
|
||||
|
||||
|
|
94
bridgestate.go
Normal file
94
bridgestate.go
Normal file
|
@ -0,0 +1,94 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 (
|
||||
"maunium.net/go/mautrix/bridge/status"
|
||||
)
|
||||
|
||||
const (
|
||||
WALoggedOut status.BridgeStateErrorCode = "wa-logged-out"
|
||||
WAMainDeviceGone status.BridgeStateErrorCode = "wa-main-device-gone"
|
||||
WAUnknownLogout status.BridgeStateErrorCode = "wa-unknown-logout"
|
||||
WANotConnected status.BridgeStateErrorCode = "wa-not-connected"
|
||||
WAConnecting status.BridgeStateErrorCode = "wa-connecting"
|
||||
WAKeepaliveTimeout status.BridgeStateErrorCode = "wa-keepalive-timeout"
|
||||
WAPhoneOffline status.BridgeStateErrorCode = "wa-phone-offline"
|
||||
WAConnectionFailed status.BridgeStateErrorCode = "wa-connection-failed"
|
||||
WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect"
|
||||
)
|
||||
|
||||
func init() {
|
||||
status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
|
||||
WALoggedOut: "You were logged out from another device. Relogin to continue using the bridge.",
|
||||
WAMainDeviceGone: "Your phone was logged out from WhatsApp. Relogin to continue using the bridge.",
|
||||
WAUnknownLogout: "You were logged out for an unknown reason. Relogin to continue using the bridge.",
|
||||
WANotConnected: "You're not connected to WhatsApp",
|
||||
WAConnecting: "Reconnecting to WhatsApp...",
|
||||
WAKeepaliveTimeout: "The WhatsApp web servers are not responding. The bridge will try to reconnect.",
|
||||
WAPhoneOffline: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
|
||||
WAConnectionFailed: "Connecting to the WhatsApp web servers failed.",
|
||||
WADisconnected: "Disconnected from WhatsApp. Trying to reconnect.",
|
||||
})
|
||||
}
|
||||
|
||||
func (user *User) GetRemoteID() string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
return user.Phone
|
||||
}
|
||||
|
||||
func (user *User) GetRemoteName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
/*func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) {
|
||||
if !prov.bridge.AS.CheckServerToken(w, r) {
|
||||
return
|
||||
}
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
user := prov.bridge.GetUserByMXID(id.UserID(userID))
|
||||
var global status.BridgeState
|
||||
global.StateEvent = status.StateRunning
|
||||
var remote status.BridgeState
|
||||
if user.IsConnected() {
|
||||
if user.Client.IsLoggedIn() {
|
||||
remote.StateEvent = status.StateConnected
|
||||
} else if user.Session != nil {
|
||||
remote.StateEvent = status.StateConnecting
|
||||
remote.Error = WAConnecting
|
||||
} // else: unconfigured
|
||||
} else if user.Session != nil {
|
||||
remote.StateEvent = status.StateBadCredentials
|
||||
remote.Error = WANotConnected
|
||||
} // else: unconfigured
|
||||
global = global.Fill(nil)
|
||||
resp := status.GlobalBridgeState{
|
||||
BridgeState: global,
|
||||
RemoteStates: map[string]status.BridgeState{},
|
||||
}
|
||||
if len(remote.StateEvent) > 0 {
|
||||
remote = remote.Fill(user)
|
||||
resp.RemoteStates[remote.RemoteID] = remote
|
||||
}
|
||||
user.log.Debugfln("Responding bridge state in bridge status endpoint: %+v", resp)
|
||||
jsonResponse(w, http.StatusOK, &resp)
|
||||
if len(resp.RemoteStates) > 0 {
|
||||
user.BridgeState.SetPrev(remote)
|
||||
}
|
||||
}*/
|
321
commands.go
Normal file
321
commands.go
Normal file
|
@ -0,0 +1,321 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/bridge/commands"
|
||||
"maunium.net/go/mautrix/bridge/status"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type WrappedCommandEvent struct {
|
||||
*commands.Event
|
||||
Bridge *GMBridge
|
||||
User *User
|
||||
Portal *Portal
|
||||
}
|
||||
|
||||
func (br *GMBridge) RegisterCommands() {
|
||||
proc := br.CommandProcessor.(*commands.Processor)
|
||||
proc.AddHandlers(
|
||||
cmdLogin,
|
||||
cmdDeleteSession,
|
||||
cmdReconnect,
|
||||
cmdDisconnect,
|
||||
cmdPing,
|
||||
cmdDeletePortal,
|
||||
cmdDeleteAllPortals,
|
||||
)
|
||||
}
|
||||
|
||||
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.(*GMBridge)
|
||||
handler(&WrappedCommandEvent{ce, br, user, portal})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
HelpSectionConnectionManagement = commands.HelpSection{Name: "Connection management", Order: 11}
|
||||
HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
|
||||
)
|
||||
|
||||
var cmdLogin = &commands.FullHandler{
|
||||
Func: wrapCommand(fnLogin),
|
||||
Name: "login",
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Link the bridge to Google Messages on your Android phone as a web client.",
|
||||
},
|
||||
}
|
||||
|
||||
func fnLogin(ce *WrappedCommandEvent) {
|
||||
if ce.User.Session != nil {
|
||||
if ce.User.IsConnected() {
|
||||
ce.Reply("You're already logged in")
|
||||
} else {
|
||||
ce.Reply("You're already logged in. Perhaps you wanted to `reconnect`?")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_, err := ce.User.Login(context.Background())
|
||||
if err != nil {
|
||||
ce.User.log.Errorf("Failed to log in:", err)
|
||||
ce.Reply("Failed to log in: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (user *User) sendQR(ce *WrappedCommandEvent, code string, prevEvent id.EventID) id.EventID {
|
||||
url, ok := user.uploadQR(ce, code)
|
||||
if !ok {
|
||||
return prevEvent
|
||||
}
|
||||
content := event.MessageEventContent{
|
||||
MsgType: event.MsgImage,
|
||||
Body: code,
|
||||
URL: url.CUString(),
|
||||
}
|
||||
if len(prevEvent) != 0 {
|
||||
content.SetEdit(prevEvent)
|
||||
}
|
||||
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
|
||||
if err != nil {
|
||||
user.log.Errorln("Failed to send edited QR code to user:", err)
|
||||
} else if len(prevEvent) == 0 {
|
||||
prevEvent = resp.EventID
|
||||
}
|
||||
return prevEvent
|
||||
}
|
||||
|
||||
func (user *User) uploadQR(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) {
|
||||
qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
|
||||
if err != nil {
|
||||
user.log.Errorln("Failed to encode QR code:", err)
|
||||
ce.Reply("Failed to encode QR code: %v", err)
|
||||
return id.ContentURI{}, false
|
||||
}
|
||||
|
||||
bot := user.bridge.AS.BotClient()
|
||||
|
||||
resp, err := bot.UploadBytes(qrCode, "image/png")
|
||||
if err != nil {
|
||||
user.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 cmdDeleteSession = &commands.FullHandler{
|
||||
Func: wrapCommand(fnDeleteSession),
|
||||
Name: "delete-session",
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Delete session information and disconnect from Google Messages without sending a logout request.",
|
||||
},
|
||||
}
|
||||
|
||||
func fnDeleteSession(ce *WrappedCommandEvent) {
|
||||
if ce.User.Session == nil && ce.User.Client == nil {
|
||||
ce.Reply("Nothing to purge: no session information stored and no active connection.")
|
||||
return
|
||||
}
|
||||
ce.User.removeFromPhoneMap(status.BridgeState{StateEvent: status.StateLoggedOut})
|
||||
ce.User.DeleteConnection()
|
||||
ce.User.DeleteSession()
|
||||
ce.Reply("Session information purged")
|
||||
}
|
||||
|
||||
var cmdReconnect = &commands.FullHandler{
|
||||
Func: wrapCommand(fnReconnect),
|
||||
Name: "reconnect",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionConnectionManagement,
|
||||
Description: "Reconnect to Google Messages.",
|
||||
},
|
||||
}
|
||||
|
||||
func fnReconnect(ce *WrappedCommandEvent) {
|
||||
if ce.User.Client == nil {
|
||||
if ce.User.Session == nil {
|
||||
ce.Reply("You're not logged into Google Messages. Please log in first.")
|
||||
} else {
|
||||
ce.User.Connect()
|
||||
ce.Reply("Started connecting to Google Messages")
|
||||
}
|
||||
} else {
|
||||
ce.User.DeleteConnection()
|
||||
ce.User.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WANotConnected})
|
||||
ce.User.Connect()
|
||||
ce.Reply("Restarted connection to Google Messages")
|
||||
}
|
||||
}
|
||||
|
||||
var cmdDisconnect = &commands.FullHandler{
|
||||
Func: wrapCommand(fnDisconnect),
|
||||
Name: "disconnect",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionConnectionManagement,
|
||||
Description: "Disconnect from Google Messages (without logging out).",
|
||||
},
|
||||
}
|
||||
|
||||
func fnDisconnect(ce *WrappedCommandEvent) {
|
||||
if ce.User.Client == nil {
|
||||
ce.Reply("You don't have a Google Messages connection.")
|
||||
return
|
||||
}
|
||||
ce.User.DeleteConnection()
|
||||
ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.")
|
||||
ce.User.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: WANotConnected})
|
||||
}
|
||||
|
||||
var cmdPing = &commands.FullHandler{
|
||||
Func: wrapCommand(fnPing),
|
||||
Name: "ping",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionConnectionManagement,
|
||||
Description: "Check your connection to Google Messages.",
|
||||
},
|
||||
}
|
||||
|
||||
func fnPing(ce *WrappedCommandEvent) {
|
||||
if ce.User.Session == nil {
|
||||
if ce.User.Client != nil {
|
||||
ce.Reply("Connected to Google Messages, but not logged in.")
|
||||
} else {
|
||||
ce.Reply("You're not logged into Google Messages.")
|
||||
}
|
||||
} else if ce.User.Client == nil || !ce.User.Client.IsConnected() {
|
||||
ce.Reply("You're logged in as %s (device #%d), but you don't have a Google Messages connection.", ce.User.Phone)
|
||||
} else {
|
||||
ce.Reply("Logged in as %s, connection to Google Messages may be OK", ce.User.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
func canDeletePortal(portal *Portal, userID id.UserID) bool {
|
||||
if len(portal.MXID) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
members, err := portal.MainIntent().JoinedMembers(portal.MXID)
|
||||
if err != nil {
|
||||
portal.log.Errorfln("Failed to get joined members to check if portal can be deleted by %s: %v", userID, err)
|
||||
return false
|
||||
}
|
||||
for otherUser := range members.Joined {
|
||||
_, isPuppet := portal.bridge.ParsePuppetMXID(otherUser)
|
||||
if isPuppet || otherUser == portal.bridge.Bot.UserID || otherUser == userID {
|
||||
continue
|
||||
}
|
||||
user := portal.bridge.GetUserByMXID(otherUser)
|
||||
if user != nil && user.Session != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var cmdDeletePortal = &commands.FullHandler{
|
||||
Func: wrapCommand(fnDeletePortal),
|
||||
Name: "delete-portal",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Delete the current portal. If the portal is used by other people, this is limited to bridge admins.",
|
||||
},
|
||||
RequiresPortal: true,
|
||||
}
|
||||
|
||||
func fnDeletePortal(ce *WrappedCommandEvent) {
|
||||
if !ce.User.Admin && !canDeletePortal(ce.Portal, ce.User.MXID) {
|
||||
ce.Reply("Only bridge admins can delete portals with other Matrix users")
|
||||
return
|
||||
}
|
||||
|
||||
ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.")
|
||||
ce.Portal.Delete()
|
||||
ce.Portal.Cleanup(false)
|
||||
}
|
||||
|
||||
var cmdDeleteAllPortals = &commands.FullHandler{
|
||||
Func: wrapCommand(fnDeleteAllPortals),
|
||||
Name: "delete-all-portals",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Delete all portals.",
|
||||
},
|
||||
}
|
||||
|
||||
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
|
||||
portals := ce.Bridge.GetAllPortalsForUser(ce.User.RowID)
|
||||
if len(portals) == 0 {
|
||||
ce.Reply("Didn't find any portals to delete")
|
||||
return
|
||||
}
|
||||
|
||||
leave := func(portal *Portal) {
|
||||
if len(portal.MXID) > 0 {
|
||||
_, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{
|
||||
Reason: "Deleting portal",
|
||||
UserID: ce.User.MXID,
|
||||
})
|
||||
}
|
||||
}
|
||||
intent := ce.User.DoublePuppetIntent
|
||||
if intent != nil {
|
||||
leave = func(portal *Portal) {
|
||||
if len(portal.MXID) > 0 {
|
||||
_, _ = intent.LeaveRoom(portal.MXID)
|
||||
_, _ = intent.ForgetRoom(portal.MXID)
|
||||
}
|
||||
}
|
||||
}
|
||||
roomYeeting := ce.Bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting)
|
||||
if roomYeeting {
|
||||
leave = func(portal *Portal) {
|
||||
portal.Cleanup(false)
|
||||
}
|
||||
}
|
||||
ce.Reply("Found %d portals, deleting...", len(portals))
|
||||
for _, portal := range portals {
|
||||
portal.Delete()
|
||||
leave(portal)
|
||||
}
|
||||
if !roomYeeting {
|
||||
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.")
|
||||
go func() {
|
||||
for _, portal := range portals {
|
||||
portal.Cleanup(false)
|
||||
}
|
||||
ce.Reply("Finished background cleanup of deleted portal rooms.")
|
||||
}()
|
||||
} else {
|
||||
ce.Reply("Finished deleting portals.")
|
||||
}
|
||||
}
|
159
config/bridge.go
Normal file
159
config/bridge.go
Normal file
|
@ -0,0 +1,159 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||
)
|
||||
|
||||
type BridgeConfig struct {
|
||||
UsernameTemplate string `yaml:"username_template"`
|
||||
DisplaynameTemplate string `yaml:"displayname_template"`
|
||||
|
||||
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
|
||||
|
||||
DeliveryReceipts bool `yaml:"delivery_receipts"`
|
||||
MessageStatusEvents bool `yaml:"message_status_events"`
|
||||
MessageErrorNotices bool `yaml:"message_error_notices"`
|
||||
PortalMessageBuffer int `yaml:"portal_message_buffer"`
|
||||
|
||||
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
|
||||
|
||||
DoublePuppetServerMap map[string]string `yaml:"double_puppet_server_map"`
|
||||
DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"`
|
||||
LoginSharedSecretMap map[string]string `yaml:"login_shared_secret_map"`
|
||||
|
||||
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
|
||||
BridgeNotices bool `yaml:"bridge_notices"`
|
||||
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
|
||||
MuteBridging bool `yaml:"mute_bridging"`
|
||||
ArchiveTag string `yaml:"archive_tag"`
|
||||
PinnedTag string `yaml:"pinned_tag"`
|
||||
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
|
||||
FederateRooms bool `yaml:"federate_rooms"`
|
||||
CaptionInMessage bool `yaml:"caption_in_message"`
|
||||
|
||||
DisableBridgeAlerts bool `yaml:"disable_bridge_alerts"`
|
||||
|
||||
CommandPrefix string `yaml:"command_prefix"`
|
||||
|
||||
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
|
||||
|
||||
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
|
||||
|
||||
Provisioning struct {
|
||||
Prefix string `yaml:"prefix"`
|
||||
SharedSecret string `yaml:"shared_secret"`
|
||||
} `yaml:"provisioning"`
|
||||
|
||||
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
|
||||
|
||||
ParsedUsernameTemplate *template.Template `yaml:"-"`
|
||||
displaynameTemplate *template.Template `yaml:"-"`
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
|
||||
return bc.Encryption
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) EnableMessageStatusEvents() bool {
|
||||
return bc.MessageStatusEvents
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) EnableMessageErrorNotices() bool {
|
||||
return bc.MessageErrorNotices
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) GetCommandPrefix() string {
|
||||
return bc.CommandPrefix
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
|
||||
return bc.ManagementRoomText
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) GetResendBridgeInfo() bool {
|
||||
return bc.ResendBridgeInfo
|
||||
}
|
||||
|
||||
func boolToInt(val bool) int {
|
||||
if val {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) Validate() error {
|
||||
_, hasWildcard := bc.Permissions["*"]
|
||||
_, hasExampleDomain := bc.Permissions["example.com"]
|
||||
_, hasExampleUser := bc.Permissions["@admin:example.com"]
|
||||
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
|
||||
if len(bc.Permissions) <= exampleLen {
|
||||
return errors.New("bridge.permissions not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type umBridgeConfig BridgeConfig
|
||||
|
||||
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
err := unmarshal((*umBridgeConfig)(bc))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bc.ParsedUsernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !strings.Contains(bc.FormatUsername("1.1234567890"), "1.1234567890") {
|
||||
return fmt.Errorf("username template is missing user ID placeholder")
|
||||
}
|
||||
|
||||
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type DisplaynameTemplateArgs struct {
|
||||
PhoneNumber string
|
||||
FullName string
|
||||
FirstName string
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) FormatDisplayname(phone, fullName, firstName string) string {
|
||||
var buf strings.Builder
|
||||
_ = bc.displaynameTemplate.Execute(&buf, DisplaynameTemplateArgs{
|
||||
PhoneNumber: phone,
|
||||
FullName: fullName,
|
||||
FirstName: firstName,
|
||||
})
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) FormatUsername(username string) string {
|
||||
var buf strings.Builder
|
||||
_ = bc.ParsedUsernameTemplate.Execute(&buf, username)
|
||||
return buf.String()
|
||||
}
|
42
config/config.go
Normal file
42
config/config.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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/bridge/bridgeconfig"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
*bridgeconfig.BaseConfig `yaml:",inline"`
|
||||
|
||||
SegmentKey string `yaml:"segment_key"`
|
||||
SegmentUserID string `yaml:"segment_user_id"`
|
||||
|
||||
Metrics struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Listen string `yaml:"listen"`
|
||||
} `yaml:"metrics"`
|
||||
|
||||
Bridge BridgeConfig `yaml:"bridge"`
|
||||
}
|
||||
|
||||
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
|
||||
_, homeserver, _ := userID.Parse()
|
||||
_, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
|
||||
return hasSecret
|
||||
}
|
108
config/upgrade.go
Normal file
108
config/upgrade.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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/bridge/bridgeconfig"
|
||||
"maunium.net/go/mautrix/util"
|
||||
up "maunium.net/go/mautrix/util/configupgrade"
|
||||
)
|
||||
|
||||
func DoUpgrade(helper *up.Helper) {
|
||||
bridgeconfig.Upgrader.DoUpgrade(helper)
|
||||
|
||||
helper.Copy(up.Str|up.Null, "segment_key")
|
||||
helper.Copy(up.Str|up.Null, "segment_user_id")
|
||||
|
||||
helper.Copy(up.Bool, "metrics", "enabled")
|
||||
helper.Copy(up.Str, "metrics", "listen")
|
||||
|
||||
helper.Copy(up.Str, "bridge", "username_template")
|
||||
helper.Copy(up.Str, "bridge", "displayname_template")
|
||||
helper.Copy(up.Bool, "bridge", "personal_filtering_spaces")
|
||||
helper.Copy(up.Bool, "bridge", "delivery_receipts")
|
||||
helper.Copy(up.Bool, "bridge", "message_status_events")
|
||||
helper.Copy(up.Bool, "bridge", "message_error_notices")
|
||||
helper.Copy(up.Int, "bridge", "portal_message_buffer")
|
||||
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
|
||||
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", "private_chat_portal_meta")
|
||||
helper.Copy(up.Bool, "bridge", "bridge_notices")
|
||||
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
|
||||
helper.Copy(up.Bool, "bridge", "mute_bridging")
|
||||
helper.Copy(up.Str|up.Null, "bridge", "archive_tag")
|
||||
helper.Copy(up.Str|up.Null, "bridge", "pinned_tag")
|
||||
helper.Copy(up.Bool, "bridge", "tag_only_on_create")
|
||||
helper.Copy(up.Str, "bridge", "command_prefix")
|
||||
helper.Copy(up.Bool, "bridge", "federate_rooms")
|
||||
helper.Copy(up.Bool, "bridge", "disable_bridge_alerts")
|
||||
helper.Copy(up.Bool, "bridge", "caption_in_message")
|
||||
|
||||
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", "require")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
|
||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
|
||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
|
||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
|
||||
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
|
||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
|
||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
|
||||
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
|
||||
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
|
||||
sharedSecret := util.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")
|
||||
}
|
||||
|
||||
var SpacedBlocks = [][]string{
|
||||
{"homeserver", "software"},
|
||||
{"appservice"},
|
||||
{"appservice", "hostname"},
|
||||
{"appservice", "database"},
|
||||
{"appservice", "id"},
|
||||
{"appservice", "as_token"},
|
||||
{"segment_key"},
|
||||
{"metrics"},
|
||||
{"bridge"},
|
||||
{"bridge", "command_prefix"},
|
||||
{"bridge", "management_room_text"},
|
||||
{"bridge", "encryption"},
|
||||
{"bridge", "provisioning"},
|
||||
{"bridge", "permissions"},
|
||||
{"logging"},
|
||||
}
|
168
custompuppet.go
Normal file
168
custompuppet.go
Normal file
|
@ -0,0 +1,168 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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"
|
||||
"crypto/hmac"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/bridge"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
|
||||
)
|
||||
|
||||
var _ bridge.DoublePuppet = (*User)(nil)
|
||||
|
||||
func (user *User) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
|
||||
if mxid != user.MXID {
|
||||
return errors.New("mismatching mxid")
|
||||
}
|
||||
user.AccessToken = accessToken
|
||||
return user.startCustomMXID(false)
|
||||
}
|
||||
|
||||
func (user *User) CustomIntent() *appservice.IntentAPI {
|
||||
return user.DoublePuppetIntent
|
||||
}
|
||||
|
||||
func (user *User) loginWithSharedSecret() error {
|
||||
_, homeserver, _ := user.MXID.Parse()
|
||||
user.zlog.Debug().Msg("Logging into double puppet with shared secret")
|
||||
loginSecret := user.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]
|
||||
client, err := user.bridge.newDoublePuppetClient(user.MXID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := mautrix.ReqLogin{
|
||||
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(user.MXID)},
|
||||
DeviceID: "Google Messages Bridge",
|
||||
InitialDeviceDisplayName: "Google Messages Bridge",
|
||||
}
|
||||
if loginSecret == "appservice" {
|
||||
client.AccessToken = user.bridge.AS.Registration.AppToken
|
||||
req.Type = mautrix.AuthTypeAppservice
|
||||
} else {
|
||||
mac := hmac.New(sha512.New, []byte(loginSecret))
|
||||
mac.Write([]byte(user.MXID))
|
||||
req.Password = hex.EncodeToString(mac.Sum(nil))
|
||||
req.Type = mautrix.AuthTypePassword
|
||||
}
|
||||
resp, err := client.Login(&req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to log in with shared secret: %w", err)
|
||||
}
|
||||
user.AccessToken = resp.AccessToken
|
||||
err = user.Update(context.TODO())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save access token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (br *GMBridge) 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 = ""
|
||||
} 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.ZLog.Debug().
|
||||
Str("server_name", homeserver).
|
||||
Str("base_url", homeserverURL).
|
||||
Str("user_id", mxid.String()).
|
||||
Msg("Discovered homeserver URL to enable double puppeting for external user")
|
||||
} else {
|
||||
return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
|
||||
}
|
||||
}
|
||||
return br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL)
|
||||
}
|
||||
|
||||
func (user *User) newDoublePuppetIntent() (*appservice.IntentAPI, error) {
|
||||
client, err := user.bridge.newDoublePuppetClient(user.MXID, user.AccessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ia := user.bridge.AS.NewIntentAPI("custom")
|
||||
ia.Client = client
|
||||
ia.Localpart, _, _ = user.MXID.Parse()
|
||||
ia.UserID = user.MXID
|
||||
ia.IsCustomPuppet = true
|
||||
return ia, nil
|
||||
}
|
||||
|
||||
func (user *User) clearCustomMXID() {
|
||||
user.AccessToken = ""
|
||||
user.DoublePuppetIntent = nil
|
||||
}
|
||||
|
||||
func (user *User) startCustomMXID(reloginOnFail bool) error {
|
||||
if len(user.AccessToken) == 0 {
|
||||
return nil
|
||||
}
|
||||
intent, err := user.newDoublePuppetIntent()
|
||||
if err != nil {
|
||||
user.clearCustomMXID()
|
||||
return fmt.Errorf("failed to create double puppet intent: %w", err)
|
||||
}
|
||||
resp, err := intent.Whoami()
|
||||
if err != nil {
|
||||
if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !user.tryRelogin(err)) {
|
||||
user.clearCustomMXID()
|
||||
return fmt.Errorf("failed to ensure double puppet token is valid: %w", err)
|
||||
}
|
||||
intent.AccessToken = user.AccessToken
|
||||
}
|
||||
if resp.UserID != user.MXID {
|
||||
user.clearCustomMXID()
|
||||
return ErrMismatchingMXID
|
||||
}
|
||||
user.DoublePuppetIntent = intent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) tryRelogin(err error) bool {
|
||||
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
|
||||
return false
|
||||
}
|
||||
user.zlog.Debug().Err(err).Msg("Trying to relogin after error in double puppet")
|
||||
err = user.loginWithSharedSecret()
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Failed to relogin after error in double puppet")
|
||||
return false
|
||||
}
|
||||
user.zlog.Info().Msg("Successfully relogined after error in double puppet")
|
||||
return true
|
||||
}
|
77
database/database.go
Normal file
77
database/database.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"maunium.net/go/mautrix/util/dbutil"
|
||||
|
||||
"go.mau.fi/mautrix-gmessages/database/upgrades"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
*dbutil.Database
|
||||
|
||||
User *UserQuery
|
||||
Portal *PortalQuery
|
||||
Puppet *PuppetQuery
|
||||
Message *MessageQuery
|
||||
}
|
||||
|
||||
func New(baseDB *dbutil.Database) *Database {
|
||||
db := &Database{Database: baseDB}
|
||||
db.UpgradeTable = upgrades.Table
|
||||
db.User = &UserQuery{db: db}
|
||||
db.Portal = &PortalQuery{db: db}
|
||||
db.Puppet = &PuppetQuery{db: db}
|
||||
db.Message = &MessageQuery{db: db}
|
||||
return db
|
||||
}
|
||||
|
||||
type dataStruct[T any] interface {
|
||||
Scan(row dbutil.Scannable) (T, error)
|
||||
}
|
||||
|
||||
type queryStruct[T dataStruct[T]] interface {
|
||||
New() T
|
||||
getDB() *Database
|
||||
}
|
||||
|
||||
func get[T dataStruct[T]](qs queryStruct[T], ctx context.Context, query string, args ...any) (T, error) {
|
||||
return qs.New().Scan(qs.getDB().Conn(ctx).QueryRowContext(ctx, query, args...))
|
||||
}
|
||||
|
||||
func getAll[T dataStruct[T]](qs queryStruct[T], ctx context.Context, query string, args ...any) ([]T, error) {
|
||||
rows, err := qs.getDB().Conn(ctx).QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]T, 0)
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
for rows.Next() {
|
||||
item, err := qs.New().Scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
104
database/message.go
Normal file
104
database/message.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/util/dbutil"
|
||||
)
|
||||
|
||||
type MessageQuery struct {
|
||||
db *Database
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) New() *Message {
|
||||
return &Message{
|
||||
db: mq.db,
|
||||
}
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) getDB() *Database {
|
||||
return mq.db
|
||||
}
|
||||
|
||||
const (
|
||||
getMessageByIDQuery = `
|
||||
SELECT conv_id, conv_receiver, id, mxid, sender, timestamp FROM message
|
||||
WHERE conv_id=$1 AND conv_receiver=$2 AND id=$3
|
||||
`
|
||||
getMessageByMXIDQuery = `
|
||||
SELECT conv_id, conv_receiver, id, mxid, sender, timestamp FROM message
|
||||
WHERE mxid=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (mq *MessageQuery) GetByID(ctx context.Context, chat Key, messageID string) (*Message, error) {
|
||||
return get[*Message](mq, ctx, getMessageByIDQuery, chat.ID, chat.Receiver, messageID)
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) {
|
||||
return get[*Message](mq, ctx, getMessageByMXIDQuery, mxid)
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
Chat Key
|
||||
ID string
|
||||
MXID id.EventID
|
||||
Sender string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) {
|
||||
var ts int64
|
||||
err := row.Scan(&msg.Chat.ID, &msg.Chat.Receiver, &msg.ID, &msg.MXID, &msg.Sender, &msg.Timestamp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ts != 0 {
|
||||
msg.Timestamp = time.UnixMilli(ts)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (msg *Message) sqlVariables() []any {
|
||||
return []any{msg.Chat.ID, msg.Chat.Receiver, msg.ID, msg.MXID, msg.Sender, msg.Timestamp.UnixMilli()}
|
||||
}
|
||||
|
||||
func (msg *Message) Insert(ctx context.Context) error {
|
||||
_, err := msg.db.Conn(ctx).ExecContext(ctx, `
|
||||
INSERT INTO message (conv_id, conv_receiver, id, mxid, sender, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, msg.sqlVariables()...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (msg *Message) Delete(ctx context.Context) error {
|
||||
_, err := msg.db.Conn(ctx).ExecContext(ctx, "DELETE FROM message WHERE conv_id=$1 AND conv_receiver=$2 AND id=$3", msg.Chat.ID, msg.Chat.Receiver, msg.ID)
|
||||
return err
|
||||
}
|
138
database/portal.go
Normal file
138
database/portal.go
Normal file
|
@ -0,0 +1,138 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/util/dbutil"
|
||||
)
|
||||
|
||||
type PortalQuery struct {
|
||||
db *Database
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) New() *Portal {
|
||||
return &Portal{
|
||||
db: pq.db,
|
||||
}
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) getDB() *Database {
|
||||
return pq.db
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) {
|
||||
return getAll[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal")
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetAllForUser(ctx context.Context, receiver int) ([]*Portal, error) {
|
||||
return getAll[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal WHERE receiver=$1", receiver)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetByKey(ctx context.Context, key Key) (*Portal, error) {
|
||||
return get[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal WHERE id=$1 AND receiver=$2", key.ID, key.Receiver)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) {
|
||||
return get[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal WHERE mxid=$1", mxid)
|
||||
}
|
||||
|
||||
type Key struct {
|
||||
ID string
|
||||
Receiver int
|
||||
}
|
||||
|
||||
func (p Key) String() string {
|
||||
return fmt.Sprintf("%d.%s", p.Receiver, p.ID)
|
||||
}
|
||||
|
||||
func (p Key) MarshalZerologObject(e *zerolog.Event) {
|
||||
e.Str("id", p.ID).Int("receiver", p.Receiver)
|
||||
}
|
||||
|
||||
type Portal struct {
|
||||
db *Database
|
||||
|
||||
Key
|
||||
SelfUserID string
|
||||
OtherUserID string
|
||||
MXID id.RoomID
|
||||
|
||||
Name string
|
||||
NameSet bool
|
||||
AvatarID string
|
||||
AvatarMXC id.ContentURI
|
||||
AvatarSet bool
|
||||
Encrypted bool
|
||||
InSpace bool
|
||||
}
|
||||
|
||||
func (portal *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
|
||||
var mxid, selfUserID, otherUserID sql.NullString
|
||||
err := row.Scan(&portal.ID, &portal.Receiver, &selfUserID, &otherUserID, &mxid, &portal.Name, &portal.NameSet, &portal.AvatarID, &portal.AvatarMXC, &portal.AvatarSet, &portal.Encrypted, &portal.InSpace)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
portal.MXID = id.RoomID(mxid.String)
|
||||
portal.SelfUserID = selfUserID.String
|
||||
portal.OtherUserID = otherUserID.String
|
||||
return portal, nil
|
||||
}
|
||||
|
||||
func (portal *Portal) sqlVariables() []any {
|
||||
var mxid, selfUserID, otherUserID *string
|
||||
if portal.MXID != "" {
|
||||
mxid = (*string)(&portal.MXID)
|
||||
}
|
||||
if portal.SelfUserID != "" {
|
||||
selfUserID = &portal.SelfUserID
|
||||
}
|
||||
if portal.OtherUserID != "" {
|
||||
otherUserID = &portal.OtherUserID
|
||||
}
|
||||
return []any{portal.ID, portal.Receiver, selfUserID, otherUserID, mxid, portal.Name, portal.NameSet, portal.AvatarID, portal.AvatarMXC, portal.AvatarSet, portal.Encrypted, portal.InSpace}
|
||||
}
|
||||
|
||||
func (portal *Portal) Insert(ctx context.Context) error {
|
||||
_, err := portal.db.Conn(ctx).ExecContext(ctx, `
|
||||
INSERT INTO portal (id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
`, portal.sqlVariables()...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (portal *Portal) Update(ctx context.Context) error {
|
||||
_, err := portal.db.Conn(ctx).ExecContext(ctx, `
|
||||
UPDATE portal
|
||||
SET self_user=$3, other_user=$4, mxid=$5, name=$6, name_set=$7, avatar_id=$8, avatar_mxc=$9, avatar_set=$10, encrypted=$11, in_space=$12
|
||||
WHERE id=$1 AND receiver=$2
|
||||
`, portal.sqlVariables()...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (portal *Portal) Delete(ctx context.Context) error {
|
||||
_, err := portal.db.Conn(ctx).ExecContext(ctx, "DELETE FROM portal WHERE id=$1 AND receiver=$2", portal.ID, portal.Receiver)
|
||||
return err
|
||||
}
|
92
database/puppet.go
Normal file
92
database/puppet.go
Normal file
|
@ -0,0 +1,92 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/util/dbutil"
|
||||
)
|
||||
|
||||
type PuppetQuery struct {
|
||||
db *Database
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) New() *Puppet {
|
||||
return &Puppet{
|
||||
db: pq.db,
|
||||
}
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) getDB() *Database {
|
||||
return pq.db
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) GetAll(ctx context.Context) ([]*Puppet, error) {
|
||||
return getAll[*Puppet](pq, ctx, "SELECT id, receiver, phone, name, name_set, avatar_id, avatar_mxc, avatar_set, contact_info_set FROM puppet")
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) Get(ctx context.Context, key Key) (*Puppet, error) {
|
||||
return get[*Puppet](pq, ctx, "SELECT id, receiver, phone, name, name_set, avatar_id, avatar_mxc, avatar_set, contact_info_set FROM puppet WHERE phone=$1 AND receiver=$2", key.ID, key.Receiver)
|
||||
}
|
||||
|
||||
type Puppet struct {
|
||||
db *Database
|
||||
|
||||
Key
|
||||
Phone string
|
||||
Name string
|
||||
NameSet bool
|
||||
AvatarID string
|
||||
AvatarMXC id.ContentURI
|
||||
AvatarSet bool
|
||||
ContactInfoSet bool
|
||||
}
|
||||
|
||||
func (puppet *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) {
|
||||
err := row.Scan(&puppet.ID, &puppet.Receiver, &puppet.Phone, &puppet.Name, &puppet.NameSet, &puppet.AvatarID, &puppet.AvatarMXC, &puppet.AvatarSet, &puppet.ContactInfoSet)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return puppet, nil
|
||||
}
|
||||
|
||||
func (puppet *Puppet) sqlVariables() []any {
|
||||
return []any{puppet.ID, puppet.Receiver, puppet.Phone, puppet.Name, puppet.NameSet, puppet.AvatarID, puppet.AvatarMXC, puppet.AvatarSet, puppet.ContactInfoSet}
|
||||
}
|
||||
|
||||
func (puppet *Puppet) Insert(ctx context.Context) error {
|
||||
_, err := puppet.db.Conn(ctx).ExecContext(ctx, `
|
||||
INSERT INTO puppet (id, receiver, phone, name, name_set, avatar_id, avatar_mxc, avatar_set, contact_info_set)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, puppet.sqlVariables()...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (puppet *Puppet) Update(ctx context.Context) error {
|
||||
_, err := puppet.db.Conn(ctx).ExecContext(ctx, `
|
||||
UPDATE puppet
|
||||
SET phone=$3, name=$4, name_set=$5, avatar_id=$6, avatar_mxc=$7, avatar_set=$8, contact_info_set=$9
|
||||
WHERE id=$1 AND receiver=$2
|
||||
`, puppet.sqlVariables()...)
|
||||
return err
|
||||
}
|
64
database/upgrades/00-latest-revision.sql
Normal file
64
database/upgrades/00-latest-revision.sql
Normal file
|
@ -0,0 +1,64 @@
|
|||
-- v0 -> v1: Latest revision
|
||||
|
||||
CREATE TABLE "user" (
|
||||
-- only: postgres
|
||||
rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
|
||||
-- only: sqlite
|
||||
rowid INTEGER PRIMARY KEY,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
phone TEXT UNIQUE,
|
||||
session jsonb,
|
||||
|
||||
management_room TEXT,
|
||||
space_room TEXT,
|
||||
|
||||
access_token TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE puppet (
|
||||
id TEXT NOT NULL,
|
||||
receiver BIGINT NOT NULL,
|
||||
phone TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
||||
avatar_id TEXT NOT NULL,
|
||||
avatar_mxc TEXT NOT NULL,
|
||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
||||
contact_info_set BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
FOREIGN KEY (receiver) REFERENCES "user"(rowid) ON DELETE CASCADE,
|
||||
UNIQUE (phone, receiver),
|
||||
PRIMARY KEY (id, receiver)
|
||||
);
|
||||
|
||||
CREATE TABLE portal (
|
||||
id TEXT NOT NULL,
|
||||
receiver BIGINT NOT NULL,
|
||||
self_user TEXT,
|
||||
other_user TEXT,
|
||||
mxid TEXT UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
||||
avatar_id TEXT NOT NULL,
|
||||
avatar_mxc TEXT NOT NULL,
|
||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
||||
encrypted BOOLEAN NOT NULL DEFAULT false,
|
||||
in_space BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
FOREIGN KEY (receiver) REFERENCES "user"(rowid) ON DELETE CASCADE,
|
||||
FOREIGN KEY (other_user, receiver) REFERENCES puppet(id, receiver) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id, receiver)
|
||||
);
|
||||
|
||||
CREATE TABLE message (
|
||||
conv_id TEXT NOT NULL,
|
||||
conv_receiver BIGINT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
sender TEXT NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (conv_id, conv_receiver, id),
|
||||
FOREIGN KEY (conv_id, conv_receiver) REFERENCES portal(id, receiver) ON DELETE CASCADE
|
||||
);
|
32
database/upgrades/upgrades.go
Normal file
32
database/upgrades/upgrades.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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)
|
||||
}
|
144
database/user.go
Normal file
144
database/user.go
Normal file
|
@ -0,0 +1,144 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/util/dbutil"
|
||||
|
||||
"go.mau.fi/mautrix-gmessages/libgm/binary"
|
||||
)
|
||||
|
||||
type UserQuery struct {
|
||||
db *Database
|
||||
}
|
||||
|
||||
func (uq *UserQuery) New() *User {
|
||||
return &User{
|
||||
db: uq.db,
|
||||
}
|
||||
}
|
||||
|
||||
func (uq *UserQuery) getDB() *Database {
|
||||
return uq.db
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetAllWithSession(ctx context.Context) ([]*User, error) {
|
||||
return getAll[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE phone<>'' AND session IS NOT NULL`)
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetAllWithDoublePuppet(ctx context.Context) ([]*User, error) {
|
||||
return getAll[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE access_token<>''`)
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetByRowID(ctx context.Context, rowID int) (*User, error) {
|
||||
return get[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE rowid=$1`, rowID)
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) {
|
||||
return get[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE mxid=$1`, userID)
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetByPhone(ctx context.Context, phone string) (*User, error) {
|
||||
return get[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE phone=$1`, phone)
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
WebAuthKey []byte `json:"web_auth_key"`
|
||||
AESKey []byte `json:"aes_key"`
|
||||
HMACKey []byte `json:"hmac_key"`
|
||||
|
||||
PhoneInfo *binary.Device `json:"phone_info"`
|
||||
BrowserInfo *binary.Device `json:"browser_info"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
db *Database
|
||||
|
||||
RowID int
|
||||
MXID id.UserID
|
||||
Phone string
|
||||
Session *Session
|
||||
|
||||
ManagementRoom id.RoomID
|
||||
SpaceRoom id.RoomID
|
||||
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
func (user *User) Scan(row dbutil.Scannable) (*User, error) {
|
||||
var phone, session, managementRoom, spaceRoom, accessToken sql.NullString
|
||||
err := row.Scan(&user.RowID, &user.MXID, &phone, &session, &managementRoom, &spaceRoom, &accessToken)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.String != "" {
|
||||
var sess Session
|
||||
err = json.Unmarshal([]byte(session.String), &sess)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse session: %w", err)
|
||||
}
|
||||
user.Session = &sess
|
||||
}
|
||||
user.Phone = phone.String
|
||||
user.AccessToken = accessToken.String
|
||||
user.ManagementRoom = id.RoomID(managementRoom.String)
|
||||
user.SpaceRoom = id.RoomID(spaceRoom.String)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (user *User) sqlVariables() []any {
|
||||
var phone, session, managementRoom, spaceRoom, accessToken *string
|
||||
if user.Phone != "" {
|
||||
phone = &user.Phone
|
||||
}
|
||||
if user.Session != nil {
|
||||
data, _ := json.Marshal(user.Session)
|
||||
strData := string(data)
|
||||
session = &strData
|
||||
}
|
||||
if user.ManagementRoom != "" {
|
||||
managementRoom = (*string)(&user.ManagementRoom)
|
||||
}
|
||||
if user.SpaceRoom != "" {
|
||||
spaceRoom = (*string)(&user.SpaceRoom)
|
||||
}
|
||||
if user.AccessToken != "" {
|
||||
accessToken = &user.AccessToken
|
||||
}
|
||||
return []any{user.MXID, phone, session, managementRoom, spaceRoom, accessToken}
|
||||
}
|
||||
|
||||
func (user *User) Insert(ctx context.Context) error {
|
||||
err := user.db.Conn(ctx).
|
||||
QueryRowContext(ctx, `INSERT INTO "user" (mxid, phone, session, management_room, space_room, access_token) VALUES ($1, $2, $3, $4, $5, $6) RETURNING rowid`, user.sqlVariables()...).
|
||||
Scan(&user.RowID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (user *User) Update(ctx context.Context) error {
|
||||
_, err := user.db.Conn(ctx).ExecContext(ctx, `UPDATE "user" SET phone=$2, session=$3, management_room=$4, space_room=$5, access_token=$6 WHERE mxid=$1`, user.sqlVariables()...)
|
||||
return err
|
||||
}
|
36
docker-run.sh
Executable file
36
docker-run.sh
Executable file
|
@ -0,0 +1,36 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [[ -z "$GID" ]]; then
|
||||
GID="$UID"
|
||||
fi
|
||||
|
||||
# Define functions.
|
||||
function fixperms {
|
||||
chown -R $UID:$GID /data
|
||||
|
||||
# /opt/mautrix-gmessages is read-only, so disable file logging if it's pointing there.
|
||||
if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-gmessages.log" ]]; then
|
||||
yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ! -f /data/config.yaml ]]; then
|
||||
cp /opt/mautrix-gmessages/example-config.yaml /data/config.yaml
|
||||
echo "Didn't find a config file."
|
||||
echo "Copied default config file to /data/config.yaml"
|
||||
echo "Modify that config file to your liking."
|
||||
echo "Start the container again after that to generate the registration file."
|
||||
exit
|
||||
fi
|
||||
|
||||
if [[ ! -f /data/registration.yaml ]]; then
|
||||
/usr/bin/mautrix-gmessages -g -c /data/config.yaml -r /data/registration.yaml || exit $?
|
||||
echo "Didn't find a registration file."
|
||||
echo "Generated one for you."
|
||||
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
|
||||
exit
|
||||
fi
|
||||
|
||||
cd /data
|
||||
fixperms
|
||||
exec su-exec $UID:$GID /usr/bin/mautrix-gmessages
|
282
example-config.yaml
Normal file
282
example-config.yaml
Normal file
|
@ -0,0 +1,282 @@
|
|||
# Homeserver details.
|
||||
homeserver:
|
||||
# The address that this appservice can use to connect to the homeserver.
|
||||
address: https://matrix.example.com
|
||||
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
|
||||
domain: example.com
|
||||
|
||||
# What software is the homeserver running?
|
||||
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
|
||||
software: standard
|
||||
# The URL to push real-time bridge status to.
|
||||
# If set, the bridge will make POST requests to this URL whenever a user's google messages 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
|
||||
|
||||
# Should the bridge use a websocket for connecting to the homeserver?
|
||||
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
|
||||
# mautrix-asmux (deprecated), and hungryserv (proprietary).
|
||||
websocket: false
|
||||
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
|
||||
ping_interval_seconds: 0
|
||||
|
||||
# Application service host/registration related details.
|
||||
# Changing these values requires regeneration of the registration.
|
||||
appservice:
|
||||
# The address that the homeserver can use to connect to this appservice.
|
||||
address: http://localhost:29336
|
||||
|
||||
# The hostname and port where this appservice should listen.
|
||||
hostname: 0.0.0.0
|
||||
port: 29336
|
||||
|
||||
# Database config.
|
||||
database:
|
||||
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
|
||||
type: postgres
|
||||
# The database URI.
|
||||
# SQLite: A raw file path is supported, but `file:<path>?_txlock=immediate` is recommended.
|
||||
# https://github.com/mattn/go-sqlite3#connection-string
|
||||
# Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
|
||||
# To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
|
||||
uri: postgres://user:password@host/database?sslmode=disable
|
||||
# Maximum number of connections. Mostly relevant for Postgres.
|
||||
max_open_conns: 20
|
||||
max_idle_conns: 2
|
||||
# Maximum connection idle time and lifetime before they're closed. Disabled if null.
|
||||
# Parsed with https://pkg.go.dev/time#ParseDuration
|
||||
max_conn_idle_time: null
|
||||
max_conn_lifetime: null
|
||||
|
||||
# The unique ID of this appservice.
|
||||
id: gmessages
|
||||
# Appservice bot details.
|
||||
bot:
|
||||
# Username of the appservice bot.
|
||||
username: gmessagesbot
|
||||
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
|
||||
# to leave display name/avatar as-is.
|
||||
displayname: Google Messages bridge bot
|
||||
avatar: mxc://maunium.net/yGOdcrJcwqARZqdzbfuxfhzb
|
||||
|
||||
# Whether or not to receive ephemeral events via appservice transactions.
|
||||
# Requires MSC2409 support (i.e. Synapse 1.22+).
|
||||
ephemeral_events: true
|
||||
|
||||
# Should incoming events be handled asynchronously?
|
||||
# This may be necessary for large public instances with lots of messages going through.
|
||||
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
|
||||
async_transactions: false
|
||||
|
||||
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
||||
as_token: "This value is generated when generating the registration"
|
||||
hs_token: "This value is generated when generating the registration"
|
||||
|
||||
# Segment API key to track some events, like provisioning API login and encryption errors.
|
||||
segment_key: null
|
||||
# Optional user_id to use when sending Segment events. If null, defaults to using mxID.
|
||||
segment_user_id: null
|
||||
|
||||
# Prometheus config.
|
||||
metrics:
|
||||
# Enable prometheus metrics?
|
||||
enabled: false
|
||||
# IP and port where the metrics listener should be. The path is always /metrics
|
||||
listen: 127.0.0.1:8001
|
||||
|
||||
# Bridge config
|
||||
bridge:
|
||||
# Localpart template of MXIDs for SMS users.
|
||||
# {{.}} is replaced with an identifier of the recipient.
|
||||
username_template: gmessages_{{.}}
|
||||
# Displayname template for SMS users.
|
||||
# {{.FullName}} - Full name provided by the phone
|
||||
# {{.FirstName}} - First name provided by the phone
|
||||
# {{.PhoneNumber}} - Formatted phone number provided by the phone
|
||||
displayname_template: "{{or .FullName .PhoneNumber}}"
|
||||
# Should the bridge create a space for each logged-in user and add bridged rooms to it?
|
||||
personal_filtering_spaces: true
|
||||
# Should the bridge send a read receipt from the bridge bot when a message has been sent to the phone?
|
||||
delivery_receipts: false
|
||||
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
|
||||
message_status_events: false
|
||||
# Whether the bridge should send error notices via m.notice events when a message fails to bridge.
|
||||
message_error_notices: true
|
||||
|
||||
portal_message_buffer: 128
|
||||
|
||||
# Should the bridge update the m.direct account data event when double puppeting is enabled.
|
||||
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
|
||||
# and is therefore prone to race conditions.
|
||||
sync_direct_chat_list: false
|
||||
|
||||
# Servers to always allow double puppeting from
|
||||
double_puppet_server_map:
|
||||
example.com: https://example.com
|
||||
# Allow using double puppeting from any server with a valid client .well-known file.
|
||||
double_puppet_allow_discovery: false
|
||||
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
|
||||
#
|
||||
# If set, double puppeting will be enabled automatically for local users
|
||||
# instead of users having to find an access token and run `login-matrix`
|
||||
# manually.
|
||||
login_shared_secret_map:
|
||||
example.com: foobar
|
||||
|
||||
# Whether to explicitly set the avatar and room name for private chat portal rooms.
|
||||
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
|
||||
# If set to `always`, all DM rooms will have explicit names and avatars set.
|
||||
# If set to `never`, DM rooms will never have names and avatars set.
|
||||
private_chat_portal_meta: default
|
||||
# Should Matrix m.notice-type messages be bridged?
|
||||
bridge_notices: true
|
||||
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
||||
# This field will automatically be changed back to false after it, except if the config file is not writable.
|
||||
resend_bridge_info: false
|
||||
# When using double puppeting, should muted chats be muted in Matrix?
|
||||
mute_bridging: false
|
||||
# When using double puppeting, should archived chats be moved to a specific tag in Matrix?
|
||||
# This can be set to a tag (e.g. m.lowpriority), or null to disable.
|
||||
archive_tag: null
|
||||
# Same as above, but for pinned chats. The favorite tag is called m.favourite
|
||||
pinned_tag: null
|
||||
# Should mute status and tags only be bridged when the portal room is created?
|
||||
tag_only_on_create: true
|
||||
# Whether or not created rooms should have federation enabled.
|
||||
# If false, created portal rooms will never be federated.
|
||||
federate_rooms: true
|
||||
# Should the bridge never send alerts to the bridge management room?
|
||||
# These are mostly things like the user being logged out.
|
||||
disable_bridge_alerts: false
|
||||
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
|
||||
# This is currently not supported in most clients.
|
||||
caption_in_message: false
|
||||
|
||||
# The prefix for commands. Only required in non-management rooms.
|
||||
command_prefix: "!gm"
|
||||
|
||||
# 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 Google Messages 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.
|
||||
welcome_unconnected: "Use `help` for help or `login` to log in."
|
||||
# Optional extra text sent when joining a management room.
|
||||
additional_help: ""
|
||||
|
||||
# End-to-bridge encryption support options.
|
||||
#
|
||||
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
|
||||
encryption:
|
||||
# Allow encryption, work in group chat rooms with e2ee enabled
|
||||
allow: false
|
||||
# Default to encryption, force-enable encryption in all portals the bridge creates
|
||||
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
||||
default: false
|
||||
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
|
||||
appservice: false
|
||||
# Require encryption, drop any unencrypted messages.
|
||||
require: false
|
||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
||||
# You must use a client that supports requesting keys from other users to use this feature.
|
||||
allow_key_sharing: false
|
||||
# Options for deleting megolm sessions from the bridge.
|
||||
delete_keys:
|
||||
# Beeper-specific: delete outbound sessions when hungryserv confirms
|
||||
# that the user has uploaded the key to key backup.
|
||||
delete_outbound_on_ack: false
|
||||
# Don't store outbound sessions in the inbound table.
|
||||
dont_store_outbound: false
|
||||
# Ratchet megolm sessions forward after decrypting messages.
|
||||
ratchet_on_decrypt: false
|
||||
# Delete fully used keys (index >= max_messages) after decrypting messages.
|
||||
delete_fully_used_on_decrypt: false
|
||||
# Delete previous megolm sessions from same device when receiving a new one.
|
||||
delete_prev_on_new_session: false
|
||||
# Delete megolm sessions received from a device when the device is deleted.
|
||||
delete_on_device_delete: false
|
||||
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
|
||||
periodically_delete_expired: false
|
||||
# Delete inbound megolm sessions that don't have the received_at field used for
|
||||
# automatic ratcheting and expired session deletion. This is meant as a migration
|
||||
# to delete old keys prior to the bridge update.
|
||||
delete_outdated_inbound: false
|
||||
# What level of device verification should be required from users?
|
||||
#
|
||||
# Valid levels:
|
||||
# unverified - Send keys to all device in the room.
|
||||
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
|
||||
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
|
||||
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
|
||||
# Note that creating user signatures from the bridge bot is not currently possible.
|
||||
# verified - Require manual per-device verification
|
||||
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
|
||||
verification_levels:
|
||||
# Minimum level for which the bridge should send keys to when bridging messages from SMS to Matrix.
|
||||
receive: unverified
|
||||
# Minimum level that the bridge should accept for incoming Matrix messages.
|
||||
send: unverified
|
||||
# Minimum level that the bridge should require for accepting key requests.
|
||||
share: cross-signed-tofu
|
||||
# Options for Megolm room key rotation. These options allow you to
|
||||
# configure the m.room.encryption event content. See:
|
||||
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
|
||||
# more information about that event.
|
||||
rotation:
|
||||
# Enable custom Megolm room key rotation settings. Note that these
|
||||
# settings will only apply to rooms created after this option is
|
||||
# set.
|
||||
enable_custom: false
|
||||
# The maximum number of milliseconds a session should be used
|
||||
# before changing it. The Matrix spec recommends 604800000 (a week)
|
||||
# as the default.
|
||||
milliseconds: 604800000
|
||||
# The maximum number of messages that should be sent with a given a
|
||||
# session before changing it. The Matrix spec recommends 100 as the
|
||||
# default.
|
||||
messages: 100
|
||||
|
||||
# Disable rotating keys when a user's devices change?
|
||||
# You should not enable this option unless you understand all the implications.
|
||||
disable_device_change_key_rotation: false
|
||||
|
||||
# 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:
|
||||
# user - Access to use the bridge to link their own Google Messages on android.
|
||||
# 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 config. See https://github.com/tulir/zeroconfig for details.
|
||||
logging:
|
||||
min_level: debug
|
||||
writers:
|
||||
- type: stdout
|
||||
format: pretty-colored
|
||||
- type: file
|
||||
format: json
|
||||
filename: ./logs/mautrix-gmessages.log
|
||||
max_size: 100
|
||||
max_backups: 10
|
||||
compress: true
|
46
go.mod
46
go.mod
|
@ -1,3 +1,49 @@
|
|||
module go.mau.fi/mautrix-gmessages
|
||||
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.17
|
||||
github.com/rs/zerolog v1.29.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
go.mau.fi/mautrix-gmessages/libgm v0.1.0
|
||||
maunium.net/go/maulogger/v2 v2.4.1
|
||||
maunium.net/go/mautrix v0.15.4-0.20230628151140-e99578a15474
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/yuin/goldmark v1.5.4 // indirect
|
||||
go.mau.fi/zeroconfig v0.1.2 // indirect
|
||||
golang.org/x/crypto v0.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
|
||||
golang.org/x/net v0.11.0 // indirect
|
||||
golang.org/x/sys v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/mauflag v1.0.0 // indirect
|
||||
)
|
||||
|
||||
// Exclude some things that cause go.sum to explode
|
||||
exclude (
|
||||
cloud.google.com/go v0.65.0
|
||||
github.com/prometheus/client_golang v1.12.1
|
||||
google.golang.org/appengine v1.6.6
|
||||
)
|
||||
|
||||
replace go.mau.fi/mautrix-gmessages/libgm => ./libgm
|
||||
|
|
83
go.sum
Normal file
83
go.sum
Normal file
|
@ -0,0 +1,83 @@
|
|||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
|
||||
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
|
||||
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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
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.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
|
||||
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
|
||||
go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
|
||||
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
|
||||
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/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.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
|
||||
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
|
||||
maunium.net/go/mautrix v0.15.4-0.20230628151140-e99578a15474 h1:Pzopg6NL7qaNdG2iUf8dVnu9+tVEa82b44KJKmpb/NY=
|
||||
maunium.net/go/mautrix v0.15.4-0.20230628151140-e99578a15474/go.mod h1:zLrQqdxJlLkurRCozTc9CL6FySkgZlO/kpCYxBILSLE=
|
175
main.go
Normal file
175
main.go
Normal file
|
@ -0,0 +1,175 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 (
|
||||
_ "embed"
|
||||
"sync"
|
||||
|
||||
"maunium.net/go/mautrix/bridge"
|
||||
"maunium.net/go/mautrix/bridge/commands"
|
||||
"maunium.net/go/mautrix/bridge/status"
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/util/configupgrade"
|
||||
|
||||
"go.mau.fi/mautrix-gmessages/config"
|
||||
"go.mau.fi/mautrix-gmessages/database"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
//go:embed example-config.yaml
|
||||
var ExampleConfig string
|
||||
|
||||
type GMBridge struct {
|
||||
bridge.Bridge
|
||||
Config *config.Config
|
||||
DB *database.Database
|
||||
//Provisioning *ProvisioningAPI
|
||||
|
||||
usersByMXID map[id.UserID]*User
|
||||
usersByPhone map[string]*User
|
||||
usersLock sync.Mutex
|
||||
spaceRooms map[id.RoomID]*User
|
||||
spaceRoomsLock sync.Mutex
|
||||
managementRooms map[id.RoomID]*User
|
||||
managementRoomsLock sync.Mutex
|
||||
portalsByMXID map[id.RoomID]*Portal
|
||||
portalsByKey map[database.Key]*Portal
|
||||
portalsLock sync.Mutex
|
||||
puppetsByKey map[database.Key]*Puppet
|
||||
puppetsLock sync.Mutex
|
||||
}
|
||||
|
||||
func (br *GMBridge) Init() {
|
||||
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
|
||||
br.RegisterCommands()
|
||||
|
||||
Segment.log = br.ZLog.With().Str("component", "segment").Logger()
|
||||
Segment.key = br.Config.SegmentKey
|
||||
Segment.userID = br.Config.SegmentUserID
|
||||
if Segment.IsEnabled() {
|
||||
Segment.log.Info().Msg("Segment metrics are enabled")
|
||||
if Segment.userID != "" {
|
||||
Segment.log.Info().Str("user_id", Segment.userID).Msg("Overriding Segment user ID")
|
||||
}
|
||||
}
|
||||
|
||||
br.DB = database.New(br.Bridge.DB)
|
||||
|
||||
ss := br.Config.Bridge.Provisioning.SharedSecret
|
||||
if len(ss) > 0 && ss != "disable" {
|
||||
//br.Provisioning = &ProvisioningAPI{bridge: br}
|
||||
}
|
||||
}
|
||||
|
||||
func (br *GMBridge) Start() {
|
||||
//if br.Provisioning != nil {
|
||||
// br.ZLog.Debug().Msg("Initializing provisioning API")
|
||||
// br.Provisioning.Init()
|
||||
//}
|
||||
br.WaitWebsocketConnected()
|
||||
go br.StartUsers()
|
||||
}
|
||||
|
||||
func (br *GMBridge) StartUsers() {
|
||||
br.ZLog.Debug().Msg("Starting users")
|
||||
foundAnySessions := false
|
||||
for _, user := range br.GetAllUsersWithSession() {
|
||||
foundAnySessions = true
|
||||
go user.Connect()
|
||||
}
|
||||
if !foundAnySessions {
|
||||
br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil))
|
||||
}
|
||||
br.ZLog.Debug().Msg("Starting custom puppets")
|
||||
for _, loopuser := range br.GetAllUsersWithDoublePuppet() {
|
||||
go func(user *User) {
|
||||
user.zlog.Debug().Msg("Starting double puppet")
|
||||
err := user.startCustomMXID(true)
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Failed to start double puppet")
|
||||
}
|
||||
}(loopuser)
|
||||
}
|
||||
}
|
||||
|
||||
func (br *GMBridge) Stop() {
|
||||
for _, user := range br.usersByPhone {
|
||||
if user.Client == nil {
|
||||
continue
|
||||
}
|
||||
br.ZLog.Debug().Str("user_id", user.MXID.String()).Msg("Disconnecting user")
|
||||
user.Client.Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetExampleConfig() string {
|
||||
return ExampleConfig
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetConfigPtr() interface{} {
|
||||
br.Config = &config.Config{
|
||||
BaseConfig: &br.Bridge.Config,
|
||||
}
|
||||
br.Config.BaseConfig.Bridge = &br.Config.Bridge
|
||||
return br.Config
|
||||
}
|
||||
|
||||
func main() {
|
||||
br := &GMBridge{
|
||||
usersByMXID: make(map[id.UserID]*User),
|
||||
usersByPhone: make(map[string]*User),
|
||||
spaceRooms: make(map[id.RoomID]*User),
|
||||
managementRooms: make(map[id.RoomID]*User),
|
||||
portalsByMXID: make(map[id.RoomID]*Portal),
|
||||
portalsByKey: make(map[database.Key]*Portal),
|
||||
puppetsByKey: make(map[database.Key]*Puppet),
|
||||
}
|
||||
br.Bridge = bridge.Bridge{
|
||||
Name: "mautrix-gmessages",
|
||||
URL: "https://github.com/mautrix/gmessages",
|
||||
Description: "A Matrix-Google Messages puppeting bridge.",
|
||||
Version: "0.1.0",
|
||||
ProtocolName: "Google Messages",
|
||||
BeeperServiceName: "googlesms",
|
||||
BeeperNetworkName: "googlesms",
|
||||
|
||||
CryptoPickleKey: "go.mau.fi/mautrix-gmessages",
|
||||
|
||||
ConfigUpgrader: &configupgrade.StructUpgrader{
|
||||
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
|
||||
Blocks: config.SpacedBlocks,
|
||||
Base: ExampleConfig,
|
||||
},
|
||||
|
||||
Child: br,
|
||||
}
|
||||
br.InitVersion(Tag, Commit, BuildTime)
|
||||
|
||||
br.Main()
|
||||
}
|
||||
|
||||
func (br *GMBridge) CreatePrivatePortal(roomID id.RoomID, brUser bridge.User, brGhost bridge.Ghost) {
|
||||
//TODO implement?
|
||||
}
|
306
messagetracking.go
Normal file
306
messagetracking.go
Normal file
|
@ -0,0 +1,306 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/bridge/status"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
var (
|
||||
errUserNotConnected = errors.New("you are not connected to WhatsApp")
|
||||
errDifferentUser = errors.New("user is not the recipient of this private chat portal")
|
||||
errUserNotLoggedIn = errors.New("user is not logged in and chat has no relay bot")
|
||||
errMNoticeDisabled = errors.New("bridging m.notice messages is disabled")
|
||||
errUnexpectedParsedContentType = errors.New("unexpected parsed content type")
|
||||
errInvalidGeoURI = errors.New("invalid `geo:` URI in message")
|
||||
errUnknownMsgType = errors.New("unknown msgtype")
|
||||
errMediaUnsupportedType = errors.New("unsupported media type")
|
||||
errTargetNotFound = errors.New("target event not found")
|
||||
errReactionDatabaseNotFound = errors.New("reaction database entry not found")
|
||||
errReactionTargetNotFound = errors.New("reaction target message not found")
|
||||
errTargetIsFake = errors.New("target is a fake event")
|
||||
errReactionSentBySomeoneElse = errors.New("target reaction was sent by someone else")
|
||||
errDMSentByOtherUser = errors.New("target message was sent by the other user in a DM")
|
||||
errPollMissingQuestion = errors.New("poll message is missing question")
|
||||
errPollDuplicateOption = errors.New("poll options must be unique")
|
||||
|
||||
errEditUnknownTarget = errors.New("unknown edit target message")
|
||||
errEditUnknownTargetType = errors.New("unsupported edited message type")
|
||||
errEditDifferentSender = errors.New("can't edit message sent by another user")
|
||||
errEditTooOld = errors.New("message is too old to be edited")
|
||||
|
||||
errBroadcastReactionNotSupported = errors.New("reacting to status messages is not currently supported")
|
||||
errBroadcastSendDisabled = errors.New("sending status messages is disabled")
|
||||
|
||||
errMessageTakingLong = errors.New("bridging the message is taking longer than usual")
|
||||
errTimeoutBeforeHandling = errors.New("message timed out before handling was started")
|
||||
)
|
||||
|
||||
func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) {
|
||||
switch {
|
||||
case errors.Is(err, errUnexpectedParsedContentType),
|
||||
errors.Is(err, errUnknownMsgType),
|
||||
errors.Is(err, errInvalidGeoURI),
|
||||
errors.Is(err, errBroadcastReactionNotSupported),
|
||||
errors.Is(err, errBroadcastSendDisabled):
|
||||
return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, ""
|
||||
case errors.Is(err, errMNoticeDisabled):
|
||||
return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, ""
|
||||
case errors.Is(err, errMediaUnsupportedType),
|
||||
errors.Is(err, errPollMissingQuestion),
|
||||
errors.Is(err, errPollDuplicateOption),
|
||||
errors.Is(err, errEditDifferentSender),
|
||||
errors.Is(err, errEditTooOld),
|
||||
errors.Is(err, errEditUnknownTarget),
|
||||
errors.Is(err, errEditUnknownTargetType):
|
||||
return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, err.Error()
|
||||
case errors.Is(err, errTimeoutBeforeHandling):
|
||||
return event.MessageStatusTooOld, event.MessageStatusRetriable, true, true, "the message was too old when it reached the bridge, so it was not handled"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "handling the message took too long and was cancelled"
|
||||
case errors.Is(err, errMessageTakingLong):
|
||||
return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error()
|
||||
case errors.Is(err, errTargetNotFound),
|
||||
errors.Is(err, errTargetIsFake),
|
||||
errors.Is(err, errReactionDatabaseNotFound),
|
||||
errors.Is(err, errReactionTargetNotFound),
|
||||
errors.Is(err, errReactionSentBySomeoneElse),
|
||||
errors.Is(err, errDMSentByOtherUser):
|
||||
return event.MessageStatusGenericError, event.MessageStatusFail, true, false, ""
|
||||
case errors.Is(err, errUserNotConnected):
|
||||
return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, ""
|
||||
case errors.Is(err, errUserNotLoggedIn),
|
||||
errors.Is(err, errDifferentUser):
|
||||
return event.MessageStatusGenericError, event.MessageStatusRetriable, true, false, ""
|
||||
default:
|
||||
return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, ""
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) sendErrorMessage(evt *event.Event, err error, msgType string, confirmed bool, editID id.EventID) id.EventID {
|
||||
if !portal.bridge.Config.Bridge.MessageErrorNotices {
|
||||
return ""
|
||||
}
|
||||
certainty := "may not have been"
|
||||
if confirmed {
|
||||
certainty = "was not"
|
||||
}
|
||||
msg := fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, err)
|
||||
if errors.Is(err, errMessageTakingLong) {
|
||||
msg = fmt.Sprintf("\u26a0 Bridging your %s is taking longer than usual", msgType)
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgNotice,
|
||||
Body: msg,
|
||||
}
|
||||
if editID != "" {
|
||||
content.SetEdit(editID)
|
||||
} else {
|
||||
content.SetReply(evt)
|
||||
}
|
||||
resp, err := portal.sendMainIntentMessage(content)
|
||||
if err != nil {
|
||||
portal.log.Warnfln("Failed to send bridging error message:", err)
|
||||
return ""
|
||||
}
|
||||
return resp.EventID
|
||||
}
|
||||
|
||||
func (portal *Portal) sendStatusEvent(evtID, lastRetry id.EventID, err error) {
|
||||
if !portal.bridge.Config.Bridge.MessageStatusEvents {
|
||||
return
|
||||
}
|
||||
if lastRetry == evtID {
|
||||
lastRetry = ""
|
||||
}
|
||||
intent := portal.bridge.Bot
|
||||
if !portal.Encrypted {
|
||||
// Bridge bot isn't present in unencrypted DMs
|
||||
intent = portal.MainIntent()
|
||||
}
|
||||
content := event.BeeperMessageStatusEventContent{
|
||||
Network: portal.getBridgeInfoStateKey(),
|
||||
RelatesTo: event.RelatesTo{
|
||||
Type: event.RelReference,
|
||||
EventID: evtID,
|
||||
},
|
||||
LastRetry: lastRetry,
|
||||
}
|
||||
if err == nil {
|
||||
content.Status = event.MessageStatusSuccess
|
||||
} else {
|
||||
content.Reason, content.Status, _, _, content.Message = errorToStatusReason(err)
|
||||
content.Error = err.Error()
|
||||
}
|
||||
_, err = intent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, &content)
|
||||
if err != nil {
|
||||
portal.log.Warnln("Failed to send message status event:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) {
|
||||
if portal.bridge.Config.Bridge.DeliveryReceipts {
|
||||
err := portal.bridge.Bot.SendReceipt(portal.MXID, eventID, event.ReceiptTypeRead, nil)
|
||||
if err != nil {
|
||||
portal.log.Debugfln("Failed to send delivery receipt for %s: %v", eventID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part string, ms *metricSender) {
|
||||
var msgType string
|
||||
switch evt.Type {
|
||||
case event.EventMessage:
|
||||
msgType = "message"
|
||||
case event.EventReaction:
|
||||
msgType = "reaction"
|
||||
case event.EventRedaction:
|
||||
msgType = "redaction"
|
||||
default:
|
||||
msgType = "unknown event"
|
||||
}
|
||||
evtDescription := evt.ID.String()
|
||||
if evt.Type == event.EventRedaction {
|
||||
evtDescription += fmt.Sprintf(" of %s", evt.Redacts)
|
||||
}
|
||||
origEvtID := evt.ID
|
||||
if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil {
|
||||
origEvtID = retryMeta.OriginalEventID
|
||||
}
|
||||
if err != nil {
|
||||
level := log.LevelError
|
||||
if part == "Ignoring" {
|
||||
level = log.LevelDebug
|
||||
}
|
||||
portal.log.Logfln(level, "%s %s %s from %s: %v", part, msgType, evtDescription, evt.Sender, err)
|
||||
reason, statusCode, isCertain, sendNotice, _ := errorToStatusReason(err)
|
||||
checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode)
|
||||
portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, err, checkpointStatus, ms.getRetryNum())
|
||||
if sendNotice {
|
||||
ms.setNoticeID(portal.sendErrorMessage(evt, err, msgType, isCertain, ms.getNoticeID()))
|
||||
}
|
||||
portal.sendStatusEvent(origEvtID, evt.ID, err)
|
||||
} else {
|
||||
portal.log.Debugfln("Handled Matrix %s %s", msgType, evtDescription)
|
||||
portal.sendDeliveryReceipt(evt.ID)
|
||||
portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum())
|
||||
portal.sendStatusEvent(origEvtID, evt.ID, nil)
|
||||
if prevNotice := ms.popNoticeID(); prevNotice != "" {
|
||||
_, _ = portal.MainIntent().RedactEvent(portal.MXID, prevNotice, mautrix.ReqRedact{
|
||||
Reason: "error resolved",
|
||||
})
|
||||
}
|
||||
}
|
||||
if ms != nil {
|
||||
portal.log.Debugfln("Timings for %s: %s", evt.ID, ms.timings.String())
|
||||
}
|
||||
}
|
||||
|
||||
type messageTimings struct {
|
||||
initReceive time.Duration
|
||||
decrypt time.Duration
|
||||
implicitRR time.Duration
|
||||
portalQueue time.Duration
|
||||
totalReceive time.Duration
|
||||
|
||||
preproc time.Duration
|
||||
convert time.Duration
|
||||
totalSend time.Duration
|
||||
}
|
||||
|
||||
func niceRound(dur time.Duration) time.Duration {
|
||||
switch {
|
||||
case dur < time.Millisecond:
|
||||
return dur
|
||||
case dur < time.Second:
|
||||
return dur.Round(100 * time.Microsecond)
|
||||
default:
|
||||
return dur.Round(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (mt *messageTimings) String() string {
|
||||
mt.initReceive = niceRound(mt.initReceive)
|
||||
mt.decrypt = niceRound(mt.decrypt)
|
||||
mt.portalQueue = niceRound(mt.portalQueue)
|
||||
mt.totalReceive = niceRound(mt.totalReceive)
|
||||
mt.implicitRR = niceRound(mt.implicitRR)
|
||||
mt.preproc = niceRound(mt.preproc)
|
||||
mt.convert = niceRound(mt.convert)
|
||||
mt.totalSend = niceRound(mt.totalSend)
|
||||
return fmt.Sprintf("BRIDGE: receive: %s, decrypt: %s, queue: %s, total hs->portal: %s, implicit rr: %s -- PORTAL: preprocess: %s, convert: %s, total send: %s", mt.initReceive, mt.decrypt, mt.implicitRR, mt.portalQueue, mt.totalReceive, mt.preproc, mt.convert, mt.totalSend)
|
||||
}
|
||||
|
||||
type metricSender struct {
|
||||
portal *Portal
|
||||
previousNotice id.EventID
|
||||
lock sync.Mutex
|
||||
completed bool
|
||||
retryNum int
|
||||
timings *messageTimings
|
||||
}
|
||||
|
||||
func (ms *metricSender) getRetryNum() int {
|
||||
if ms != nil {
|
||||
return ms.retryNum
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (ms *metricSender) getNoticeID() id.EventID {
|
||||
if ms == nil {
|
||||
return ""
|
||||
}
|
||||
return ms.previousNotice
|
||||
}
|
||||
|
||||
func (ms *metricSender) popNoticeID() id.EventID {
|
||||
if ms == nil {
|
||||
return ""
|
||||
}
|
||||
evtID := ms.previousNotice
|
||||
ms.previousNotice = ""
|
||||
return evtID
|
||||
}
|
||||
|
||||
func (ms *metricSender) setNoticeID(evtID id.EventID) {
|
||||
if ms != nil && ms.previousNotice == "" {
|
||||
ms.previousNotice = evtID
|
||||
}
|
||||
}
|
||||
|
||||
func (ms *metricSender) sendMessageMetrics(evt *event.Event, err error, part string, completed bool) {
|
||||
ms.lock.Lock()
|
||||
defer ms.lock.Unlock()
|
||||
if !completed && ms.completed {
|
||||
return
|
||||
}
|
||||
ms.portal.sendMessageMetrics(evt, err, part, ms)
|
||||
ms.retryNum++
|
||||
ms.completed = completed
|
||||
}
|
995
portal.go
Normal file
995
portal.go
Normal file
|
@ -0,0 +1,995 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/bridge"
|
||||
"maunium.net/go/mautrix/crypto/attachment"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-gmessages/database"
|
||||
"go.mau.fi/mautrix-gmessages/libgm/binary"
|
||||
"go.mau.fi/mautrix-gmessages/libgm/util"
|
||||
)
|
||||
|
||||
func (br *GMBridge) GetPortalByMXID(mxid id.RoomID) *Portal {
|
||||
br.portalsLock.Lock()
|
||||
defer br.portalsLock.Unlock()
|
||||
portal, ok := br.portalsByMXID[mxid]
|
||||
if !ok {
|
||||
dbPortal, err := br.DB.Portal.GetByMXID(context.TODO(), mxid)
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Str("mxid", mxid.String()).Msg("Failed to get portal from database")
|
||||
return nil
|
||||
}
|
||||
return br.loadDBPortal(dbPortal, 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
|
||||
err := portal.Update(context.TODO())
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).Msg("Failed to save portal to database after marking it as encrypted")
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) ReceiveMatrixEvent(brUser bridge.User, evt *event.Event) {
|
||||
user := brUser.(*User)
|
||||
if user.RowID == portal.Receiver {
|
||||
portal.matrixMessages <- PortalMatrixMessage{user: user, evt: evt, receivedAt: time.Now()}
|
||||
}
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetPortalByKey(key database.Key) *Portal {
|
||||
br.portalsLock.Lock()
|
||||
defer br.portalsLock.Unlock()
|
||||
portal, ok := br.portalsByKey[key]
|
||||
if !ok {
|
||||
dbPortal, err := br.DB.Portal.GetByKey(context.TODO(), key)
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Object("portal_key", key).Msg("Failed to get portal from database")
|
||||
return nil
|
||||
}
|
||||
return br.loadDBPortal(dbPortal, &key)
|
||||
}
|
||||
return portal
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetExistingPortalByKey(key database.Key) *Portal {
|
||||
br.portalsLock.Lock()
|
||||
defer br.portalsLock.Unlock()
|
||||
portal, ok := br.portalsByKey[key]
|
||||
if !ok {
|
||||
dbPortal, err := br.DB.Portal.GetByKey(context.TODO(), key)
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Object("portal_key", key).Msg("Failed to get portal from database")
|
||||
return nil
|
||||
}
|
||||
return br.loadDBPortal(dbPortal, nil)
|
||||
}
|
||||
return portal
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetAllPortals() []*Portal {
|
||||
return br.loadManyPortals(br.DB.Portal.GetAll)
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetAllPortalsForUser(userID int) []*Portal {
|
||||
return br.loadManyPortals(func(ctx context.Context) ([]*database.Portal, error) {
|
||||
return br.DB.Portal.GetAllForUser(ctx, userID)
|
||||
})
|
||||
}
|
||||
|
||||
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) loadManyPortals(query func(ctx context.Context) ([]*database.Portal, error)) []*Portal {
|
||||
br.portalsLock.Lock()
|
||||
defer br.portalsLock.Unlock()
|
||||
dbPortals, err := query(context.TODO())
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Msg("Failed to load all portals from database")
|
||||
return []*Portal{}
|
||||
}
|
||||
output := make([]*Portal, len(dbPortals))
|
||||
for index, dbPortal := range dbPortals {
|
||||
if dbPortal == nil {
|
||||
continue
|
||||
}
|
||||
portal, ok := br.portalsByKey[dbPortal.Key]
|
||||
if !ok {
|
||||
portal = br.loadDBPortal(dbPortal, nil)
|
||||
}
|
||||
output[index] = portal
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func (br *GMBridge) loadDBPortal(dbPortal *database.Portal, key *database.Key) *Portal {
|
||||
if dbPortal == nil {
|
||||
if key == nil {
|
||||
return nil
|
||||
}
|
||||
dbPortal = br.DB.Portal.New()
|
||||
dbPortal.Key = *key
|
||||
err := dbPortal.Insert(context.TODO())
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Object("portal_key", key).Msg("Failed to insert portal")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
portal := br.NewPortal(dbPortal)
|
||||
br.portalsByKey[portal.Key] = portal
|
||||
if len(portal.MXID) > 0 {
|
||||
br.portalsByMXID[portal.MXID] = portal
|
||||
}
|
||||
return portal
|
||||
}
|
||||
|
||||
func (portal *Portal) GetUsers() []*User {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (br *GMBridge) newBlankPortal(key database.Key) *Portal {
|
||||
portal := &Portal{
|
||||
bridge: br,
|
||||
log: br.Log.Sub(fmt.Sprintf("Portal/%s", key.ID)),
|
||||
zlog: br.ZLog.With().Str("portal_id", key.ID).Int("portal_receiver", key.Receiver).Logger(),
|
||||
|
||||
messages: make(chan PortalMessage, br.Config.Bridge.PortalMessageBuffer),
|
||||
matrixMessages: make(chan PortalMatrixMessage, br.Config.Bridge.PortalMessageBuffer),
|
||||
|
||||
outgoingMessages: make(map[string]id.EventID),
|
||||
}
|
||||
go portal.handleMessageLoop()
|
||||
return portal
|
||||
}
|
||||
|
||||
func (br *GMBridge) NewPortal(dbPortal *database.Portal) *Portal {
|
||||
portal := br.newBlankPortal(dbPortal.Key)
|
||||
portal.Portal = dbPortal
|
||||
return portal
|
||||
}
|
||||
|
||||
const recentlyHandledLength = 100
|
||||
|
||||
type PortalMessage struct {
|
||||
evt *binary.Message
|
||||
source *User
|
||||
}
|
||||
|
||||
type PortalMatrixMessage struct {
|
||||
evt *event.Event
|
||||
user *User
|
||||
receivedAt time.Time
|
||||
}
|
||||
|
||||
type Portal struct {
|
||||
*database.Portal
|
||||
|
||||
bridge *GMBridge
|
||||
// Deprecated: use zerolog
|
||||
log log.Logger
|
||||
zlog zerolog.Logger
|
||||
|
||||
roomCreateLock sync.Mutex
|
||||
encryptLock sync.Mutex
|
||||
backfillLock sync.Mutex
|
||||
avatarLock sync.Mutex
|
||||
|
||||
latestEventBackfillLock sync.Mutex
|
||||
|
||||
recentlyHandled [recentlyHandledLength]string
|
||||
recentlyHandledLock sync.Mutex
|
||||
recentlyHandledIndex uint8
|
||||
|
||||
outgoingMessages map[string]id.EventID
|
||||
outgoingMessagesLock sync.Mutex
|
||||
|
||||
currentlyTyping []id.UserID
|
||||
currentlyTypingLock sync.Mutex
|
||||
|
||||
messages chan PortalMessage
|
||||
matrixMessages chan PortalMatrixMessage
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridge.Portal = (*Portal)(nil)
|
||||
//_ bridge.ReadReceiptHandlingPortal = (*Portal)(nil)
|
||||
//_ bridge.MembershipHandlingPortal = (*Portal)(nil)
|
||||
//_ bridge.MetaHandlingPortal = (*Portal)(nil)
|
||||
//_ bridge.TypingPortal = (*Portal)(nil)
|
||||
)
|
||||
|
||||
func (portal *Portal) handleMessageLoopItem(msg PortalMessage) {
|
||||
if len(portal.MXID) == 0 {
|
||||
return
|
||||
}
|
||||
portal.latestEventBackfillLock.Lock()
|
||||
defer portal.latestEventBackfillLock.Unlock()
|
||||
switch {
|
||||
case msg.evt != nil:
|
||||
portal.handleMessage(msg.source, msg.evt)
|
||||
//case msg.receipt != nil:
|
||||
// portal.handleReceipt(msg.receipt, msg.source)
|
||||
default:
|
||||
portal.zlog.Warn().Interface("portal_message", msg).Msg("Unexpected PortalMessage with no message")
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) handleMatrixMessageLoopItem(msg PortalMatrixMessage) {
|
||||
portal.latestEventBackfillLock.Lock()
|
||||
defer portal.latestEventBackfillLock.Unlock()
|
||||
evtTS := time.UnixMilli(msg.evt.Timestamp)
|
||||
timings := messageTimings{
|
||||
initReceive: msg.evt.Mautrix.ReceivedAt.Sub(evtTS),
|
||||
decrypt: msg.evt.Mautrix.DecryptionDuration,
|
||||
portalQueue: time.Since(msg.receivedAt),
|
||||
totalReceive: time.Since(evtTS),
|
||||
}
|
||||
switch msg.evt.Type {
|
||||
case event.EventMessage, event.EventSticker:
|
||||
portal.HandleMatrixMessage(msg.user, msg.evt, timings)
|
||||
case event.EventReaction:
|
||||
portal.HandleMatrixReaction(msg.user, msg.evt)
|
||||
default:
|
||||
portal.zlog.Warn().
|
||||
Str("event_type", msg.evt.Type.Type).
|
||||
Msg("Unsupported event type in portal message channel")
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) handleMessageLoop() {
|
||||
for {
|
||||
select {
|
||||
case msg := <-portal.messages:
|
||||
portal.handleMessageLoopItem(msg)
|
||||
case msg := <-portal.matrixMessages:
|
||||
portal.handleMatrixMessageLoopItem(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) isOutgoingMessage(evt *binary.Message) id.EventID {
|
||||
portal.outgoingMessagesLock.Lock()
|
||||
defer portal.outgoingMessagesLock.Unlock()
|
||||
evtID, ok := portal.outgoingMessages[evt.TmpId]
|
||||
if ok {
|
||||
portal.markHandled(evt, map[string]id.EventID{"": evtID}, true)
|
||||
return evtID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (portal *Portal) handleMessage(source *User, evt *binary.Message) {
|
||||
if len(portal.MXID) == 0 {
|
||||
portal.zlog.Warn().Msg("handleMessage called even though portal.MXID is empty")
|
||||
return
|
||||
}
|
||||
log := portal.zlog.With().
|
||||
Str("message_id", evt.MessageID).
|
||||
Str("participant_id", evt.ParticipantID).
|
||||
Str("action", "handleMessage").
|
||||
Logger()
|
||||
if evtID := portal.isOutgoingMessage(evt); evtID != "" {
|
||||
log.Debug().Str("event_id", evtID.String()).Msg("Got echo for outgoing message")
|
||||
return
|
||||
} else if portal.isRecentlyHandled(evt.MessageID) {
|
||||
log.Debug().Msg("Not handling recent duplicate message")
|
||||
return
|
||||
}
|
||||
existingMsg, err := portal.bridge.DB.Message.GetByID(context.TODO(), portal.Key, evt.MessageID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to check if message is duplicate")
|
||||
} else if existingMsg != nil {
|
||||
log.Debug().Msg("Not handling duplicate message")
|
||||
return
|
||||
}
|
||||
|
||||
var intent *appservice.IntentAPI
|
||||
if evt.GetFrom().GetFromMe() {
|
||||
intent = source.DoublePuppetIntent
|
||||
if intent == nil {
|
||||
log.Debug().Msg("Dropping message from self as double puppeting is not enabled")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
puppet := source.GetPuppetByID(evt.ParticipantID, "")
|
||||
if puppet == nil {
|
||||
log.Debug().Msg("Dropping message from unknown participant")
|
||||
return
|
||||
}
|
||||
intent = puppet.IntentFor(portal)
|
||||
}
|
||||
|
||||
eventIDs := make(map[string]id.EventID)
|
||||
var lastEventID id.EventID
|
||||
ts := time.UnixMicro(evt.Timestamp).UnixMilli()
|
||||
for _, part := range evt.MessageInfo {
|
||||
var content event.MessageEventContent
|
||||
switch data := part.GetData().(type) {
|
||||
case *binary.MessageInfo_MessageContent:
|
||||
content = event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: data.MessageContent.GetContent(),
|
||||
}
|
||||
case *binary.MessageInfo_ImageContent:
|
||||
content = event.MessageEventContent{
|
||||
MsgType: event.MsgNotice,
|
||||
Body: fmt.Sprintf("Attachment %s", data.ImageContent.GetImageName()),
|
||||
}
|
||||
}
|
||||
resp, err := portal.sendMessage(intent, event.EventMessage, &content, nil, ts)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to send message")
|
||||
} else {
|
||||
eventIDs[part.OrderInternal] = resp.EventID
|
||||
lastEventID = resp.EventID
|
||||
}
|
||||
}
|
||||
portal.markHandled(evt, eventIDs, true)
|
||||
portal.sendDeliveryReceipt(lastEventID)
|
||||
log.Debug().Interface("event_ids", eventIDs).Msg("Handled message")
|
||||
}
|
||||
|
||||
func (portal *Portal) isRecentlyHandled(id string) bool {
|
||||
start := portal.recentlyHandledIndex
|
||||
for i := start; i != start; i = (i - 1) % recentlyHandledLength {
|
||||
if portal.recentlyHandled[i] == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (portal *Portal) markHandled(info *binary.Message, mxids map[string]id.EventID, recent bool) *database.Message {
|
||||
msg := portal.bridge.DB.Message.New()
|
||||
msg.Chat = portal.Key
|
||||
msg.ID = info.MessageID
|
||||
for _, evtID := range mxids {
|
||||
msg.MXID = evtID
|
||||
}
|
||||
msg.Timestamp = time.UnixMicro(info.Timestamp)
|
||||
msg.Sender = info.ParticipantID
|
||||
err := msg.Insert(context.TODO())
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).Str("message_id", info.MessageID).Msg("Failed to insert message to database")
|
||||
}
|
||||
|
||||
if recent {
|
||||
portal.recentlyHandledLock.Lock()
|
||||
index := portal.recentlyHandledIndex
|
||||
portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength
|
||||
portal.recentlyHandledLock.Unlock()
|
||||
portal.recentlyHandled[index] = info.MessageID
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func (portal *Portal) SyncParticipants(source *User, metadata *binary.Conversation) (userIDs []id.UserID) {
|
||||
var firstParticipant *binary.Participant
|
||||
var manyParticipants bool
|
||||
for _, participant := range metadata.Participants {
|
||||
if participant.IsMe {
|
||||
continue
|
||||
} else if participant.Id.Number == "" {
|
||||
portal.zlog.Warn().Interface("participant", participant).Msg("No number found in non-self participant entry")
|
||||
continue
|
||||
}
|
||||
if firstParticipant == nil {
|
||||
firstParticipant = participant
|
||||
} else {
|
||||
manyParticipants = true
|
||||
}
|
||||
portal.zlog.Debug().Interface("participant", participant).Msg("Syncing participant")
|
||||
puppet := source.GetPuppetByID(participant.Id.ParticipantID, participant.Id.Number)
|
||||
userIDs = append(userIDs, puppet.MXID)
|
||||
puppet.Sync(source, participant)
|
||||
if portal.MXID != "" {
|
||||
err := puppet.IntentFor(portal).EnsureJoined(portal.MXID)
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).
|
||||
Str("user_id", puppet.MXID.String()).
|
||||
Msg("Failed to ensure ghost is joined to portal")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !metadata.IsGroupChat && !manyParticipants {
|
||||
portal.zlog.Info().
|
||||
Str("old_other_user_id", portal.OtherUserID).
|
||||
Str("new_other_user_id", firstParticipant.Id.ParticipantID).
|
||||
Msg("Found other user ID in DM")
|
||||
portal.OtherUserID = firstParticipant.Id.ParticipantID
|
||||
}
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func (portal *Portal) UpdateName(name string, updateInfo bool) bool {
|
||||
if portal.Name != name || (!portal.NameSet && len(portal.MXID) > 0 && portal.shouldSetDMRoomMetadata()) {
|
||||
portal.zlog.Debug().Str("old_name", portal.Name).Str("new_name", name).Msg("Updating name")
|
||||
portal.Name = name
|
||||
portal.NameSet = false
|
||||
if updateInfo {
|
||||
defer func() {
|
||||
err := portal.Update(context.TODO())
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).Msg("Failed to save portal after updating name")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if len(portal.MXID) > 0 && !portal.shouldSetDMRoomMetadata() {
|
||||
portal.UpdateBridgeInfo()
|
||||
} else if len(portal.MXID) > 0 {
|
||||
intent := portal.MainIntent()
|
||||
_, err := intent.SetRoomName(portal.MXID, name)
|
||||
if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() {
|
||||
_, err = portal.MainIntent().SetRoomName(portal.MXID, name)
|
||||
}
|
||||
if err == nil {
|
||||
portal.NameSet = true
|
||||
if updateInfo {
|
||||
portal.UpdateBridgeInfo()
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
portal.zlog.Warn().Err(err).Msg("Failed to set room name")
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (portal *Portal) UpdateMetadata(user *User, info *binary.Conversation) []id.UserID {
|
||||
participants := portal.SyncParticipants(user, info)
|
||||
if portal.IsPrivateChat() {
|
||||
return participants
|
||||
}
|
||||
update := false
|
||||
if portal.MXID != "" {
|
||||
update = portal.addToPersonalSpace(user) || update
|
||||
}
|
||||
update = portal.UpdateName(info.Name, false) || update
|
||||
// TODO avatar
|
||||
if update {
|
||||
err := portal.Update(context.TODO())
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).Msg("Failed to save portal after updating metadata")
|
||||
}
|
||||
if portal.MXID != "" {
|
||||
portal.UpdateBridgeInfo()
|
||||
}
|
||||
}
|
||||
return participants
|
||||
}
|
||||
|
||||
func (portal *Portal) ensureUserInvited(user *User) bool {
|
||||
return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat())
|
||||
}
|
||||
|
||||
func (portal *Portal) UpdateMatrixRoom(user *User, groupInfo *binary.Conversation) bool {
|
||||
if len(portal.MXID) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
portal.ensureUserInvited(user)
|
||||
portal.UpdateMetadata(user, groupInfo)
|
||||
return true
|
||||
}
|
||||
|
||||
func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent {
|
||||
anyone := 0
|
||||
nope := 99
|
||||
invite := 50
|
||||
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.EventReaction.Type: anyone, // TODO only allow reactions in RCS rooms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) getBridgeInfoStateKey() string {
|
||||
return fmt.Sprintf("fi.mau.gmessages://gmessages/%s", portal.ID)
|
||||
}
|
||||
|
||||
func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) {
|
||||
return portal.getBridgeInfoStateKey(), event.BridgeEventContent{
|
||||
BridgeBot: portal.bridge.Bot.UserID,
|
||||
Creator: portal.MainIntent().UserID,
|
||||
Protocol: event.BridgeInfoSection{
|
||||
ID: "gmessages",
|
||||
DisplayName: "Google Messages",
|
||||
AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
|
||||
ExternalURL: "https://messages.google.com/",
|
||||
},
|
||||
Channel: event.BridgeInfoSection{
|
||||
ID: portal.ID,
|
||||
DisplayName: portal.Name,
|
||||
AvatarURL: portal.AvatarMXC.CUString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) UpdateBridgeInfo() {
|
||||
if len(portal.MXID) == 0 {
|
||||
portal.zlog.Debug().Msg("Not updating bridge info: no Matrix room created")
|
||||
return
|
||||
}
|
||||
portal.zlog.Debug().Msg("Updating bridge info...")
|
||||
stateKey, content := portal.getBridgeInfo()
|
||||
_, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateBridge, stateKey, content)
|
||||
if err != nil {
|
||||
portal.zlog.Warn().Err(err).Msg("Failed to update m.bridge")
|
||||
}
|
||||
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
||||
_, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateHalfShotBridge, stateKey, content)
|
||||
if err != nil {
|
||||
portal.zlog.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge")
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) shouldSetDMRoomMetadata() bool {
|
||||
return !portal.IsPrivateChat() ||
|
||||
portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" ||
|
||||
(portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never")
|
||||
}
|
||||
|
||||
func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) {
|
||||
evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}
|
||||
if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom {
|
||||
evt.RotationPeriodMillis = rot.Milliseconds
|
||||
evt.RotationPeriodMessages = rot.Messages
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (portal *Portal) CreateMatrixRoom(user *User, conv *binary.Conversation) error {
|
||||
portal.roomCreateLock.Lock()
|
||||
defer portal.roomCreateLock.Unlock()
|
||||
if len(portal.MXID) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
members := portal.UpdateMetadata(user, conv)
|
||||
|
||||
if portal.IsPrivateChat() && portal.GetDMPuppet() == nil {
|
||||
portal.zlog.Error().Msg("Didn't find ghost of other user in DM :(")
|
||||
return fmt.Errorf("ghost not found")
|
||||
}
|
||||
|
||||
intent := portal.MainIntent()
|
||||
if err := intent.EnsureRegistered(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
portal.zlog.Info().Msg("Creating Matrix room")
|
||||
|
||||
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,
|
||||
}}
|
||||
var invite []id.UserID
|
||||
if portal.bridge.Config.Bridge.Encryption.Default {
|
||||
initialState = append(initialState, &event.Event{
|
||||
Type: event.StateEncryption,
|
||||
Content: event.Content{
|
||||
Parsed: portal.GetEncryptionEventContent(),
|
||||
},
|
||||
})
|
||||
portal.Encrypted = true
|
||||
if portal.IsPrivateChat() {
|
||||
invite = append(invite, portal.bridge.Bot.UserID)
|
||||
}
|
||||
}
|
||||
if !portal.AvatarMXC.IsEmpty() && portal.shouldSetDMRoomMetadata() {
|
||||
initialState = append(initialState, &event.Event{
|
||||
Type: event.StateRoomAvatar,
|
||||
Content: event.Content{
|
||||
Parsed: event.RoomAvatarEventContent{URL: portal.AvatarMXC},
|
||||
},
|
||||
})
|
||||
portal.AvatarSet = true
|
||||
} else {
|
||||
portal.AvatarSet = false
|
||||
}
|
||||
|
||||
creationContent := make(map[string]interface{})
|
||||
if !portal.bridge.Config.Bridge.FederateRooms {
|
||||
creationContent["m.federate"] = false
|
||||
}
|
||||
autoJoinInvites := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites)
|
||||
if autoJoinInvites {
|
||||
portal.zlog.Debug().Msg("Hungryserv mode: adding all group members in create request")
|
||||
invite = append(invite, members...)
|
||||
invite = append(invite, user.MXID)
|
||||
}
|
||||
req := &mautrix.ReqCreateRoom{
|
||||
Visibility: "private",
|
||||
Name: portal.Name,
|
||||
Invite: invite,
|
||||
Preset: "private_chat",
|
||||
IsDirect: portal.IsPrivateChat(),
|
||||
InitialState: initialState,
|
||||
CreationContent: creationContent,
|
||||
|
||||
BeeperAutoJoinInvites: autoJoinInvites,
|
||||
}
|
||||
if !portal.shouldSetDMRoomMetadata() {
|
||||
req.Name = ""
|
||||
}
|
||||
resp, err := intent.CreateRoom(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
portal.zlog.Info().Str("room_id", resp.RoomID.String()).Msg("Matrix room created")
|
||||
portal.InSpace = false
|
||||
portal.NameSet = len(req.Name) > 0
|
||||
portal.MXID = resp.RoomID
|
||||
portal.bridge.portalsLock.Lock()
|
||||
portal.bridge.portalsByMXID[portal.MXID] = portal
|
||||
portal.bridge.portalsLock.Unlock()
|
||||
err = portal.Update(context.TODO())
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).Msg("Failed to save portal after creating room")
|
||||
}
|
||||
|
||||
// We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here.
|
||||
inviteMembership := event.MembershipInvite
|
||||
if autoJoinInvites {
|
||||
inviteMembership = event.MembershipJoin
|
||||
}
|
||||
for _, userID := range invite {
|
||||
portal.bridge.StateStore.SetMembership(portal.MXID, userID, inviteMembership)
|
||||
}
|
||||
|
||||
if !autoJoinInvites {
|
||||
if !portal.IsPrivateChat() {
|
||||
portal.SyncParticipants(user, conv)
|
||||
} else {
|
||||
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{portal.GetDMPuppet().MXID: {portal.MXID}})
|
||||
}
|
||||
portal.ensureUserInvited(user)
|
||||
}
|
||||
user.syncChatDoublePuppetDetails(portal, conv, true)
|
||||
|
||||
go portal.addToPersonalSpace(user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (portal *Portal) addToPersonalSpace(user *User) bool {
|
||||
spaceID := user.GetSpaceRoom()
|
||||
if len(spaceID) == 0 || portal.InSpace {
|
||||
return false
|
||||
}
|
||||
_, err := portal.bridge.Bot.SendStateEvent(spaceID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{
|
||||
Via: []string{portal.bridge.Config.Homeserver.Domain},
|
||||
})
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).Str("space_id", spaceID.String()).Msg("Failed to add room to user's personal filtering space")
|
||||
portal.InSpace = false
|
||||
} else {
|
||||
portal.zlog.Debug().Str("space_id", spaceID.String()).Msg("Added room to user's personal filtering space")
|
||||
portal.InSpace = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (portal *Portal) IsPrivateChat() bool {
|
||||
return portal.OtherUserID != ""
|
||||
}
|
||||
|
||||
func (portal *Portal) GetDMPuppet() *Puppet {
|
||||
if portal.IsPrivateChat() {
|
||||
puppet := portal.bridge.GetPuppetByKey(database.Key{Receiver: portal.Receiver, ID: portal.OtherUserID}, "")
|
||||
return puppet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (portal *Portal) MainIntent() *appservice.IntentAPI {
|
||||
if puppet := portal.GetDMPuppet(); puppet != nil {
|
||||
return puppet.DefaultIntent()
|
||||
}
|
||||
return portal.bridge.Bot
|
||||
}
|
||||
|
||||
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]interface{}, 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 timestamp == 0 {
|
||||
return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent)
|
||||
} else {
|
||||
return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) encryptFileInPlace(data []byte, mimeType string) (string, *event.EncryptedFileInfo) {
|
||||
if !portal.Encrypted {
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
file := &event.EncryptedFileInfo{
|
||||
EncryptedFile: *attachment.NewEncryptedFile(),
|
||||
URL: "",
|
||||
}
|
||||
file.EncryptInPlace(data)
|
||||
return "application/octet-stream", file
|
||||
}
|
||||
|
||||
func (portal *Portal) uploadMedia(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
|
||||
uploadMimeType, file := portal.encryptFileInPlace(data, content.Info.MimeType)
|
||||
|
||||
req := mautrix.ReqUploadMedia{
|
||||
ContentBytes: data,
|
||||
ContentType: uploadMimeType,
|
||||
}
|
||||
var mxc id.ContentURI
|
||||
if portal.bridge.Config.Homeserver.AsyncMedia {
|
||||
uploaded, err := intent.UploadAsync(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mxc = uploaded.ContentURI
|
||||
} else {
|
||||
uploaded, err := intent.UploadMedia(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mxc = uploaded.ContentURI
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
file.URL = mxc.CUString()
|
||||
content.File = file
|
||||
} else {
|
||||
content.URL = mxc.CUString()
|
||||
}
|
||||
|
||||
content.Info.Size = len(data)
|
||||
if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") {
|
||||
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
|
||||
content.Info.Width, content.Info.Height = cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
// This is a hack for bad clients like Element iOS that require a thumbnail (https://github.com/vector-im/element-ios/issues/4004)
|
||||
if strings.HasPrefix(content.Info.MimeType, "image/") && content.Info.ThumbnailInfo == nil {
|
||||
infoCopy := *content.Info
|
||||
content.Info.ThumbnailInfo = &infoCopy
|
||||
if content.File != nil {
|
||||
content.Info.ThumbnailFile = file
|
||||
} else {
|
||||
content.Info.ThumbnailURL = content.URL
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event, timings messageTimings) {
|
||||
ms := metricSender{portal: portal, timings: &timings}
|
||||
|
||||
log := portal.zlog.With().Str("event_id", evt.ID.String()).Logger()
|
||||
log.Debug().Dur("age", timings.totalReceive).Msg("Handling Matrix message")
|
||||
|
||||
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
txnID := util.GenerateTmpId()
|
||||
portal.outgoingMessagesLock.Lock()
|
||||
portal.outgoingMessages[txnID] = evt.ID
|
||||
portal.outgoingMessagesLock.Unlock()
|
||||
switch content.MsgType {
|
||||
case event.MsgText, event.MsgEmote, event.MsgNotice:
|
||||
text := content.Body
|
||||
if content.MsgType == event.MsgEmote {
|
||||
text = "/me " + text
|
||||
}
|
||||
_, err := sender.Client.Conversations.SendMessage(
|
||||
sender.Client.NewMessageBuilder().
|
||||
SetConversationID(portal.ID).
|
||||
SetSelfParticipantID(portal.SelfUserID).
|
||||
SetContent(text).
|
||||
SetTmpID(txnID), "",
|
||||
)
|
||||
if err != nil {
|
||||
go ms.sendMessageMetrics(evt, err, "Error sending", true)
|
||||
} else {
|
||||
go ms.sendMessageMetrics(evt, nil, "", true)
|
||||
}
|
||||
default:
|
||||
go ms.sendMessageMetrics(evt, fmt.Errorf("unsupported msgtype"), "Ignoring", true)
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) HandleMatrixReaction(sender *User, evt *event.Event) {
|
||||
|
||||
}
|
||||
|
||||
func (portal *Portal) Delete() {
|
||||
err := portal.Portal.Delete(context.TODO())
|
||||
if err != nil {
|
||||
portal.zlog.Err(err).Msg("Failed to delete portal from database")
|
||||
}
|
||||
portal.bridge.portalsLock.Lock()
|
||||
delete(portal.bridge.portalsByKey, portal.Key)
|
||||
if len(portal.MXID) > 0 {
|
||||
delete(portal.bridge.portalsByMXID, portal.MXID)
|
||||
}
|
||||
portal.bridge.portalsLock.Unlock()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
var users []id.UserID
|
||||
for userID := range members.Joined {
|
||||
_, isPuppet := portal.bridge.ParsePuppetMXID(userID)
|
||||
if !isPuppet && userID != portal.bridge.Bot.UserID {
|
||||
users = append(users, userID)
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
portal.log.Infoln("Room seems to be empty, cleaning up...")
|
||||
portal.Delete()
|
||||
portal.Cleanup(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) Cleanup(puppetsOnly bool) {
|
||||
if len(portal.MXID) == 0 {
|
||||
return
|
||||
}
|
||||
intent := portal.MainIntent()
|
||||
if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
|
||||
err := intent.BeeperDeleteRoom(portal.MXID)
|
||||
if err != nil && !errors.Is(err, mautrix.MNotFound) {
|
||||
portal.zlog.Err(err).Msg("Failed to delete room using hungryserv yeet endpoint")
|
||||
}
|
||||
return
|
||||
}
|
||||
members, err := intent.JoinedMembers(portal.MXID)
|
||||
if err != nil {
|
||||
portal.log.Errorln("Failed to get portal members for cleanup:", err)
|
||||
return
|
||||
}
|
||||
for member := range members.Joined {
|
||||
if member == intent.UserID {
|
||||
continue
|
||||
}
|
||||
puppet := portal.bridge.GetPuppetByMXID(member)
|
||||
if puppet != nil {
|
||||
_, err = puppet.DefaultIntent().LeaveRoom(portal.MXID)
|
||||
if err != nil {
|
||||
portal.log.Errorln("Error leaving as puppet while cleaning up portal:", err)
|
||||
}
|
||||
} else if !puppetsOnly {
|
||||
_, err = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
|
||||
if err != nil {
|
||||
portal.log.Errorln("Error kicking user while cleaning up portal:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = intent.LeaveRoom(portal.MXID)
|
||||
if err != nil {
|
||||
portal.log.Errorln("Error leaving with main intent while cleaning up portal:", err)
|
||||
}
|
||||
}
|
280
puppet.go
Normal file
280
puppet.go
Normal file
|
@ -0,0 +1,280 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/bridge"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-gmessages/database"
|
||||
"go.mau.fi/mautrix-gmessages/libgm/binary"
|
||||
)
|
||||
|
||||
var userIDRegex *regexp.Regexp
|
||||
|
||||
func (br *GMBridge) ParsePuppetMXID(mxid id.UserID) (key database.Key, ok bool) {
|
||||
if userIDRegex == nil {
|
||||
userIDRegex = br.Config.MakeUserIDRegex(`([0-9]+)\.([0-9]+)`)
|
||||
}
|
||||
match := userIDRegex.FindStringSubmatch(string(mxid))
|
||||
if len(match) == 3 {
|
||||
var err error
|
||||
key.Receiver, err = strconv.Atoi(match[1])
|
||||
ok = err == nil
|
||||
key.ID = match[2]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
|
||||
key, ok := br.ParsePuppetMXID(mxid)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return br.GetPuppetByKey(key, "")
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetPuppetByKey(key database.Key, phone string) *Puppet {
|
||||
br.puppetsLock.Lock()
|
||||
defer br.puppetsLock.Unlock()
|
||||
puppet, ok := br.puppetsByKey[key]
|
||||
if !ok {
|
||||
dbPuppet, err := br.DB.Puppet.Get(context.TODO(), key)
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Object("puppet_key", key).Msg("Failed to get puppet from database")
|
||||
return nil
|
||||
}
|
||||
if phone == "" {
|
||||
return nil
|
||||
}
|
||||
if dbPuppet == nil {
|
||||
dbPuppet = br.DB.Puppet.New()
|
||||
dbPuppet.Key = key
|
||||
dbPuppet.Phone = phone
|
||||
err = dbPuppet.Insert(context.TODO())
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Object("puppet_key", key).Msg("Failed to insert puppet into database")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
puppet = br.NewPuppet(dbPuppet)
|
||||
br.puppetsByKey[puppet.Key] = puppet
|
||||
}
|
||||
return puppet
|
||||
}
|
||||
|
||||
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 (br *GMBridge) GetAllPuppets() []*Puppet {
|
||||
return br.loadManyPuppets(br.DB.Puppet.GetAll)
|
||||
}
|
||||
|
||||
func (br *GMBridge) loadManyPuppets(query func(ctx context.Context) ([]*database.Puppet, error)) []*Puppet {
|
||||
br.puppetsLock.Lock()
|
||||
defer br.puppetsLock.Unlock()
|
||||
dbPuppets, err := query(context.TODO())
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Msg("Failed to load all puppets from database")
|
||||
return []*Puppet{}
|
||||
}
|
||||
output := make([]*Puppet, len(dbPuppets))
|
||||
for index, dbPuppet := range dbPuppets {
|
||||
if dbPuppet == nil {
|
||||
continue
|
||||
}
|
||||
puppet, ok := br.puppetsByKey[dbPuppet.Key]
|
||||
if !ok {
|
||||
puppet = br.NewPuppet(dbPuppet)
|
||||
br.puppetsByKey[puppet.Key] = puppet
|
||||
}
|
||||
output[index] = puppet
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func (br *GMBridge) FormatPuppetMXID(key database.Key) id.UserID {
|
||||
return id.NewUserID(
|
||||
br.Config.Bridge.FormatUsername(key.String()),
|
||||
br.Config.Homeserver.Domain)
|
||||
}
|
||||
|
||||
func (br *GMBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
|
||||
return &Puppet{
|
||||
Puppet: dbPuppet,
|
||||
bridge: br,
|
||||
log: br.ZLog.With().Str("phone", dbPuppet.Phone).Int("puppet_receiver", dbPuppet.Receiver).Logger(),
|
||||
MXID: br.FormatPuppetMXID(dbPuppet.Key),
|
||||
}
|
||||
}
|
||||
|
||||
type Puppet struct {
|
||||
*database.Puppet
|
||||
bridge *GMBridge
|
||||
log zerolog.Logger
|
||||
MXID id.UserID
|
||||
}
|
||||
|
||||
var _ bridge.GhostWithProfile = (*Puppet)(nil)
|
||||
|
||||
func (puppet *Puppet) GetDisplayname() string {
|
||||
return puppet.Name
|
||||
}
|
||||
|
||||
func (puppet *Puppet) GetAvatarURL() id.ContentURI {
|
||||
return puppet.AvatarMXC
|
||||
}
|
||||
|
||||
func (puppet *Puppet) SwitchCustomMXID(_ string, _ id.UserID) error {
|
||||
return fmt.Errorf("puppets don't support custom MXIDs here")
|
||||
}
|
||||
|
||||
func (puppet *Puppet) IntentFor(_ *Portal) *appservice.IntentAPI {
|
||||
return puppet.DefaultIntent()
|
||||
}
|
||||
|
||||
func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
|
||||
return puppet.bridge.AS.Intent(puppet.MXID)
|
||||
}
|
||||
|
||||
func (puppet *Puppet) UpdateAvatar(source *User, avatarID string) bool {
|
||||
if puppet.AvatarID == avatarID && puppet.AvatarSet {
|
||||
return false
|
||||
}
|
||||
puppet.AvatarID = avatarID
|
||||
puppet.AvatarMXC = id.ContentURI{}
|
||||
puppet.AvatarSet = false
|
||||
// TODO bridge avatar
|
||||
if puppet.AvatarMXC.IsEmpty() {
|
||||
return false
|
||||
}
|
||||
err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarMXC)
|
||||
if err != nil {
|
||||
puppet.log.Warn().Err(err).Msg("Failed to set avatar")
|
||||
} else {
|
||||
puppet.AvatarSet = true
|
||||
}
|
||||
go puppet.updatePortalAvatar()
|
||||
return true
|
||||
}
|
||||
|
||||
func (puppet *Puppet) UpdateName(formattedPhone, fullName, firstName string) bool {
|
||||
newName := puppet.bridge.Config.Bridge.FormatDisplayname(formattedPhone, fullName, firstName)
|
||||
if puppet.Name != newName || !puppet.NameSet {
|
||||
oldName := puppet.Name
|
||||
puppet.Name = newName
|
||||
puppet.NameSet = false
|
||||
err := puppet.DefaultIntent().SetDisplayName(newName)
|
||||
if err == nil {
|
||||
puppet.log.Debug().Str("old_name", oldName).Str("new_name", newName).Msg("Updated displayname")
|
||||
puppet.NameSet = true
|
||||
go puppet.updatePortalName()
|
||||
} else {
|
||||
puppet.log.Warn().Err(err).Msg("Failed to set displayname")
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (puppet *Puppet) UpdateContactInfo() bool {
|
||||
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
|
||||
return false
|
||||
}
|
||||
|
||||
if puppet.ContactInfoSet {
|
||||
return false
|
||||
}
|
||||
|
||||
contactInfo := map[string]any{
|
||||
"com.beeper.bridge.identifiers": []string{
|
||||
fmt.Sprintf("tel:+%s", puppet.Phone),
|
||||
},
|
||||
"com.beeper.bridge.remote_id": puppet.Key.String(),
|
||||
"com.beeper.bridge.service": "gmessages",
|
||||
"com.beeper.bridge.network": "gmessages",
|
||||
}
|
||||
err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo)
|
||||
if err != nil {
|
||||
puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile")
|
||||
return false
|
||||
} else {
|
||||
puppet.ContactInfoSet = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (puppet *Puppet) updatePortalAvatar() {
|
||||
// TODO implement
|
||||
}
|
||||
|
||||
func (puppet *Puppet) updatePortalName() {
|
||||
// TODO implement
|
||||
}
|
||||
|
||||
func (puppet *Puppet) Sync(source *User, contact *binary.Participant) {
|
||||
err := puppet.DefaultIntent().EnsureRegistered()
|
||||
if err != nil {
|
||||
puppet.log.Err(err).Msg("Failed to ensure registered")
|
||||
}
|
||||
|
||||
puppet.log.Debug().Msg("Syncing info")
|
||||
|
||||
update := false
|
||||
if contact != nil {
|
||||
if contact.Id.Number != "" && puppet.Phone != contact.Id.Number {
|
||||
puppet.Phone = contact.Id.Number
|
||||
update = true
|
||||
}
|
||||
update = puppet.UpdateName(contact.GetFormattedNumber(), contact.GetFullName(), contact.GetFirstName()) || update
|
||||
update = puppet.UpdateAvatar(source, contact.GetAvatarID()) || update
|
||||
}
|
||||
update = puppet.UpdateContactInfo() || update
|
||||
if update {
|
||||
err = puppet.Update(context.TODO())
|
||||
if err != nil {
|
||||
puppet.log.Err(err).Msg("Failed to save puppet to database after sync")
|
||||
}
|
||||
}
|
||||
}
|
97
segment.go
Normal file
97
segment.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 Tulir Asokan, Sumner Evans
|
||||
//
|
||||
// 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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const SegmentURL = "https://api.segment.io/v1/track"
|
||||
|
||||
type SegmentClient struct {
|
||||
key string
|
||||
userID string
|
||||
log zerolog.Logger
|
||||
client http.Client
|
||||
}
|
||||
|
||||
var Segment SegmentClient
|
||||
|
||||
func (sc *SegmentClient) trackSync(userID id.UserID, event string, properties map[string]interface{}) error {
|
||||
var buf bytes.Buffer
|
||||
var segmentUserID string
|
||||
if Segment.userID != "" {
|
||||
segmentUserID = Segment.userID
|
||||
} else {
|
||||
segmentUserID = userID.String()
|
||||
}
|
||||
err := json.NewEncoder(&buf).Encode(map[string]interface{}{
|
||||
"userId": segmentUserID,
|
||||
"event": event,
|
||||
"properties": properties,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", SegmentURL, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(sc.key, "")
|
||||
resp, err := sc.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("unexpected status code %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *SegmentClient) IsEnabled() bool {
|
||||
return len(sc.key) > 0
|
||||
}
|
||||
|
||||
func (sc *SegmentClient) Track(userID id.UserID, event string, properties ...map[string]interface{}) {
|
||||
if !sc.IsEnabled() {
|
||||
return
|
||||
} else if len(properties) > 1 {
|
||||
panic("Track should be called with at most one property map")
|
||||
}
|
||||
|
||||
go func() {
|
||||
props := map[string]interface{}{}
|
||||
if len(properties) > 0 {
|
||||
props = properties[0]
|
||||
}
|
||||
props["bridge"] = "gmessages"
|
||||
err := sc.trackSync(userID, event, props)
|
||||
if err != nil {
|
||||
sc.log.Err(err).Str("event", event).Msg("Error tracking event")
|
||||
} else {
|
||||
sc.log.Debug().Str("event", event).Msg("Tracked event")
|
||||
}
|
||||
}()
|
||||
}
|
703
user.go
Normal file
703
user.go
Normal file
|
@ -0,0 +1,703 @@
|
|||
// mautrix-gmessages - A Matrix-Google Messages puppeting bridge.
|
||||
// Copyright (C) 2023 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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/maulogger/v2/maulogadapt"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/bridge"
|
||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||
"maunium.net/go/mautrix/bridge/status"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/pushrules"
|
||||
|
||||
"go.mau.fi/mautrix-gmessages/database"
|
||||
"go.mau.fi/mautrix-gmessages/libgm"
|
||||
"go.mau.fi/mautrix-gmessages/libgm/binary"
|
||||
"go.mau.fi/mautrix-gmessages/libgm/crypto"
|
||||
"go.mau.fi/mautrix-gmessages/libgm/events"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
*database.User
|
||||
Client *libgm.Client
|
||||
|
||||
bridge *GMBridge
|
||||
zlog zerolog.Logger
|
||||
// Deprecated
|
||||
log maulogger.Logger
|
||||
|
||||
Admin bool
|
||||
Whitelisted bool
|
||||
PermissionLevel bridgeconfig.PermissionLevel
|
||||
|
||||
mgmtCreateLock sync.Mutex
|
||||
spaceCreateLock sync.Mutex
|
||||
connLock sync.Mutex
|
||||
|
||||
BridgeState *bridge.BridgeStateQueue
|
||||
|
||||
spaceMembershipChecked bool
|
||||
|
||||
DoublePuppetIntent *appservice.IntentAPI
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
dbUser, err := br.DB.User.GetByMXID(context.TODO(), userID)
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).
|
||||
Str("user_id", userID.String()).
|
||||
Msg("Failed to load user from database")
|
||||
return nil
|
||||
}
|
||||
return br.loadDBUser(dbUser, 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 (user *User) GetPuppetByID(id, phone string) *Puppet {
|
||||
return user.bridge.GetPuppetByKey(database.Key{Receiver: user.RowID, ID: id}, phone)
|
||||
}
|
||||
|
||||
func (user *User) GetPortalByID(id string) *Portal {
|
||||
return user.bridge.GetPortalByKey(database.Key{Receiver: user.RowID, ID: id})
|
||||
}
|
||||
|
||||
func (user *User) GetIDoublePuppet() bridge.DoublePuppet {
|
||||
return user
|
||||
}
|
||||
|
||||
func (user *User) GetIGhost() bridge.Ghost {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 (br *GMBridge) GetUserByMXIDIfExists(userID id.UserID) *User {
|
||||
return br.getUserByMXID(userID, true)
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetUserByPhone(phone string) *User {
|
||||
br.usersLock.Lock()
|
||||
defer br.usersLock.Unlock()
|
||||
user, ok := br.usersByPhone[phone]
|
||||
if !ok {
|
||||
dbUser, err := br.DB.User.GetByPhone(context.TODO(), phone)
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).
|
||||
Str("phone", phone).
|
||||
Msg("Failed to load user from database")
|
||||
}
|
||||
return br.loadDBUser(dbUser, nil)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func (user *User) addToPhoneMap() {
|
||||
user.bridge.usersLock.Lock()
|
||||
user.bridge.usersByPhone[user.Phone] = user
|
||||
user.bridge.usersLock.Unlock()
|
||||
}
|
||||
|
||||
func (user *User) removeFromPhoneMap(state status.BridgeState) {
|
||||
user.bridge.usersLock.Lock()
|
||||
phoneUser, ok := user.bridge.usersByPhone[user.Phone]
|
||||
if ok && user == phoneUser {
|
||||
delete(user.bridge.usersByPhone, user.Phone)
|
||||
}
|
||||
user.bridge.usersLock.Unlock()
|
||||
user.BridgeState.Send(state)
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetAllUsersWithSession() []*User {
|
||||
return br.loadManyUsers(br.DB.User.GetAllWithSession)
|
||||
}
|
||||
|
||||
func (br *GMBridge) GetAllUsersWithDoublePuppet() []*User {
|
||||
return br.loadManyUsers(br.DB.User.GetAllWithDoublePuppet)
|
||||
}
|
||||
|
||||
func (br *GMBridge) loadManyUsers(query func(ctx context.Context) ([]*database.User, error)) []*User {
|
||||
br.usersLock.Lock()
|
||||
defer br.usersLock.Unlock()
|
||||
dbUsers, err := query(context.TODO())
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).Msg("Failed to all load users from database")
|
||||
return []*User{}
|
||||
}
|
||||
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
|
||||
err := dbUser.Insert(context.TODO())
|
||||
if err != nil {
|
||||
br.ZLog.Err(err).
|
||||
Str("user_id", mxid.String()).
|
||||
Msg("Failed to insert user to database")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
user := br.NewUser(dbUser)
|
||||
br.usersByMXID[user.MXID] = user
|
||||
if user.Session != nil && user.Phone != "" {
|
||||
br.usersByPhone[user.Phone] = user
|
||||
} else {
|
||||
user.Session = nil
|
||||
user.Phone = ""
|
||||
}
|
||||
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,
|
||||
zlog: br.ZLog.With().Str("user_id", dbUser.MXID.String()).Logger(),
|
||||
}
|
||||
user.log = maulogadapt.ZeroAsMau(&user.zlog)
|
||||
|
||||
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)
|
||||
return user
|
||||
}
|
||||
|
||||
func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) {
|
||||
extraContent := make(map[string]any)
|
||||
if isDirect {
|
||||
extraContent["is_direct"] = true
|
||||
}
|
||||
if user.DoublePuppetIntent != 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.zlog.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to invite user to room")
|
||||
} else {
|
||||
ok = true
|
||||
}
|
||||
|
||||
if user.DoublePuppetIntent != nil {
|
||||
err = user.DoublePuppetIntent.EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
|
||||
if err != nil {
|
||||
user.zlog.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to auto-join room")
|
||||
ok = false
|
||||
} else {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (user *User) GetSpaceRoom() id.RoomID {
|
||||
if !user.bridge.Config.Bridge.PersonalFilteringSpaces {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(user.SpaceRoom) == 0 {
|
||||
user.spaceCreateLock.Lock()
|
||||
defer user.spaceCreateLock.Unlock()
|
||||
if len(user.SpaceRoom) > 0 {
|
||||
return user.SpaceRoom
|
||||
}
|
||||
|
||||
resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
|
||||
Visibility: "private",
|
||||
Name: "Google Messages",
|
||||
Topic: "Your Google Messages bridged chats",
|
||||
InitialState: []*event.Event{{
|
||||
Type: event.StateRoomAvatar,
|
||||
Content: event.Content{
|
||||
Parsed: &event.RoomAvatarEventContent{
|
||||
URL: user.bridge.Config.AppService.Bot.ParsedAvatar,
|
||||
},
|
||||
},
|
||||
}},
|
||||
CreationContent: map[string]interface{}{
|
||||
"type": event.RoomTypeSpace,
|
||||
},
|
||||
PowerLevelOverride: &event.PowerLevelsEventContent{
|
||||
Users: map[id.UserID]int{
|
||||
user.bridge.Bot.UserID: 9001,
|
||||
user.MXID: 50,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Failed to auto-create space room")
|
||||
} else {
|
||||
user.SpaceRoom = resp.RoomID
|
||||
err = user.Update(context.TODO())
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Failed to update database after creating space room")
|
||||
}
|
||||
user.ensureInvited(user.bridge.Bot, user.SpaceRoom, false)
|
||||
}
|
||||
} else if !user.spaceMembershipChecked && !user.bridge.StateStore.IsInRoom(user.SpaceRoom, user.MXID) {
|
||||
user.ensureInvited(user.bridge.Bot, user.SpaceRoom, false)
|
||||
}
|
||||
user.spaceMembershipChecked = true
|
||||
|
||||
return user.SpaceRoom
|
||||
}
|
||||
|
||||
func (user *User) GetManagementRoom() id.RoomID {
|
||||
if len(user.ManagementRoom) == 0 {
|
||||
user.mgmtCreateLock.Lock()
|
||||
defer user.mgmtCreateLock.Unlock()
|
||||
if len(user.ManagementRoom) > 0 {
|
||||
return user.ManagementRoom
|
||||
}
|
||||
creationContent := make(map[string]interface{})
|
||||
if !user.bridge.Config.Bridge.FederateRooms {
|
||||
creationContent["m.federate"] = false
|
||||
}
|
||||
resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
|
||||
Topic: "Google Messages bridge notices",
|
||||
IsDirect: true,
|
||||
CreationContent: creationContent,
|
||||
})
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Failed to auto-create management room")
|
||||
} else {
|
||||
user.SetManagementRoom(resp.RoomID)
|
||||
}
|
||||
}
|
||||
return user.ManagementRoom
|
||||
}
|
||||
|
||||
func (user *User) SetManagementRoom(roomID id.RoomID) {
|
||||
log := user.zlog.With().
|
||||
Str("management_room_id", roomID.String()).
|
||||
Str("action", "SetManagementRoom").
|
||||
Logger()
|
||||
existingUser, ok := user.bridge.managementRooms[roomID]
|
||||
if ok {
|
||||
existingUser.ManagementRoom = ""
|
||||
err := existingUser.Update(context.TODO())
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Str("prev_user_id", existingUser.MXID.String()).
|
||||
Msg("Failed to clear management room from previous user")
|
||||
}
|
||||
}
|
||||
|
||||
user.ManagementRoom = roomID
|
||||
user.bridge.managementRooms[user.ManagementRoom] = user
|
||||
err := user.Update(context.TODO())
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to update database with management room ID")
|
||||
}
|
||||
}
|
||||
|
||||
var ErrAlreadyLoggedIn = errors.New("already logged in")
|
||||
|
||||
func (user *User) createClient() {
|
||||
var devicePair *libgm.DevicePair
|
||||
var cryptor *crypto.Cryptor
|
||||
if user.Session != nil && user.Session.WebAuthKey != nil {
|
||||
devicePair = &libgm.DevicePair{
|
||||
Mobile: user.Session.PhoneInfo,
|
||||
Browser: user.Session.BrowserInfo,
|
||||
}
|
||||
cryptor = &crypto.Cryptor{
|
||||
AESCTR256Key: user.Session.AESKey,
|
||||
SHA256Key: user.Session.HMACKey,
|
||||
}
|
||||
} else {
|
||||
cryptor = crypto.NewCryptor(nil, nil)
|
||||
user.Session = &database.Session{
|
||||
AESKey: cryptor.AESCTR256Key,
|
||||
HMACKey: cryptor.SHA256Key,
|
||||
}
|
||||
}
|
||||
user.Client = libgm.NewClient(devicePair, cryptor, user.zlog.With().Str("component", "libgm").Logger(), nil)
|
||||
user.Client.SetEventHandler(user.HandleEvent)
|
||||
}
|
||||
|
||||
func (user *User) Login(ctx context.Context) (<-chan string, error) {
|
||||
user.connLock.Lock()
|
||||
defer user.connLock.Unlock()
|
||||
if user.Session != nil {
|
||||
return nil, ErrAlreadyLoggedIn
|
||||
} else if user.Client != nil {
|
||||
user.unlockedDeleteConnection()
|
||||
}
|
||||
user.createClient()
|
||||
pairer, err := user.Client.NewPairer(nil, 20)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize pairer: %w", err)
|
||||
}
|
||||
resp, err := pairer.RegisterPhoneRelay()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register phone relay: %w", err)
|
||||
}
|
||||
err = user.Client.Connect(resp.Field5.RpcKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Google Messages: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (user *User) Connect() bool {
|
||||
user.connLock.Lock()
|
||||
defer user.connLock.Unlock()
|
||||
if user.Client != nil {
|
||||
return true
|
||||
} else if user.Session == nil {
|
||||
return false
|
||||
}
|
||||
user.zlog.Debug().Msg("Connecting to Google Messages")
|
||||
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting, Error: WAConnecting})
|
||||
err := user.Client.Connect(user.Session.WebAuthKey)
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Error connecting to Google Messages")
|
||||
user.BridgeState.Send(status.BridgeState{
|
||||
StateEvent: status.StateUnknownError,
|
||||
Error: WAConnectionFailed,
|
||||
Info: map[string]interface{}{
|
||||
"go_error": err.Error(),
|
||||
},
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (user *User) unlockedDeleteConnection() {
|
||||
if user.Client == nil {
|
||||
return
|
||||
}
|
||||
user.Client.Disconnect()
|
||||
user.Client.SetEventHandler(nil)
|
||||
user.Client = nil
|
||||
}
|
||||
|
||||
func (user *User) DeleteConnection() {
|
||||
user.connLock.Lock()
|
||||
defer user.connLock.Unlock()
|
||||
user.unlockedDeleteConnection()
|
||||
}
|
||||
|
||||
func (user *User) HasSession() bool {
|
||||
return user.Session != nil
|
||||
}
|
||||
|
||||
func (user *User) DeleteSession() {
|
||||
user.Session = nil
|
||||
user.Phone = ""
|
||||
err := user.Update(context.TODO())
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Failed to delete session from database")
|
||||
}
|
||||
}
|
||||
|
||||
func (user *User) IsConnected() bool {
|
||||
return user.Client != nil && user.Client.IsConnected()
|
||||
}
|
||||
|
||||
func (user *User) IsLoggedIn() bool {
|
||||
return user.IsConnected() && user.Client.IsLoggedIn()
|
||||
}
|
||||
|
||||
func (user *User) tryAutomaticDoublePuppeting() {
|
||||
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) || user.DoublePuppetIntent != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.loginWithSharedSecret(); err != nil {
|
||||
user.zlog.Warn().Err(err).Msg("Failed to login with shared secret for double puppeting")
|
||||
} else if err = user.startCustomMXID(false); err != nil {
|
||||
user.zlog.Warn().Err(err).Msg("Failed to start double puppet after logging in with shared secret")
|
||||
} else {
|
||||
user.zlog.Info().Msg("Successfully automatically enabled double puppet")
|
||||
}
|
||||
}
|
||||
|
||||
func (user *User) sendMarkdownBridgeAlert(formatString string, args ...interface{}) {
|
||||
if user.bridge.Config.Bridge.DisableBridgeAlerts {
|
||||
return
|
||||
}
|
||||
notice := fmt.Sprintf(formatString, args...)
|
||||
content := format.RenderMarkdown(notice, true, false)
|
||||
_, err := user.bridge.Bot.SendMessageEvent(user.GetManagementRoom(), event.EventMessage, content)
|
||||
if err != nil {
|
||||
user.zlog.Warn().Err(err).Str("notice", notice).Msg("Failed to send bridge alert")
|
||||
}
|
||||
}
|
||||
|
||||
func (user *User) HandleEvent(event interface{}) {
|
||||
switch v := event.(type) {
|
||||
case *events.QR:
|
||||
// These should be here
|
||||
user.zlog.Info().Msg(v.URL)
|
||||
case *events.PairSuccessful:
|
||||
user.Phone = v.PairDeviceData.Mobile.RegistrationID
|
||||
user.Session.PhoneInfo = v.PairDeviceData.Mobile
|
||||
user.Session.BrowserInfo = v.PairDeviceData.Browser
|
||||
user.Session.WebAuthKey = v.PairDeviceData.WebAuthKeyData.GetWebAuthKey()
|
||||
user.addToPhoneMap()
|
||||
err := user.Update(context.TODO())
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Failed to update session in database")
|
||||
}
|
||||
case *binary.Event_ConversationEvent:
|
||||
portal := user.GetPortalByID(v.ConversationEvent.GetData().GetConversationID())
|
||||
if portal.MXID != "" {
|
||||
portal.UpdateMetadata(user, v.ConversationEvent.GetData())
|
||||
} else {
|
||||
err := portal.CreateMatrixRoom(user, v.ConversationEvent.GetData())
|
||||
if err != nil {
|
||||
user.zlog.Err(err).Msg("Error creating Matrix room from conversation event")
|
||||
}
|
||||
}
|
||||
case *binary.Event_MessageEvent:
|
||||
portal := user.GetPortalByID(v.MessageEvent.GetData().GetConversationID())
|
||||
portal.messages <- PortalMessage{evt: v.MessageEvent.GetData(), source: user}
|
||||
case *events.ClientReady:
|
||||
user.zlog.Trace().Any("data", v).Msg("Client is ready!")
|
||||
case *events.BrowserActive:
|
||||
user.zlog.Trace().Any("data", v).Msg("Browser active")
|
||||
case *events.Battery:
|
||||
user.zlog.Trace().Any("data", v).Msg("Battery")
|
||||
case *events.DataConnection:
|
||||
user.zlog.Trace().Any("data", v).Msg("Data connection")
|
||||
default:
|
||||
user.zlog.Trace().Any("data", v).Msg("Unknown event")
|
||||
}
|
||||
}
|
||||
|
||||
func (user *User) updateChatMute(portal *Portal, mutedUntil time.Time) {
|
||||
intent := user.DoublePuppetIntent
|
||||
if intent == nil || len(portal.MXID) == 0 {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if mutedUntil.IsZero() && mutedUntil.Before(time.Now()) {
|
||||
user.log.Debugfln("Portal %s is muted until %s, unmuting...", portal.MXID, mutedUntil)
|
||||
err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID))
|
||||
} else {
|
||||
user.log.Debugfln("Portal %s is muted until %s, muting...", portal.MXID, mutedUntil)
|
||||
err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{
|
||||
Actions: []pushrules.PushActionType{pushrules.ActionDontNotify},
|
||||
})
|
||||
}
|
||||
if err != nil && !errors.Is(err, mautrix.MNotFound) {
|
||||
user.log.Warnfln("Failed to update push rule for %s through double puppet: %v", portal.MXID, err)
|
||||
}
|
||||
}
|
||||
|
||||
type CustomTagData struct {
|
||||
Order json.Number `json:"order"`
|
||||
DoublePuppet string `json:"fi.mau.double_puppet_source"`
|
||||
}
|
||||
|
||||
type CustomTagEventContent struct {
|
||||
Tags map[string]CustomTagData `json:"tags"`
|
||||
}
|
||||
|
||||
func (user *User) updateChatTag(portal *Portal, tag string, active bool) {
|
||||
intent := user.DoublePuppetIntent
|
||||
if intent == nil || len(portal.MXID) == 0 {
|
||||
return
|
||||
}
|
||||
var existingTags CustomTagEventContent
|
||||
err := intent.GetTagsWithCustomData(portal.MXID, &existingTags)
|
||||
if err != nil && !errors.Is(err, mautrix.MNotFound) {
|
||||
user.log.Warnfln("Failed to get tags of %s: %v", portal.MXID, err)
|
||||
}
|
||||
currentTag, ok := existingTags.Tags[tag]
|
||||
if active && !ok {
|
||||
user.log.Debugln("Adding tag", tag, "to", portal.MXID)
|
||||
data := CustomTagData{Order: "0.5", DoublePuppet: user.bridge.Name}
|
||||
err = intent.AddTagWithCustomData(portal.MXID, tag, &data)
|
||||
} else if !active && ok && currentTag.DoublePuppet == user.bridge.Name {
|
||||
user.log.Debugln("Removing tag", tag, "from", portal.MXID)
|
||||
err = intent.RemoveTag(portal.MXID, tag)
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
user.log.Warnfln("Failed to update tag %s for %s through double puppet: %v", tag, portal.MXID, err)
|
||||
}
|
||||
}
|
||||
|
||||
type CustomReadReceipt struct {
|
||||
Timestamp int64 `json:"ts,omitempty"`
|
||||
DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"`
|
||||
}
|
||||
|
||||
type CustomReadMarkers struct {
|
||||
mautrix.ReqSetReadMarkers
|
||||
ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"`
|
||||
FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"`
|
||||
}
|
||||
|
||||
func (user *User) syncChatDoublePuppetDetails(portal *Portal, conv *binary.Conversation, justCreated bool) {
|
||||
if user.DoublePuppetIntent == nil || len(portal.MXID) == 0 {
|
||||
return
|
||||
}
|
||||
if justCreated || !user.bridge.Config.Bridge.TagOnlyOnCreate {
|
||||
//user.updateChatMute(portal, chat.MutedUntil)
|
||||
//user.updateChatTag(portal, user.bridge.Config.Bridge.ArchiveTag, conv.Status == 2)
|
||||
//user.updateChatTag(portal, user.bridge.Config.Bridge.PinnedTag, chat.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func (user *User) UpdateDirectChats(chats map[id.UserID][]id.RoomID) {
|
||||
if !user.bridge.Config.Bridge.SyncDirectChatList || user.DoublePuppetIntent == nil {
|
||||
return
|
||||
}
|
||||
intent := user.DoublePuppetIntent
|
||||
method := http.MethodPatch
|
||||
//if chats == nil {
|
||||
// chats = user.getDirectChats()
|
||||
// method = http.MethodPut
|
||||
//}
|
||||
user.zlog.Debug().Msg("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) markUnread(portal *Portal, unread bool) {
|
||||
if user.DoublePuppetIntent == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log := user.zlog.With().Str("room_id", portal.MXID.String()).Logger()
|
||||
|
||||
err := user.DoublePuppetIntent.SetRoomAccountData(portal.MXID, "m.marked_unread", map[string]bool{"unread": unread})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("event_type", "m.marked_unread").
|
||||
Msg("Failed to mark room as unread")
|
||||
} else {
|
||||
log.Debug().Str("event_type", "m.marked_unread").Msg("Marked room as unread")
|
||||
}
|
||||
|
||||
err = user.DoublePuppetIntent.SetRoomAccountData(portal.MXID, "com.famedly.marked_unread", map[string]bool{"unread": unread})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("event_type", "com.famedly.marked_unread").
|
||||
Msg("Failed to mark room as unread")
|
||||
} else {
|
||||
log.Debug().Str("event_type", "com.famedly.marked_unread").Msg("Marked room as unread")
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue