2023-07-02 14:21:55 +00:00
// 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 (
2024-02-23 19:10:31 +00:00
"context"
2024-02-22 21:05:00 +00:00
"encoding/json"
2024-03-12 15:51:45 +00:00
"errors"
2023-07-16 12:55:30 +00:00
"fmt"
2023-07-22 16:47:46 +00:00
"strings"
2023-07-02 14:21:55 +00:00
"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"
2023-07-22 16:47:46 +00:00
2024-03-12 15:51:45 +00:00
"go.mau.fi/mautrix-gmessages/libgm"
2023-07-22 16:47:46 +00:00
"go.mau.fi/mautrix-gmessages/libgm/gmproto"
2023-07-02 14:21:55 +00:00
)
type WrappedCommandEvent struct {
* commands . Event
Bridge * GMBridge
User * User
Portal * Portal
}
func ( br * GMBridge ) RegisterCommands ( ) {
proc := br . CommandProcessor . ( * commands . Processor )
proc . AddHandlers (
2024-02-22 21:05:00 +00:00
cmdLoginQR ,
cmdLoginGoogle ,
2023-07-02 14:21:55 +00:00
cmdDeleteSession ,
2023-07-15 23:11:25 +00:00
cmdLogout ,
2023-07-02 14:21:55 +00:00
cmdReconnect ,
cmdDisconnect ,
2023-07-19 20:04:28 +00:00
cmdSetActive ,
2023-07-02 14:21:55 +00:00
cmdPing ,
2023-07-22 16:47:46 +00:00
cmdPM ,
2023-07-02 14:21:55 +00:00
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 }
)
2024-02-22 21:05:00 +00:00
var cmdLoginQR = & commands . FullHandler {
Func : wrapCommand ( fnLoginQR ) ,
Name : "login-qr" ,
Aliases : [ ] string { "login" } ,
2023-07-02 14:21:55 +00:00
Help : commands . HelpMeta {
Section : commands . HelpSectionAuth ,
2024-02-22 21:05:00 +00:00
Description : "Link the bridge to Google Messages on your Android phone by scanning a QR code." ,
2023-07-02 14:21:55 +00:00
} ,
}
2024-02-22 21:05:00 +00:00
func fnLoginQR ( ce * WrappedCommandEvent ) {
2023-07-02 14:21:55 +00:00
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
2023-07-16 12:55:30 +00:00
} else if ce . User . pairSuccessChan != nil {
ce . Reply ( "You already have a login in progress" )
return
2023-07-02 14:21:55 +00:00
}
2023-07-18 11:51:43 +00:00
ch , err := ce . User . Login ( 6 )
2023-07-02 14:21:55 +00:00
if err != nil {
2023-07-16 12:55:30 +00:00
ce . ZLog . Err ( err ) . Msg ( "Failed to start login" )
ce . Reply ( "Failed to start login: %v" , err )
2023-07-02 14:21:55 +00:00
return
}
2023-07-16 12:55:30 +00:00
var prevEvent id . EventID
for item := range ch {
switch {
case item . qr != "" :
ce . ZLog . Debug ( ) . Msg ( "Got code in QR channel" )
prevEvent = ce . User . sendQR ( ce , item . qr , prevEvent )
case item . err != nil :
ce . ZLog . Err ( err ) . Msg ( "Error in QR channel" )
prevEvent = ce . User . sendQREdit ( ce , & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : fmt . Sprintf ( "Failed to log in: %v" , err ) ,
} , prevEvent )
case item . success :
ce . ZLog . Debug ( ) . Msg ( "Got pair success in QR channel" )
prevEvent = ce . User . sendQREdit ( ce , & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : "Successfully logged in" ,
} , prevEvent )
2023-07-16 22:13:46 +00:00
default :
ce . ZLog . Error ( ) . Any ( "item_data" , item ) . Msg ( "Unknown item in QR channel" )
2023-07-16 12:55:30 +00:00
}
}
ce . ZLog . Trace ( ) . Msg ( "Login command finished" )
2023-07-02 14:21:55 +00:00
}
2024-02-22 21:05:00 +00:00
var cmdLoginGoogle = & commands . FullHandler {
Func : wrapCommand ( fnLoginGoogle ) ,
Name : "login-google" ,
Aliases : [ ] string { "login" } ,
Help : commands . HelpMeta {
Section : commands . HelpSectionAuth ,
Description : "Link the bridge to Google Messages on your Android phone by logging in with your Google account." ,
} ,
}
func fnLoginGoogle ( 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
} else if ce . User . pairSuccessChan != nil {
ce . Reply ( "You already have a login in progress" )
return
}
ce . User . CommandState = & commands . CommandState {
Next : commands . MinimalHandlerFunc ( wrapCommand ( fnLoginGoogleCookies ) ) ,
Action : "Login" ,
}
ce . Reply ( "Send your Google cookies here, formatted as a key-value JSON object (see <https://docs.mau.fi/bridges/go/gmessages/authentication.html> for details)" )
}
2024-03-12 15:51:45 +00:00
const (
pairingErrMsgNoDevices = "No devices found. Make sure you've enabled account pairing in the Google Messages app on your phone."
pairingErrMsgIncorrectEmoji = "Incorrect emoji chosen on phone, please try again"
pairingErrMsgCancelled = "Pairing cancelled on phone"
pairingErrMsgTimeout = "Pairing timed out, please try again"
)
2024-02-22 21:05:00 +00:00
func fnLoginGoogleCookies ( ce * WrappedCommandEvent ) {
ce . User . CommandState = nil
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
} else if ce . User . pairSuccessChan != nil {
ce . Reply ( "You already have a login in progress" )
return
}
var cookies map [ string ] string
err := json . Unmarshal ( [ ] byte ( ce . RawArgs ) , & cookies )
if err != nil {
ce . Reply ( "Failed to parse cookies: %v" , err )
return
2024-02-26 14:48:06 +00:00
} else if missingCookie := findMissingCookies ( cookies ) ; missingCookie != "" {
ce . Reply ( "Missing %s cookie" , missingCookie )
return
2024-02-22 21:05:00 +00:00
}
2024-02-23 20:00:39 +00:00
ce . Redact ( )
2024-02-26 14:10:32 +00:00
err = ce . User . LoginGoogle ( ce . Ctx , cookies , func ( emoji string ) {
2024-02-22 21:05:00 +00:00
ce . Reply ( emoji )
} )
if err != nil {
2024-03-12 15:51:45 +00:00
if errors . Is ( err , libgm . ErrNoDevicesFound ) {
ce . Reply ( pairingErrMsgNoDevices )
} else if errors . Is ( err , libgm . ErrIncorrectEmoji ) {
ce . Reply ( pairingErrMsgIncorrectEmoji )
} else if errors . Is ( err , libgm . ErrPairingCancelled ) {
ce . Reply ( pairingErrMsgCancelled )
} else if errors . Is ( err , libgm . ErrPairingTimeout ) {
ce . Reply ( pairingErrMsgTimeout )
} else {
ce . Reply ( "Login failed: %v" , err )
}
2024-02-22 21:05:00 +00:00
} else {
ce . Reply ( "Login successful" )
}
}
2023-07-16 12:55:30 +00:00
func ( user * User ) sendQREdit ( ce * WrappedCommandEvent , content * event . MessageEventContent , prevEvent id . EventID ) id . EventID {
2023-07-02 14:21:55 +00:00
if len ( prevEvent ) != 0 {
content . SetEdit ( prevEvent )
}
2024-02-23 19:10:31 +00:00
resp , err := ce . Bot . SendMessageEvent ( ce . Ctx , ce . RoomID , event . EventMessage , & content )
2023-07-02 14:21:55 +00:00
if err != nil {
2023-07-16 12:55:30 +00:00
ce . ZLog . Err ( err ) . Msg ( "Failed to send edited QR code" )
2023-07-02 14:21:55 +00:00
} else if len ( prevEvent ) == 0 {
prevEvent = resp . EventID
}
return prevEvent
}
2023-07-16 12:55:30 +00:00
func ( user * User ) sendQR ( ce * WrappedCommandEvent , code string , prevEvent id . EventID ) id . EventID {
var content event . MessageEventContent
2024-02-23 19:10:31 +00:00
url , err := user . uploadQR ( ce . Ctx , code )
2023-07-02 14:21:55 +00:00
if err != nil {
2023-07-16 12:55:30 +00:00
ce . ZLog . Err ( err ) . Msg ( "Failed to upload QR code" )
content = event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : fmt . Sprintf ( "Failed to upload QR code: %v" , err ) ,
}
} else {
content = event . MessageEventContent {
MsgType : event . MsgImage ,
Body : code ,
URL : url . CUString ( ) ,
}
2023-07-02 14:21:55 +00:00
}
2023-07-16 12:55:30 +00:00
return user . sendQREdit ( ce , & content , prevEvent )
}
2023-07-02 14:21:55 +00:00
2024-02-23 19:10:31 +00:00
func ( user * User ) uploadQR ( ctx context . Context , code string ) ( id . ContentURI , error ) {
2023-07-16 12:55:30 +00:00
qrCode , err := qrcode . Encode ( code , qrcode . Low , 256 )
if err != nil {
return id . ContentURI { } , err
}
2024-02-23 19:10:31 +00:00
resp , err := user . bridge . Bot . UploadBytes ( ctx , qrCode , "image/png" )
2023-07-02 14:21:55 +00:00
if err != nil {
2023-07-16 12:55:30 +00:00
return id . ContentURI { } , err
2023-07-02 14:21:55 +00:00
}
2023-07-16 12:55:30 +00:00
return resp . ContentURI , nil
2023-07-02 14:21:55 +00:00
}
2023-07-15 23:11:25 +00:00
var cmdLogout = & commands . FullHandler {
Func : wrapCommand ( fnLogout ) ,
Name : "logout" ,
Help : commands . HelpMeta {
Section : commands . HelpSectionAuth ,
Description : "Unpair the bridge and delete session information." ,
} ,
}
func fnLogout ( ce * WrappedCommandEvent ) {
if ce . User . Session == nil && ce . User . Client == nil {
ce . Reply ( "You're not logged in" )
return
}
logoutOK := ce . User . Logout ( status . BridgeState { StateEvent : status . StateLoggedOut } , true )
if logoutOK {
ce . Reply ( "Successfully logged out" )
} else {
ce . Reply ( "Session information removed, but unpairing may not have succeeded" )
}
}
2023-07-02 14:21:55 +00:00
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
}
2023-07-15 23:11:25 +00:00
ce . User . Logout ( status . BridgeState { StateEvent : status . StateLoggedOut } , false )
2023-07-02 14:21:55 +00:00
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 ( )
2023-07-03 21:03:36 +00:00
ce . User . BridgeState . Send ( status . BridgeState { StateEvent : status . StateTransientDisconnect , Error : GMNotConnected } )
2023-07-02 14:21:55 +00:00
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." )
2023-07-03 21:03:36 +00:00
ce . User . BridgeState . Send ( status . BridgeState { StateEvent : status . StateBadCredentials , Error : GMNotConnected } )
2023-07-02 14:21:55 +00:00
}
2023-07-19 20:04:28 +00:00
var cmdSetActive = & commands . FullHandler {
Func : wrapCommand ( fnSetActive ) ,
Name : "set-active" ,
Help : commands . HelpMeta {
Section : HelpSectionConnectionManagement ,
Description : "Set the bridge as the active browser (if you opened Google Messages in a real browser)" ,
} ,
}
func fnSetActive ( ce * WrappedCommandEvent ) {
if ce . User . Client == nil {
ce . Reply ( "You don't have a Google Messages connection." )
return
}
err := ce . User . Client . SetActiveSession ( )
if err != nil {
ce . Reply ( "Failed to set active session: %v" , err )
} else {
ce . Reply ( "Set bridge as active session" )
}
}
2023-07-02 14:21:55 +00:00
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 ( ) {
2023-08-02 12:19:55 +00:00
ce . Reply ( "Linked to %s, but not connected to Google Messages." , ce . User . PhoneID )
} else if ce . User . longPollingError != nil {
ce . Reply ( "Linked to %s, but long polling is erroring (%v)" , ce . User . PhoneID , ce . User . longPollingError )
} else if ce . User . browserInactiveType != "" {
ce . Reply ( "Linked to %s, but not active, use `set-active` to reconnect" , ce . User . PhoneID )
2023-07-02 14:21:55 +00:00
} else {
2023-08-02 12:19:55 +00:00
modifiers := make ( [ ] string , 0 , 3 )
if ce . User . batteryLow {
modifiers = append ( modifiers , "battery low" )
}
if ce . User . mobileData {
modifiers = append ( modifiers , "using mobile data" )
}
if ! ce . User . phoneResponding {
modifiers = append ( modifiers , "phone not responding" )
}
var modifierStr string
if len ( modifiers ) > 0 {
modifierStr = fmt . Sprintf ( " (warnings: %s)" , strings . Join ( modifiers , ", " ) )
}
ce . Reply ( "Linked to %s and active as primary browser%s" , ce . User . PhoneID , modifierStr )
2023-07-02 14:21:55 +00:00
}
}
2023-07-22 16:47:46 +00:00
var cmdPM = & commands . FullHandler {
Func : wrapCommand ( fnPM ) ,
Name : "pm" ,
Help : commands . HelpMeta {
Section : HelpSectionPortalManagement ,
Description : "Create a chat on Google Messages" ,
Args : "<phone numbers...>" ,
} ,
RequiresLogin : true ,
}
func fnPM ( ce * WrappedCommandEvent ) {
var reqData gmproto . GetOrCreateConversationRequest
reqData . Numbers = make ( [ ] * gmproto . ContactNumber , 0 , len ( ce . Args ) )
for _ , number := range ce . Args {
number = strings . TrimSpace ( number )
if number == "" {
continue
}
reqData . Numbers = append ( reqData . Numbers , & gmproto . ContactNumber {
// This should maybe sometimes be 7
MysteriousInt : 2 ,
Number : number ,
Number2 : number ,
} )
}
resp , err := ce . User . Client . GetOrCreateConversation ( & reqData )
if err != nil {
ce . ZLog . Err ( err ) . Msg ( "Failed to start chat" )
ce . Reply ( "Failed to start chat: request failed" )
} else if len ( reqData . Numbers ) > 1 && resp . GetStatus ( ) == gmproto . GetOrCreateConversationResponse_CREATE_RCS {
ce . Reply ( "All recipients are on RCS, but creating RCS groups via this command is not yet supported." )
} else if resp . GetConversation ( ) == nil {
ce . ZLog . Warn ( ) .
Int ( "req_number_count" , len ( reqData . Numbers ) ) .
Str ( "status" , resp . GetStatus ( ) . String ( ) ) .
Msg ( "No conversation in chat create response" )
ce . Reply ( "Failed to start chat: no conversation in response" )
} else if portal := ce . User . GetPortalByID ( resp . Conversation . ConversationID ) ; portal . MXID != "" {
ce . Reply ( "Chat already exists at [%s](https://matrix.to/#/%s)" , portal . MXID , portal . MXID )
2024-02-23 19:10:31 +00:00
} else if err = portal . CreateMatrixRoom ( ce . Ctx , ce . User , resp . Conversation , false ) ; err != nil {
2023-07-22 16:47:46 +00:00
ce . ZLog . Err ( err ) . Msg ( "Failed to create matrix room" )
ce . Reply ( "Failed to create portal room for conversation" )
} else {
ce . Reply ( "Chat created: [%s](https://matrix.to/#/%s)" , portal . MXID , portal . MXID )
}
}
2023-07-02 14:21:55 +00:00
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 ) {
2023-07-19 21:01:05 +00:00
if ! ce . User . Admin && ce . Portal . Receiver != ce . User . RowID {
ce . Reply ( "Only bridge admins can delete other users' portals" )
2023-07-02 14:21:55 +00:00
return
}
2023-07-19 21:01:05 +00:00
ce . ZLog . Info ( ) . Str ( "conversation_id" , ce . Portal . ID ) . Msg ( "Deleting portal from command" )
2024-02-23 19:10:31 +00:00
ce . Portal . Delete ( ce . Ctx )
ce . Portal . Cleanup ( ce . Ctx )
2023-07-02 14:21:55 +00:00
}
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 {
2024-02-23 19:10:31 +00:00
_ , _ = portal . MainIntent ( ) . KickUser ( ce . Ctx , portal . MXID , & mautrix . ReqKickUser {
2023-07-02 14:21:55 +00:00
Reason : "Deleting portal" ,
UserID : ce . User . MXID ,
} )
}
}
intent := ce . User . DoublePuppetIntent
if intent != nil {
leave = func ( portal * Portal ) {
if len ( portal . MXID ) > 0 {
2024-02-23 19:10:31 +00:00
_ , _ = intent . LeaveRoom ( ce . Ctx , portal . MXID )
_ , _ = intent . ForgetRoom ( ce . Ctx , portal . MXID )
2023-07-02 14:21:55 +00:00
}
}
}
roomYeeting := ce . Bridge . SpecVersions . Supports ( mautrix . BeeperFeatureRoomYeeting )
if roomYeeting {
leave = func ( portal * Portal ) {
2024-02-23 19:10:31 +00:00
portal . Cleanup ( ce . Ctx )
2023-07-02 14:21:55 +00:00
}
}
ce . Reply ( "Found %d portals, deleting..." , len ( portals ) )
for _ , portal := range portals {
2024-02-23 19:10:31 +00:00
portal . Delete ( ce . Ctx )
2023-07-02 14:21:55 +00:00
leave ( portal )
}
if ! roomYeeting {
ce . Reply ( "Finished deleting portal info. Now cleaning up rooms in background." )
go func ( ) {
for _ , portal := range portals {
2024-02-23 19:10:31 +00:00
portal . Cleanup ( ce . Ctx )
2023-07-02 14:21:55 +00:00
}
ce . Reply ( "Finished background cleanup of deleted portal rooms." )
} ( )
} else {
ce . Reply ( "Finished deleting portals." )
}
}