Skip to content

Commit 778f223

Browse files
committed
phoneping: implement hacky phone pinging
1 parent 71e0723 commit 778f223

File tree

3 files changed

+154
-2
lines changed

3 files changed

+154
-2
lines changed

pkg/connector/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ type WhatsAppClient struct {
9090
resyncQueue map[types.JID]resyncQueueItem
9191
resyncQueueLock sync.Mutex
9292
nextResync time.Time
93+
94+
lastPhoneOfflineWarning time.Time
9395
}
9496

9597
var _ bridgev2.NetworkAPI = (*WhatsAppClient)(nil)
@@ -144,6 +146,7 @@ func (wa *WhatsAppClient) startLoops() {
144146
}
145147
go wa.historySyncLoop(ctx)
146148
go wa.ghostResyncLoop(ctx)
149+
go wa.disconnectWarningLoop(ctx)
147150
}
148151

149152
func (wa *WhatsAppClient) Disconnect() {

pkg/connector/handlewhatsapp.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) {
109109
wa.historySyncs <- evt.Data
110110
}
111111
case *events.MediaRetry:
112+
wa.phoneSeen(evt.Timestamp)
112113
// TODO
113114

114115
case *events.GroupInfo:
@@ -271,6 +272,9 @@ func (wa *WhatsAppClient) handleWAUndecryptableMessage(evt *events.Undecryptable
271272
}
272273

273274
func (wa *WhatsAppClient) handleWAReceipt(evt *events.Receipt) {
275+
if evt.IsFromMe && evt.Sender.Device == 0 {
276+
wa.phoneSeen(evt.Timestamp)
277+
}
274278
var evtType bridgev2.RemoteEventType
275279
switch evt.Type {
276280
case types.ReceiptTypeRead, types.ReceiptTypeReadSelf:

pkg/connector/phoneping.go

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,151 @@
1+
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
2+
// Copyright (C) 2024 Tulir Asokan
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
117
package connector
218

19+
import (
20+
"context"
21+
"fmt"
22+
"time"
23+
24+
"go.mau.fi/util/exfmt"
25+
"go.mau.fi/util/jsontime"
26+
"go.mau.fi/whatsmeow"
27+
"go.mau.fi/whatsmeow/proto/waE2E"
28+
"maunium.net/go/mautrix/bridge/status"
29+
"maunium.net/go/mautrix/event"
30+
31+
"maunium.net/go/mautrix-whatsapp/pkg/waid"
32+
)
33+
34+
func (wa *WhatsAppClient) disconnectWarningLoop(ctx context.Context) {
35+
ticker := time.NewTicker(1 * time.Hour)
36+
defer ticker.Stop()
37+
for {
38+
select {
39+
case <-ctx.Done():
40+
return
41+
case <-ticker.C:
42+
if wa.Client != nil && wa.Client.IsConnected() {
43+
if !wa.PhoneRecentlySeen(true) {
44+
go wa.sendPhoneOfflineWarning(ctx)
45+
}
46+
}
47+
}
48+
}
49+
}
50+
51+
func (wa *WhatsAppClient) sendPhoneOfflineWarning(ctx context.Context) {
52+
if wa.UserLogin.User.ManagementRoom == "" || time.Since(wa.lastPhoneOfflineWarning) < 12*time.Hour {
53+
// Don't spam the warning too much
54+
return
55+
}
56+
wa.lastPhoneOfflineWarning = time.Now()
57+
timeSinceSeen := time.Since(wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PhoneLastSeen.Time).Round(time.Hour)
58+
// TODO remove this manual message after bridge states are plumbed to the management room as messages
59+
_, _ = wa.Main.Bridge.Bot.SendMessage(ctx, wa.UserLogin.User.ManagementRoom, event.EventMessage, &event.Content{
60+
Parsed: &event.MessageEventContent{
61+
MsgType: event.MsgText,
62+
Body: fmt.Sprintf("Your phone hasn't been seen in %s. The server will force the bridge to log out if the phone is not active at least every 2 weeks.", exfmt.Duration(timeSinceSeen)),
63+
},
64+
}, nil)
65+
}
66+
67+
const PhoneDisconnectWarningTime = 12 * 24 * time.Hour // 12 days
68+
const PhoneDisconnectPingTime = 10 * 24 * time.Hour
69+
const PhoneMinPingInterval = 24 * time.Hour
70+
371
func (wa *WhatsAppClient) PhoneRecentlySeen(doPing bool) bool {
4-
// TODO implement
5-
return true
72+
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
73+
if doPing && !meta.PhoneLastSeen.IsZero() && time.Since(meta.PhoneLastSeen.Time) > PhoneDisconnectPingTime && time.Since(meta.PhoneLastPinged.Time) > PhoneMinPingInterval {
74+
// Over 10 days since the phone was seen and over a day since the last somewhat hacky ping, send a new ping.
75+
go wa.sendHackyPhonePing()
76+
}
77+
return meta.PhoneLastSeen.IsZero() || time.Since(meta.PhoneLastSeen.Time) < PhoneDisconnectWarningTime
78+
}
79+
80+
const getUserLastAppStateKeyIDQuery = "SELECT key_id FROM whatsmeow_app_state_sync_keys WHERE jid=$1 ORDER BY timestamp DESC LIMIT 1"
81+
82+
func (wa *WhatsAppClient) sendHackyPhonePing() {
83+
log := wa.UserLogin.Log.With().Str("action", "hacky phone ping").Logger()
84+
ctx := log.WithContext(context.Background())
85+
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
86+
meta.PhoneLastPinged = jsontime.UnixNow()
87+
msgID := wa.Client.GenerateMessageID()
88+
keyIDs := make([]*waE2E.AppStateSyncKeyId, 0, 1)
89+
var lastKeyID []byte
90+
err := wa.Main.DB.QueryRow(ctx, getUserLastAppStateKeyIDQuery, wa.JID).Scan(&lastKeyID)
91+
if err != nil {
92+
log.Err(err).Msg("Failed to get last app state key ID to send hacky phone ping - sending empty request")
93+
} else if lastKeyID != nil {
94+
keyIDs = append(keyIDs, &waE2E.AppStateSyncKeyId{
95+
KeyID: lastKeyID,
96+
})
97+
}
98+
resp, err := wa.Client.SendMessage(ctx, wa.JID.ToNonAD(), &waE2E.Message{
99+
ProtocolMessage: &waE2E.ProtocolMessage{
100+
Type: waE2E.ProtocolMessage_APP_STATE_SYNC_KEY_REQUEST.Enum(),
101+
AppStateSyncKeyRequest: &waE2E.AppStateSyncKeyRequest{
102+
KeyIDs: keyIDs,
103+
},
104+
},
105+
}, whatsmeow.SendRequestExtra{Peer: true, ID: msgID})
106+
if err != nil {
107+
log.Err(err).Msg("Failed to send hacky phone ping")
108+
} else {
109+
log.Debug().
110+
Str("message_id", msgID).
111+
Int64("message_ts", resp.Timestamp.Unix()).
112+
Msg("Sent hacky phone ping because phone has been offline for >10 days")
113+
meta.PhoneLastPinged = jsontime.U(resp.Timestamp)
114+
err = wa.UserLogin.Save(ctx)
115+
if err != nil {
116+
log.Err(err).Msg("Failed to save login metadata after sending hacky phone ping")
117+
}
118+
}
119+
}
120+
121+
// phoneSeen records a timestamp when the user's main device was seen online.
122+
// The stored timestamp can later be used to warn the user if the main device is offline for too long.
123+
func (wa *WhatsAppClient) phoneSeen(ts time.Time) {
124+
log := wa.UserLogin.Log.With().Str("action", "phone seen").Time("seen_at", ts).Logger()
125+
ctx := log.WithContext(context.Background())
126+
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
127+
if meta.PhoneLastSeen.Add(1 * time.Hour).After(ts) {
128+
// The last seen timestamp isn't going to be perfectly accurate in any case,
129+
// so don't spam the database with an update every time there's an event.
130+
return
131+
} else if !wa.PhoneRecentlySeen(false) {
132+
isConnected := wa.IsLoggedIn() && wa.Client.IsConnected()
133+
prevStateError := wa.UserLogin.BridgeState.GetPrev().Error
134+
if prevStateError == WAPhoneOffline && isConnected {
135+
log.Debug().Msg("Saw phone after current bridge state said it has been offline, switching state back to connected")
136+
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
137+
} else {
138+
log.Debug().
139+
Bool("is_connected", isConnected).
140+
Str("prev_error", string(prevStateError)).
141+
Msg("Saw phone after current bridge state said it has been offline, not sending new bridge state")
142+
}
143+
}
144+
meta.PhoneLastSeen = jsontime.U(ts)
145+
go func() {
146+
err := wa.UserLogin.Save(ctx)
147+
if err != nil {
148+
log.Err(err).Msg("Failed to save user after updating phone last seen")
149+
}
150+
}()
6151
}

0 commit comments

Comments
 (0)