Add initial compiling version of bridge

This commit is contained in:
Tulir Asokan 2023-07-02 17:21:55 +03:00
parent 28629b8229
commit 63284481d7
25 changed files with 4547 additions and 1 deletions

1
.gitignore vendored
View file

@ -8,5 +8,6 @@
*.json *.json
*.db *.db
*.log *.log
*.log.gz
/mautrix-gmessages /mautrix-gmessages

View file

@ -14,7 +14,6 @@ ENV UID=1337 \
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl 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 /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 COPY --from=builder /build/docker-run.sh /docker-run.sh
VOLUME /data VOLUME /data

94
bridgestate.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
);

View 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
View 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
View 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
View 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
View file

@ -1,3 +1,49 @@
module go.mau.fi/mautrix-gmessages module go.mau.fi/mautrix-gmessages
go 1.20 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")
}
}