diff --git a/asmux.go b/asmux.go new file mode 100644 index 0000000..bdb3bcb --- /dev/null +++ b/asmux.go @@ -0,0 +1,154 @@ +// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// Copyright (C) 2020 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 . + +package main + +import ( + "errors" + "net/http" + "time" + + "github.com/Rhymen/go-whatsapp" + "maunium.net/go/mautrix/id" +) + +type AsmuxError string + +const ( + AsmuxWANotLoggedIn AsmuxError = "wa-not-logged-in" + AsmuxWANotConnected AsmuxError = "wa-not-connected" + AsmuxWAConnecting AsmuxError = "wa-connecting" + AsmuxWATimeout AsmuxError = "wa-timeout" + AsmuxWAPingFalse AsmuxError = "wa-ping-false" + AsmuxWAPingError AsmuxError = "wa-ping-error" +) + +var asmuxHumanErrors = map[AsmuxError]string{ + AsmuxWANotLoggedIn: "You're not logged into WhatsApp", + AsmuxWANotConnected: "You're not connected to WhatsApp", + AsmuxWAConnecting: "Trying to reconnect to WhatsApp. Please make sure WhatsApp is running on your phone and connected to the internet.", + AsmuxWATimeout: "WhatsApp on your phone is not responding. Please make sure it is running and connected to the internet.", + AsmuxWAPingFalse: "WhatsApp returned an error, reconnecting. Please make sure WhatsApp is running on your phone and connected to the internet.", + AsmuxWAPingError: "WhatsApp returned an unknown error", +} + +type AsmuxPong struct { + OK bool `json:"ok"` + Timestamp int64 `json:"timestamp"` + TTL int `json:"ttl"` + ErrorSource string `json:"error_source,omitempty"` + Error AsmuxError `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +func (pong *AsmuxPong) fill() { + pong.Timestamp = time.Now().Unix() + if !pong.OK { + pong.TTL = 300 + pong.ErrorSource = "bridge" + pong.Message = asmuxHumanErrors[pong.Error] + } else { + pong.TTL = 1800 + } +} + +func (pong *AsmuxPong) shouldDeduplicate(newPong *AsmuxPong) bool { + if pong == nil || pong.OK != newPong.OK || pong.Error != newPong.Error { + return false + } + return pong.Timestamp+int64(pong.TTL/10) > time.Now().Unix() +} + +func (user *User) setupAdminTestHooks() { + if !user.bridge.Config.Homeserver.Asmux { + return + } + user.Conn.AdminTestHook = func(err error) { + if errors.Is(err, whatsapp.ErrConnectionTimeout) { + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWATimeout}) + } else if errors.Is(err, whatsapp.ErrPingFalse) { + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWAPingFalse}) + } else if err == nil { + user.sendBridgeStatus(AsmuxPong{OK: true}) + } else { + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWAPingError}) + } + } + user.Conn.CountTimeoutHook = func() { + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWATimeout}) + } +} + +func (user *User) sendBridgeStatus(state AsmuxPong) { + if !user.bridge.Config.Homeserver.Asmux { + return + } + state.fill() + if user.prevBridgeStatus.shouldDeduplicate(&state) { + return + } + cli := user.bridge.AS.BotClient() + url := cli.BuildBaseURL("_matrix", "client", "unstable", "com.beeper.asmux", "pong") + _, err := cli.MakeRequest("POST", url, &state, nil) + if err != nil { + user.log.Warnln("Failed to update bridge state in asmux:", err) + } else { + user.prevBridgeStatus = &state + } +} + +func (prov *ProvisioningAPI) AsmuxPing(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 resp AsmuxPong + if user.Conn == nil { + if user.Session == nil { + resp.Error = AsmuxWANotLoggedIn + } else { + resp.Error = AsmuxWANotConnected + } + } else { + if user.Conn.IsConnected() && user.Conn.IsLoggedIn() { + user.log.Debugln("Pinging WhatsApp mobile due to asmux /ping API request") + err := user.Conn.AdminTestWithSuppress(true) + user.log.Debugln("Ping response:", err) + if err == whatsapp.ErrPingFalse { + user.log.Debugln("Forwarding ping false error from provisioning API to HandleError") + go user.HandleError(err) + resp.Error = AsmuxWAPingFalse + } else if errors.Is(err, whatsapp.ErrConnectionTimeout) { + user.Conn.CountTimeout() + resp.Error = AsmuxWATimeout + } else if err != nil { + resp.Error = AsmuxWAPingError + } else { + resp.OK = true + } + } else if user.Conn.IsLoginInProgress() { + resp.Error = AsmuxWAConnecting + } else if user.Conn.IsConnected() { + resp.Error = AsmuxWANotLoggedIn + } else { + resp.Error = AsmuxWANotConnected + } + } + resp.fill() + jsonResponse(w, http.StatusOK, &resp) + user.prevBridgeStatus = &resp +} diff --git a/commands.go b/commands.go index 3774b94..4f5f7ab 100644 --- a/commands.go +++ b/commands.go @@ -568,6 +568,7 @@ func (handler *CommandHandler) CommandDisconnect(ce *CommandEvent) { return } ce.User.bridge.Metrics.TrackConnectionState(ce.User.JID, false) + ce.User.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotConnected}) ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.") } diff --git a/go.mod b/go.mod index 6407855..78b904c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( gopkg.in/yaml.v2 v2.3.0 maunium.net/go/mauflag v1.0.0 maunium.net/go/maulogger/v2 v2.2.4 - maunium.net/go/mautrix v0.9.1-0.20210307225120-4e7cb8da4d83 + maunium.net/go/mautrix v0.9.2 ) -replace github.com/Rhymen/go-whatsapp => github.com/tulir/go-whatsapp v0.4.0-rc.4 +replace github.com/Rhymen/go-whatsapp => github.com/tulir/go-whatsapp v0.4.0 diff --git a/go.sum b/go.sum index b4fd8dd..86de914 100644 --- a/go.sum +++ b/go.sum @@ -315,6 +315,8 @@ github.com/tulir/go-whatsapp v0.4.0-rc.3.0.20210305145305-1afcd8642930 h1:9FMxSL github.com/tulir/go-whatsapp v0.4.0-rc.3.0.20210305145305-1afcd8642930/go.mod h1:rwwuTh1bKqhgrRvOBAr8hDqtuz8Cc1Quqw/0BeXb+/E= github.com/tulir/go-whatsapp v0.4.0-rc.4 h1:S0ZLzeZPYUj6fATACy7Kzok9TcnntLa1xDJKTBnN19k= github.com/tulir/go-whatsapp v0.4.0-rc.4/go.mod h1:rwwuTh1bKqhgrRvOBAr8hDqtuz8Cc1Quqw/0BeXb+/E= +github.com/tulir/go-whatsapp v0.4.0 h1:rvWQZkJrDSoO8IaltASUjYTXjPymByv45RPTL0ApcYQ= +github.com/tulir/go-whatsapp v0.4.0/go.mod h1:rwwuTh1bKqhgrRvOBAr8hDqtuz8Cc1Quqw/0BeXb+/E= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= @@ -500,5 +502,7 @@ maunium.net/go/mautrix v0.9.0 h1:+u2NDmNWUwOqUmlQgDiDo9gE0XLPqfB0UeZYwx9XghI= maunium.net/go/mautrix v0.9.0/go.mod h1:mckyHSKKyI0PQF2K9MgWMMDUWH1meCNggE28ILTLuMg= maunium.net/go/mautrix v0.9.1-0.20210307225120-4e7cb8da4d83 h1:rXZpKROiWoye9wmu0SOadsyaWOb6/wmPfZqSXCA8ipM= maunium.net/go/mautrix v0.9.1-0.20210307225120-4e7cb8da4d83/go.mod h1:mckyHSKKyI0PQF2K9MgWMMDUWH1meCNggE28ILTLuMg= +maunium.net/go/mautrix v0.9.2 h1:siyu2A4t9nao8i8azGz+UD1jx1r1u4kAvB6ugsH6PF8= +maunium.net/go/mautrix v0.9.2/go.mod h1:mckyHSKKyI0PQF2K9MgWMMDUWH1meCNggE28ILTLuMg= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/provisioning.go b/provisioning.go index 88199e5..f5e51cd 100644 --- a/provisioning.go +++ b/provisioning.go @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + package main import ( @@ -51,6 +52,7 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/delete_connection", prov.DeleteConnection).Methods(http.MethodPost) r.HandleFunc("/disconnect", prov.Disconnect).Methods(http.MethodPost) r.HandleFunc("/reconnect", prov.Reconnect).Methods(http.MethodPost) + prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.AsmuxPing).Methods(http.MethodPost) } type responseWrap struct { diff --git a/user.go b/user.go index 31193c2..192b470 100644 --- a/user.go +++ b/user.go @@ -76,6 +76,8 @@ type User struct { mgmtCreateLock sync.Mutex connLock sync.Mutex cancelReconnect func() + + prevBridgeStatus *AsmuxPong } func (bridge *Bridge) GetUserByMXID(userID id.UserID) *User { @@ -116,6 +118,7 @@ func (user *User) removeFromJIDMap() { } user.bridge.usersLock.Unlock() user.bridge.Metrics.TrackLoginState(user.JID, false) + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotLoggedIn}) } func (bridge *Bridge) GetAllUsers() []*User { @@ -249,6 +252,9 @@ func (user *User) Connect(evenIfNoSession bool) bool { return false } user.log.Debugln("Connecting to WhatsApp") + if user.Session != nil { + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWAConnecting}) + } timeout := time.Duration(user.bridge.Config.Bridge.ConnectionTimeout) if timeout == 0 { timeout = 20 @@ -261,6 +267,7 @@ func (user *User) Connect(evenIfNoSession bool) bool { Log: user.log.Sub("Conn"), Handler: []whatsapp.Handler{user}, }) + user.setupAdminTestHooks() user.connLock.Unlock() return user.RestoreSession() } @@ -278,6 +285,7 @@ func (user *User) DeleteConnection() { user.Conn.RemoveHandlers() user.Conn = nil user.bridge.Metrics.TrackConnectionState(user.JID, false) + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotConnected}) user.connLock.Unlock() } @@ -300,6 +308,7 @@ func (user *User) RestoreSession() bool { user.DeleteConnection() return false } else { + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotConnected}) user.sendMarkdownBridgeAlert("\u26a0 Failed to connect to WhatsApp. Make sure WhatsApp " + "on your phone is reachable and use `reconnect` to try connecting again.") } @@ -449,6 +458,7 @@ func (cl ChatList) Swap(i, j int) { } func (user *User) PostLogin() { + user.sendBridgeStatus(AsmuxPong{OK: true}) user.bridge.Metrics.TrackConnectionState(user.JID, true) user.bridge.Metrics.TrackLoginState(user.JID, true) user.bridge.Metrics.TrackBufferLength(user.MXID, 0) @@ -522,6 +532,7 @@ func (user *User) postConnPing() bool { if disconnectErr != nil { user.log.Warnln("Error while disconnecting after failed post-connection ping:", disconnectErr) } + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotConnected}) user.bridge.Metrics.TrackDisconnection(user.MXID) go func() { time.Sleep(1 * time.Second) @@ -831,6 +842,7 @@ func (user *User) HandleError(err error) { if closed.Code == 1000 && user.cleanDisconnection { user.cleanDisconnection = false if !user.bridge.Config.Bridge.AggressiveReconnect { + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotConnected}) user.bridge.Metrics.TrackConnectionState(user.JID, false) user.log.Infoln("Clean disconnection by server") return @@ -863,6 +875,7 @@ func (user *User) tryReconnect(msg string) { user.bridge.Metrics.TrackConnectionState(user.JID, false) if user.ConnectionErrors > user.bridge.Config.Bridge.MaxConnectionAttempts { user.sendMarkdownBridgeAlert("%s. Use the `reconnect` command to reconnect.", msg) + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotConnected}) return } if user.bridge.Config.Bridge.ReportConnectionRetry { @@ -888,6 +901,7 @@ func (user *User) tryReconnect(msg string) { return default: } + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWAConnecting}) err := user.Conn.Restore(true, ctx) if err == nil { user.ConnectionErrors = 0 @@ -910,6 +924,7 @@ func (user *User) tryReconnect(msg string) { user.DeleteConnection() user.sendMarkdownBridgeAlert("\u26a0 Failed to reconnect to WhatsApp: unpaired from phone. " + "To re-pair your phone, log in again.") + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotLoggedIn}) return } else if errors.Is(err, whatsapp.ErrAlreadyLoggedIn) { user.log.Warnln("Reconnection said we're already logged in, not trying anymore") @@ -930,6 +945,7 @@ func (user *User) tryReconnect(msg string) { } } + user.sendBridgeStatus(AsmuxPong{Error: AsmuxWANotConnected}) if user.bridge.Config.Bridge.ReportConnectionRetry { user.sendMarkdownBridgeAlert("%d reconnection attempts failed. Use the `reconnect` command to try to reconnect manually.", tries) } else {