Try to track when the phone is online and warn user if it's offline for too long

This commit is contained in:
Tulir Asokan 2022-01-25 14:26:24 +02:00
parent aacb1d57df
commit e8c77c7ec6
12 changed files with 154 additions and 42 deletions

View File

@ -47,17 +47,19 @@ const (
type BridgeErrorCode string
const (
WANotLoggedIn BridgeErrorCode = "wa-logged-out"
WALoggedOut BridgeErrorCode = "wa-logged-out"
WANotConnected BridgeErrorCode = "wa-not-connected"
WAConnecting BridgeErrorCode = "wa-connecting"
WAServerTimeout BridgeErrorCode = "wa-server-timeout"
WAPhoneOffline BridgeErrorCode = "wa-phone-offline"
)
var bridgeHumanErrors = map[BridgeErrorCode]string{
WANotLoggedIn: "You're not logged into WhatsApp",
WALoggedOut: "You were logged out from another device. Relogin to continue using the bridge.",
WANotConnected: "You're not connected to WhatsApp",
WAConnecting: "Reconnecting to WhatsApp...",
WAServerTimeout: "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.",
}
type BridgeState struct {
@ -172,6 +174,13 @@ func (user *User) sendBridgeState(state BridgeState) {
}
}
func (user *User) GetPrevBridgeState() BridgeState {
if user.prevBridgeStatus != nil {
return *user.prevBridgeStatus
}
return BridgeState{}
}
func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) {
if !prov.bridge.AS.CheckServerToken(w, r) {
return

View File

@ -75,6 +75,8 @@ type BridgeConfig struct {
DisappearingMessagesInGroups bool `yaml:"disappearing_messages_in_groups"`
DisableBridgeAlerts bool `yaml:"disable_bridge_alerts"`
CommandPrefix string `yaml:"command_prefix"`
ManagementRoomText struct {

View File

@ -103,6 +103,7 @@ func (helper *UpgradeHelper) doUpgrade() {
helper.Copy(Str, "bridge", "command_prefix")
helper.Copy(Bool, "bridge", "federate_rooms")
helper.Copy(Bool, "bridge", "disappearing_messages_in_groups")
helper.Copy(Bool, "bridge", "disable_bridge_alerts")
helper.Copy(Str, "bridge", "management_room_text", "welcome")
helper.Copy(Str, "bridge", "management_room_text", "welcome_connected")
helper.Copy(Str, "bridge", "management_room_text", "welcome_unconnected")

View File

@ -0,0 +1,10 @@
package upgrades
import "database/sql"
func init() {
upgrades[35] = upgrade{"Store approximate last seen timestamp of the main device", func(tx *sql.Tx, ctx context) error {
_, err := tx.Exec(`ALTER TABLE "user" ADD COLUMN phone_last_seen BIGINT`)
return err
}}
}

View File

@ -40,7 +40,7 @@ type upgrade struct {
fn upgradeFunc
}
const NumberOfUpgrades = 35
const NumberOfUpgrades = 36
var upgrades [NumberOfUpgrades]upgrade

View File

@ -44,7 +44,7 @@ func (uq *UserQuery) New() *User {
}
func (uq *UserQuery) GetAll() (users []*User) {
rows, err := uq.db.Query(`SELECT mxid, username, agent, device, management_room, space_room FROM "user"`)
rows, err := uq.db.Query(`SELECT mxid, username, agent, device, management_room, space_room, phone_last_seen FROM "user"`)
if err != nil || rows == nil {
return nil
}
@ -56,7 +56,7 @@ func (uq *UserQuery) GetAll() (users []*User) {
}
func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
row := uq.db.QueryRow(`SELECT mxid, username, agent, device, management_room, space_room FROM "user" WHERE mxid=$1`, userID)
row := uq.db.QueryRow(`SELECT mxid, username, agent, device, management_room, space_room, phone_last_seen FROM "user" WHERE mxid=$1`, userID)
if row == nil {
return nil
}
@ -64,7 +64,7 @@ func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
}
func (uq *UserQuery) GetByUsername(username string) *User {
row := uq.db.QueryRow(`SELECT mxid, username, agent, device, management_room, space_room FROM "user" WHERE username=$1`, username)
row := uq.db.QueryRow(`SELECT mxid, username, agent, device, management_room, space_room, phone_last_seen FROM "user" WHERE username=$1`, username)
if row == nil {
return nil
}
@ -79,6 +79,7 @@ type User struct {
JID types.JID
ManagementRoom id.RoomID
SpaceRoom id.RoomID
PhoneLastSeen time.Time
lastReadCache map[PortalKey]time.Time
lastReadCacheLock sync.Mutex
@ -89,7 +90,8 @@ type User struct {
func (user *User) Scan(row Scannable) *User {
var username sql.NullString
var device, agent sql.NullByte
err := row.Scan(&user.MXID, &username, &agent, &device, &user.ManagementRoom, &user.SpaceRoom)
var phoneLastSeen sql.NullInt64
err := row.Scan(&user.MXID, &username, &agent, &device, &user.ManagementRoom, &user.SpaceRoom, &phoneLastSeen)
if err != nil {
if err != sql.ErrNoRows {
user.log.Errorln("Database scan failed:", err)
@ -99,6 +101,9 @@ func (user *User) Scan(row Scannable) *User {
if len(username.String) > 0 {
user.JID = types.NewADJID(username.String, agent.Byte, device.Byte)
}
if phoneLastSeen.Valid {
user.PhoneLastSeen = time.Unix(phoneLastSeen.Int64, 0)
}
return user
}
@ -123,17 +128,25 @@ func (user *User) devicePtr() *uint8 {
return nil
}
func (user *User) phoneLastSeenPtr() *int64 {
if user.PhoneLastSeen.IsZero() {
return nil
}
ts := user.PhoneLastSeen.Unix()
return &ts
}
func (user *User) Insert() {
_, err := user.db.Exec(`INSERT INTO "user" (mxid, username, agent, device, management_room, space_room) VALUES ($1, $2, $3, $4, $5, $6)`,
user.MXID, user.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom, user.SpaceRoom)
_, err := user.db.Exec(`INSERT INTO "user" (mxid, username, agent, device, management_room, space_room, phone_last_seen) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
user.MXID, user.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom, user.SpaceRoom, user.phoneLastSeenPtr())
if err != nil {
user.log.Warnfln("Failed to insert %s: %v", user.MXID, err)
}
}
func (user *User) Update() {
_, err := user.db.Exec(`UPDATE "user" SET username=$1, agent=$2, device=$3, management_room=$4, space_room=$5 WHERE mxid=$6`,
user.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom, user.SpaceRoom, user.MXID)
_, err := user.db.Exec(`UPDATE "user" SET username=$1, agent=$2, device=$3, management_room=$4, space_room=$5, phone_last_seen=$6 WHERE mxid=$7`,
user.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom, user.SpaceRoom, user.phoneLastSeenPtr(), user.MXID)
if err != nil {
user.log.Warnfln("Failed to update %s: %v", user.MXID, err)
}

View File

@ -50,13 +50,10 @@ func (portal *Portal) ScheduleDisappearing() {
}
}
func (bridge *Bridge) DisappearingLoop() {
for {
for _, msg := range bridge.DB.DisappearingMessage.GetUpcomingScheduled(1 * time.Hour) {
portal := bridge.GetPortalByMXID(msg.RoomID)
go portal.sleepAndDelete(msg)
}
time.Sleep(1 * time.Hour)
func (bridge *Bridge) SleepAndDeleteUpcoming() {
for _, msg := range bridge.DB.DisappearingMessage.GetUpcomingScheduled(1 * time.Hour) {
portal := bridge.GetPortalByMXID(msg.RoomID)
go portal.sleepAndDelete(msg)
}
}

View File

@ -191,6 +191,9 @@ bridge:
# the messages will be determined by the first user to read the message, rather than individually.
# If the bridge only has a single user, this can be turned on safely.
disappearing_messages_in_groups: false
# 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
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!wa"

8
go.mod
View File

@ -9,13 +9,13 @@ require (
github.com/mattn/go-sqlite3 v1.14.10
github.com/prometheus/client_golang v1.11.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
go.mau.fi/whatsmeow v0.0.0-20220111203410-b078a9e90863
go.mau.fi/whatsmeow v0.0.0-20220124150706-afc33ee3c21a
golang.org/x/image v0.0.0-20211028202545-6944b10bf410
google.golang.org/protobuf v1.27.1
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
maunium.net/go/mauflag v1.0.0
maunium.net/go/maulogger/v2 v2.3.2
maunium.net/go/mautrix v0.10.11-0.20220117200125-6d9d537973fa
maunium.net/go/mautrix v0.10.11-0.20220118151622-7cc9a5066c70
)
require (
@ -29,10 +29,10 @@ require (
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/gjson v1.10.2 // indirect
github.com/tidwall/gjson v1.13.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.3 // indirect
github.com/tidwall/sjson v1.2.4 // indirect
go.mau.fi/libsignal v0.0.0-20211109153248-a67163214910 // indirect
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect

17
go.sum
View File

@ -129,18 +129,19 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.10.2 h1:APbLGOM0rrEkd8WBw9C24nllro4ajFuJu0Sc9hRz8Bo=
github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.13.0 h1:3TFY9yxOQShrvmjdM76K+jc66zJeT6D3/VFFYCGQf7M=
github.com/tidwall/gjson v1.13.0/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.3 h1:5+deguEhHSEjmuICXZ21uSSsXotWMA0orU783+Z7Cp8=
github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc=
github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM=
go.mau.fi/libsignal v0.0.0-20211109153248-a67163214910 h1:9FFhG0OmkuMau5UEaTgiUQ+7cSbtbOQ7hiWKdN8OI3I=
go.mau.fi/libsignal v0.0.0-20211109153248-a67163214910/go.mod h1:AufGrvVh+00Nc07Jm4hTquh7yleZyn20tKJI2wCPAKg=
go.mau.fi/whatsmeow v0.0.0-20220111203410-b078a9e90863 h1:5xGt9ghwG3XvlCAnq1WJuJ4mdOR6u/Ho5oYR0Ql9uFw=
go.mau.fi/whatsmeow v0.0.0-20220111203410-b078a9e90863/go.mod h1:8jUjOAi3xtGubxcZgG8uSHpAdyQXBRbWAfxkctX/4y4=
go.mau.fi/whatsmeow v0.0.0-20220124150706-afc33ee3c21a h1:e8aExGixi/O+kveh6S3wgydk9ogU5+gx0NqOmqWMapM=
go.mau.fi/whatsmeow v0.0.0-20220124150706-afc33ee3c21a/go.mod h1:8jUjOAi3xtGubxcZgG8uSHpAdyQXBRbWAfxkctX/4y4=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -222,5 +223,5 @@ 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.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.10.11-0.20220117200125-6d9d537973fa h1:PA9cvjbiV2tqd5qgrnk42G4HfSLVWWqs27dKKvG1tCQ=
maunium.net/go/mautrix v0.10.11-0.20220117200125-6d9d537973fa/go.mod h1:4XljZZGZiIlpfbQ+Tt2ykjapskJ8a7Z2i9y/+YaceF8=
maunium.net/go/mautrix v0.10.11-0.20220118151622-7cc9a5066c70 h1:T/NnQ9DC/nSX+4wnpyl8tf+c8XjRxt7A3RAYfdAtevA=
maunium.net/go/mautrix v0.10.11-0.20220118151622-7cc9a5066c70/go.mod h1:lm06wYU/IcPcdicMNrG9wj0t3xhqYpEA1k+4G9EGZwc=

20
main.go
View File

@ -333,10 +333,28 @@ func (bridge *Bridge) Start() {
if bridge.Config.Bridge.ResendBridgeInfo {
go bridge.ResendBridgeInfo()
}
go bridge.DisappearingLoop()
go bridge.Loop()
bridge.AS.Ready = true
}
func (bridge *Bridge) Loop() {
for {
bridge.SleepAndDeleteUpcoming()
time.Sleep(1 * time.Hour)
bridge.WarnUsersAboutDisconnection()
}
}
func (bridge *Bridge) WarnUsersAboutDisconnection() {
bridge.usersLock.Lock()
for _, user := range bridge.usersByUsername {
if user.IsConnected() && !user.PhoneRecentlySeen() {
go user.sendPhoneOfflineWarning()
}
}
bridge.usersLock.Unlock()
}
func (bridge *Bridge) ResendBridgeInfo() {
if *dontSaveConfig {
bridge.Log.Warnln("Not setting resend_bridge_info to false in config due to --no-update flag")

80
user.go
View File

@ -21,6 +21,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
@ -66,7 +67,8 @@ type User struct {
prevBridgeStatus *BridgeState
lastPresence types.Presence
spaceMembershipChecked bool
spaceMembershipChecked bool
lastPhoneOfflineWarning time.Time
}
func (bridge *Bridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User {
@ -119,7 +121,7 @@ func (user *User) removeFromJIDMap(state BridgeStateEvent) {
}
user.bridge.usersLock.Unlock()
user.bridge.Metrics.TrackLoginState(user.JID, false)
user.sendBridgeState(BridgeState{StateEvent: state, Error: WANotLoggedIn})
user.sendBridgeState(BridgeState{StateEvent: state})
}
func (bridge *Bridge) GetAllUsers() []*User {
@ -425,15 +427,10 @@ func (user *User) tryAutomaticDoublePuppeting() {
user.log.Infoln("Successfully automatically enabled custom puppet")
}
func (user *User) sendBridgeNotice(formatString string, args ...interface{}) {
notice := fmt.Sprintf(formatString, args...)
_, err := user.bridge.Bot.SendNotice(user.GetManagementRoom(), notice)
if err != nil {
user.log.Warnf("Failed to send bridge notice \"%s\": %v", notice, err)
}
}
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)
@ -465,6 +462,49 @@ func (user *User) handleCallStart(sender types.JID, id, callType string, ts time
}
}
const PhoneDisconnectWarningTime = 12 * 24 * time.Hour // 12 days
func (user *User) PhoneRecentlySeen() bool {
return user.PhoneLastSeen.IsZero() || user.PhoneLastSeen.Add(PhoneDisconnectWarningTime).After(time.Now())
}
// phoneSeen records a timestamp when the user's main device was seen online.
// The stored timestamp can later be used to warn the user if the main device is offline for too long.
func (user *User) phoneSeen(ts time.Time) {
if user.PhoneLastSeen.Add(1 * time.Hour).After(ts) {
// The last seen timestamp isn't going to be perfectly accurate in any case,
// so don't spam the database with an update every time there's an event.
return
} else if !user.PhoneRecentlySeen() && user.GetPrevBridgeState().Error == WAPhoneOffline && user.IsConnected() {
user.log.Debugfln("Saw phone after current bridge state said it has been offline, switching state back to connected")
go user.sendBridgeState(BridgeState{StateEvent: StateConnected})
}
user.PhoneLastSeen = ts
go user.Update()
}
func formatDisconnectTime(dur time.Duration) string {
days := int(math.Floor(dur.Hours() / 24))
hours := int(dur.Hours()) % 24
if hours == 0 {
return fmt.Sprintf("%d days", days)
} else if hours == 1 {
return fmt.Sprintf("%d days and 1 hour", days)
} else {
return fmt.Sprintf("%d days and %d hours", days, hours)
}
}
func (user *User) sendPhoneOfflineWarning() {
if user.lastPhoneOfflineWarning.Add(12 * time.Hour).After(time.Now()) {
// Don't spam the warning too much
return
}
user.lastPhoneOfflineWarning = time.Now()
timeSinceSeen := time.Now().Sub(user.PhoneLastSeen)
user.sendMarkdownBridgeAlert("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.", formatDisconnectTime(timeSinceSeen))
}
func (user *User) HandleEvent(event interface{}) {
switch v := event.(type) {
case *events.LoggedOut:
@ -484,6 +524,20 @@ func (user *User) HandleEvent(event interface{}) {
}()
}
go user.tryAutomaticDoublePuppeting()
case *events.OfflineSyncPreview:
user.log.Infofln("Server says it's going to send %d messages and %d receipts that were missed during downtime", v.Messages, v.Receipts)
go user.sendBridgeState(BridgeState{
StateEvent: StateBackfilling,
Message: fmt.Sprintf("backfilling %d messages and %d receipts", v.Messages, v.Receipts),
})
case *events.OfflineSyncCompleted:
if !user.PhoneRecentlySeen() {
user.log.Infofln("Offline sync completed, but phone last seen date is still %s - sending phone offline bridge status", user.PhoneLastSeen)
go user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WAPhoneOffline})
} else if user.GetPrevBridgeState().StateEvent == StateBackfilling {
user.log.Infoln("Offline sync completed")
go user.sendBridgeState(BridgeState{StateEvent: StateConnected})
}
case *events.AppStateSyncComplete:
if len(user.Client.Store.PushName) > 0 && v.Name == appstate.WAPatchCriticalBlock {
err := user.Client.SendPresence(user.lastPresence)
@ -506,6 +560,7 @@ func (user *User) HandleEvent(event interface{}) {
user.log.Warnln("Failed to send presence after push name update:", err)
}
case *events.PairSuccess:
user.PhoneLastSeen = time.Now()
user.Session = user.Client.Store
user.JID = v.ID
user.addToJIDMap()
@ -527,6 +582,9 @@ func (user *User) HandleEvent(event interface{}) {
case *events.Picture:
go user.handlePictureUpdate(v)
case *events.Receipt:
if v.IsFromMe && v.Sender.Device == 0 {
user.phoneSeen(v.Timestamp)
}
go user.handleReceipt(v)
case *events.ChatPresence:
go user.handleChatPresence(v)
@ -752,7 +810,7 @@ func (user *User) UpdateDirectChats(chats map[id.UserID][]id.RoomID) {
}
func (user *User) handleLoggedOut(onConnect bool) {
user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotLoggedIn})
user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WALoggedOut})
user.JID = types.EmptyJID
user.Update()
if onConnect {