mirror of
https://github.com/42wim/matterbridge.git
synced 2024-12-28 22:22:48 +01:00
533 lines
15 KiB
Go
533 lines
15 KiB
Go
|
package whatsapp
|
|||
|
|
|||
|
import (
|
|||
|
"crypto/hmac"
|
|||
|
"crypto/rand"
|
|||
|
"crypto/sha256"
|
|||
|
"encoding/base64"
|
|||
|
"encoding/json"
|
|||
|
"fmt"
|
|||
|
"strconv"
|
|||
|
"strings"
|
|||
|
"sync/atomic"
|
|||
|
"time"
|
|||
|
|
|||
|
"github.com/Rhymen/go-whatsapp/crypto/cbc"
|
|||
|
"github.com/Rhymen/go-whatsapp/crypto/curve25519"
|
|||
|
"github.com/Rhymen/go-whatsapp/crypto/hkdf"
|
|||
|
)
|
|||
|
|
|||
|
//represents the WhatsAppWeb client version
|
|||
|
var waVersion = []int{2, 2142, 12}
|
|||
|
|
|||
|
/*
|
|||
|
Session contains session individual information. To be able to resume the connection without scanning the qr code
|
|||
|
every time you should save the Session returned by Login and use RestoreWithSession the next time you want to login.
|
|||
|
Every successful created connection returns a new Session. The Session(ClientToken, ServerToken) is altered after
|
|||
|
every re-login and should be saved every time.
|
|||
|
*/
|
|||
|
type Session struct {
|
|||
|
ClientId string
|
|||
|
ClientToken string
|
|||
|
ServerToken string
|
|||
|
EncKey []byte
|
|||
|
MacKey []byte
|
|||
|
Wid string
|
|||
|
}
|
|||
|
|
|||
|
type Info struct {
|
|||
|
Battery int
|
|||
|
Platform string
|
|||
|
Connected bool
|
|||
|
Pushname string
|
|||
|
Wid string
|
|||
|
Lc string
|
|||
|
Phone *PhoneInfo
|
|||
|
Plugged bool
|
|||
|
Tos int
|
|||
|
Lg string
|
|||
|
Is24h bool
|
|||
|
}
|
|||
|
|
|||
|
type PhoneInfo struct {
|
|||
|
Mcc string
|
|||
|
Mnc string
|
|||
|
OsVersion string
|
|||
|
DeviceManufacturer string
|
|||
|
DeviceModel string
|
|||
|
OsBuildNumber string
|
|||
|
WaVersion string
|
|||
|
}
|
|||
|
|
|||
|
func newInfoFromReq(info map[string]interface{}) *Info {
|
|||
|
phoneInfo := info["phone"].(map[string]interface{})
|
|||
|
|
|||
|
ret := &Info{
|
|||
|
Battery: int(info["battery"].(float64)),
|
|||
|
Platform: info["platform"].(string),
|
|||
|
Connected: info["connected"].(bool),
|
|||
|
Pushname: info["pushname"].(string),
|
|||
|
Wid: info["wid"].(string),
|
|||
|
Lc: info["lc"].(string),
|
|||
|
Phone: &PhoneInfo{
|
|||
|
phoneInfo["mcc"].(string),
|
|||
|
phoneInfo["mnc"].(string),
|
|||
|
phoneInfo["os_version"].(string),
|
|||
|
phoneInfo["device_manufacturer"].(string),
|
|||
|
phoneInfo["device_model"].(string),
|
|||
|
phoneInfo["os_build_number"].(string),
|
|||
|
phoneInfo["wa_version"].(string),
|
|||
|
},
|
|||
|
Plugged: info["plugged"].(bool),
|
|||
|
Lg: info["lg"].(string),
|
|||
|
Tos: int(info["tos"].(float64)),
|
|||
|
}
|
|||
|
|
|||
|
if is24h, ok := info["is24h"]; ok {
|
|||
|
ret.Is24h = is24h.(bool)
|
|||
|
}
|
|||
|
|
|||
|
return ret
|
|||
|
}
|
|||
|
|
|||
|
/*
|
|||
|
CheckCurrentServerVersion is based on the login method logic in order to establish the websocket connection and get
|
|||
|
the current version from the server with the `admin init` command. This can be very useful for automations in which
|
|||
|
you need to quickly perceive new versions (mostly patches) and update your application so it suddenly stops working.
|
|||
|
*/
|
|||
|
func CheckCurrentServerVersion() ([]int, error) {
|
|||
|
wac, err := NewConn(5 * time.Second)
|
|||
|
if err != nil {
|
|||
|
return nil, fmt.Errorf("fail to create connection")
|
|||
|
}
|
|||
|
|
|||
|
clientId := make([]byte, 16)
|
|||
|
if _, err = rand.Read(clientId); err != nil {
|
|||
|
return nil, fmt.Errorf("error creating random ClientId: %v", err)
|
|||
|
}
|
|||
|
|
|||
|
b64ClientId := base64.StdEncoding.EncodeToString(clientId)
|
|||
|
login := []interface{}{"admin", "init", waVersion, []string{wac.longClientName, wac.shortClientName, wac.clientVersion}, b64ClientId, true}
|
|||
|
loginChan, err := wac.writeJson(login)
|
|||
|
if err != nil {
|
|||
|
return nil, fmt.Errorf("error writing login: %s", err.Error())
|
|||
|
}
|
|||
|
|
|||
|
// Retrieve an answer from the websocket
|
|||
|
var r string
|
|||
|
select {
|
|||
|
case r = <-loginChan:
|
|||
|
case <-time.After(wac.msgTimeout):
|
|||
|
return nil, fmt.Errorf("login connection timed out")
|
|||
|
}
|
|||
|
|
|||
|
var resp map[string]interface{}
|
|||
|
if err = json.Unmarshal([]byte(r), &resp); err != nil {
|
|||
|
return nil, fmt.Errorf("error decoding login: %s", err.Error())
|
|||
|
}
|
|||
|
|
|||
|
// Take the curr property as X.Y.Z and split it into as int slice
|
|||
|
curr := resp["curr"].(string)
|
|||
|
currArray := strings.Split(curr, ".")
|
|||
|
version := make([]int, len(currArray))
|
|||
|
for i := range version {
|
|||
|
version[i], _ = strconv.Atoi(currArray[i])
|
|||
|
}
|
|||
|
|
|||
|
return version, nil
|
|||
|
}
|
|||
|
|
|||
|
/*
|
|||
|
SetClientName sets the long and short client names that are sent to WhatsApp when logging in and displayed in the
|
|||
|
WhatsApp Web device list. As the values are only sent when logging in, changing them after logging in is not possible.
|
|||
|
*/
|
|||
|
func (wac *Conn) SetClientName(long, short string, version string) error {
|
|||
|
if wac.session != nil && (wac.session.EncKey != nil || wac.session.MacKey != nil) {
|
|||
|
return fmt.Errorf("cannot change client name after logging in")
|
|||
|
}
|
|||
|
wac.longClientName, wac.shortClientName, wac.clientVersion = long, short, version
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
/*
|
|||
|
SetClientVersion sets WhatsApp client version
|
|||
|
Default value is 0.4.2080
|
|||
|
*/
|
|||
|
func (wac *Conn) SetClientVersion(major int, minor int, patch int) {
|
|||
|
waVersion = []int{major, minor, patch}
|
|||
|
}
|
|||
|
|
|||
|
// GetClientVersion returns WhatsApp client version
|
|||
|
func (wac *Conn) GetClientVersion() []int {
|
|||
|
return waVersion
|
|||
|
}
|
|||
|
|
|||
|
/*
|
|||
|
Login is the function that creates a new whatsapp session and logs you in. If you do not want to scan the qr code
|
|||
|
every time, you should save the returned session and use RestoreWithSession the next time. Login takes a writable channel
|
|||
|
as an parameter. This channel is used to push the data represented by the qr code back to the user. The received data
|
|||
|
should be displayed as an qr code in a way you prefer. To print a qr code to console you can use:
|
|||
|
github.com/Baozisoftware/qrcode-terminal-go Example login procedure:
|
|||
|
wac, err := whatsapp.NewConn(5 * time.Second)
|
|||
|
if err != nil {
|
|||
|
panic(err)
|
|||
|
}
|
|||
|
|
|||
|
qr := make(chan string)
|
|||
|
go func() {
|
|||
|
terminal := qrcodeTerminal.New()
|
|||
|
terminal.Get(<-qr).Print()
|
|||
|
}()
|
|||
|
|
|||
|
session, err := wac.Login(qr)
|
|||
|
if err != nil {
|
|||
|
fmt.Fprintf(os.Stderr, "error during login: %v\n", err)
|
|||
|
}
|
|||
|
fmt.Printf("login successful, session: %v\n", session)
|
|||
|
*/
|
|||
|
func (wac *Conn) Login(qrChan chan<- string) (Session, error) {
|
|||
|
session := Session{}
|
|||
|
//Makes sure that only a single Login or Restore can happen at the same time
|
|||
|
if !atomic.CompareAndSwapUint32(&wac.sessionLock, 0, 1) {
|
|||
|
return session, ErrLoginInProgress
|
|||
|
}
|
|||
|
defer atomic.StoreUint32(&wac.sessionLock, 0)
|
|||
|
|
|||
|
if wac.loggedIn {
|
|||
|
return session, ErrAlreadyLoggedIn
|
|||
|
}
|
|||
|
|
|||
|
if err := wac.connect(); err != nil && err != ErrAlreadyConnected {
|
|||
|
return session, err
|
|||
|
}
|
|||
|
|
|||
|
//logged in?!?
|
|||
|
if wac.session != nil && (wac.session.EncKey != nil || wac.session.MacKey != nil) {
|
|||
|
return session, fmt.Errorf("already logged in")
|
|||
|
}
|
|||
|
|
|||
|
clientId := make([]byte, 16)
|
|||
|
_, err := rand.Read(clientId)
|
|||
|
if err != nil {
|
|||
|
return session, fmt.Errorf("error creating random ClientId: %v", err)
|
|||
|
}
|
|||
|
|
|||
|
session.ClientId = base64.StdEncoding.EncodeToString(clientId)
|
|||
|
login := []interface{}{"admin", "init", waVersion, []string{wac.longClientName, wac.shortClientName, wac.clientVersion}, session.ClientId, true}
|
|||
|
loginChan, err := wac.writeJson(login)
|
|||
|
if err != nil {
|
|||
|
return session, fmt.Errorf("error writing login: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
var r string
|
|||
|
select {
|
|||
|
case r = <-loginChan:
|
|||
|
case <-time.After(wac.msgTimeout):
|
|||
|
return session, fmt.Errorf("login connection timed out")
|
|||
|
}
|
|||
|
|
|||
|
var resp map[string]interface{}
|
|||
|
if err = json.Unmarshal([]byte(r), &resp); err != nil {
|
|||
|
return session, fmt.Errorf("error decoding login resp: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
var ref string
|
|||
|
if rref, ok := resp["ref"].(string); ok {
|
|||
|
ref = rref
|
|||
|
} else {
|
|||
|
return session, fmt.Errorf("error decoding login resp: invalid resp['ref']\n")
|
|||
|
}
|
|||
|
|
|||
|
priv, pub, err := curve25519.GenerateKey()
|
|||
|
if err != nil {
|
|||
|
return session, fmt.Errorf("error generating keys: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
//listener for Login response
|
|||
|
s1 := make(chan string, 1)
|
|||
|
wac.listener.Lock()
|
|||
|
wac.listener.m["s1"] = s1
|
|||
|
wac.listener.Unlock()
|
|||
|
|
|||
|
qrChan <- fmt.Sprintf("%v,%v,%v", ref, base64.StdEncoding.EncodeToString(pub[:]), session.ClientId)
|
|||
|
|
|||
|
var resp2 []interface{}
|
|||
|
select {
|
|||
|
case r1 := <-s1:
|
|||
|
wac.loginSessionLock.Lock()
|
|||
|
defer wac.loginSessionLock.Unlock()
|
|||
|
if err := json.Unmarshal([]byte(r1), &resp2); err != nil {
|
|||
|
return session, fmt.Errorf("error decoding qr code resp: %v", err)
|
|||
|
}
|
|||
|
case <-time.After(time.Duration(resp["ttl"].(float64)) * time.Millisecond):
|
|||
|
return session, fmt.Errorf("qr code scan timed out")
|
|||
|
}
|
|||
|
|
|||
|
info := resp2[1].(map[string]interface{})
|
|||
|
|
|||
|
wac.Info = newInfoFromReq(info)
|
|||
|
|
|||
|
session.ClientToken = info["clientToken"].(string)
|
|||
|
session.ServerToken = info["serverToken"].(string)
|
|||
|
session.Wid = info["wid"].(string)
|
|||
|
s := info["secret"].(string)
|
|||
|
decodedSecret, err := base64.StdEncoding.DecodeString(s)
|
|||
|
if err != nil {
|
|||
|
return session, fmt.Errorf("error decoding secret: %v", err)
|
|||
|
}
|
|||
|
|
|||
|
var pubKey [32]byte
|
|||
|
copy(pubKey[:], decodedSecret[:32])
|
|||
|
|
|||
|
sharedSecret := curve25519.GenerateSharedSecret(*priv, pubKey)
|
|||
|
|
|||
|
hash := sha256.New
|
|||
|
|
|||
|
nullKey := make([]byte, 32)
|
|||
|
h := hmac.New(hash, nullKey)
|
|||
|
h.Write(sharedSecret)
|
|||
|
|
|||
|
sharedSecretExtended, err := hkdf.Expand(h.Sum(nil), 80, "")
|
|||
|
if err != nil {
|
|||
|
return session, fmt.Errorf("hkdf error: %v", err)
|
|||
|
}
|
|||
|
|
|||
|
//login validation
|
|||
|
checkSecret := make([]byte, 112)
|
|||
|
copy(checkSecret[:32], decodedSecret[:32])
|
|||
|
copy(checkSecret[32:], decodedSecret[64:])
|
|||
|
h2 := hmac.New(hash, sharedSecretExtended[32:64])
|
|||
|
h2.Write(checkSecret)
|
|||
|
if !hmac.Equal(h2.Sum(nil), decodedSecret[32:64]) {
|
|||
|
return session, fmt.Errorf("abort login")
|
|||
|
}
|
|||
|
|
|||
|
keysEncrypted := make([]byte, 96)
|
|||
|
copy(keysEncrypted[:16], sharedSecretExtended[64:])
|
|||
|
copy(keysEncrypted[16:], decodedSecret[64:])
|
|||
|
|
|||
|
keyDecrypted, err := cbc.Decrypt(sharedSecretExtended[:32], nil, keysEncrypted)
|
|||
|
if err != nil {
|
|||
|
return session, fmt.Errorf("error decryptAes: %v", err)
|
|||
|
}
|
|||
|
|
|||
|
session.EncKey = keyDecrypted[:32]
|
|||
|
session.MacKey = keyDecrypted[32:64]
|
|||
|
wac.session = &session
|
|||
|
wac.loggedIn = true
|
|||
|
|
|||
|
return session, nil
|
|||
|
}
|
|||
|
|
|||
|
//TODO: GoDoc
|
|||
|
/*
|
|||
|
Basically the old RestoreSession functionality
|
|||
|
*/
|
|||
|
func (wac *Conn) RestoreWithSession(session Session) (_ Session, err error) {
|
|||
|
if wac.loggedIn {
|
|||
|
return Session{}, ErrAlreadyLoggedIn
|
|||
|
}
|
|||
|
old := wac.session
|
|||
|
defer func() {
|
|||
|
if err != nil {
|
|||
|
wac.session = old
|
|||
|
}
|
|||
|
}()
|
|||
|
wac.session = &session
|
|||
|
|
|||
|
if err = wac.Restore(); err != nil {
|
|||
|
wac.session = nil
|
|||
|
return Session{}, err
|
|||
|
}
|
|||
|
return *wac.session, nil
|
|||
|
}
|
|||
|
|
|||
|
/*//TODO: GoDoc
|
|||
|
RestoreWithSession is the function that restores a given session. It will try to reestablish the connection to the
|
|||
|
WhatsAppWeb servers with the provided session. If it succeeds it will return a new session. This new session has to be
|
|||
|
saved because the Client and Server-Token will change after every login. Logging in with old tokens is possible, but not
|
|||
|
suggested. If so, a challenge has to be resolved which is just another possible point of failure.
|
|||
|
*/
|
|||
|
func (wac *Conn) Restore() error {
|
|||
|
//Makes sure that only a single Login or Restore can happen at the same time
|
|||
|
if !atomic.CompareAndSwapUint32(&wac.sessionLock, 0, 1) {
|
|||
|
return ErrLoginInProgress
|
|||
|
}
|
|||
|
defer atomic.StoreUint32(&wac.sessionLock, 0)
|
|||
|
|
|||
|
if wac.session == nil {
|
|||
|
return ErrInvalidSession
|
|||
|
}
|
|||
|
|
|||
|
if err := wac.connect(); err != nil && err != ErrAlreadyConnected {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
if wac.loggedIn {
|
|||
|
return ErrAlreadyLoggedIn
|
|||
|
}
|
|||
|
|
|||
|
//listener for Conn or challenge; s1 is not allowed to drop
|
|||
|
s1 := make(chan string, 1)
|
|||
|
wac.listener.Lock()
|
|||
|
wac.listener.m["s1"] = s1
|
|||
|
wac.listener.Unlock()
|
|||
|
|
|||
|
//admin init
|
|||
|
init := []interface{}{"admin", "init", waVersion, []string{wac.longClientName, wac.shortClientName, wac.clientVersion}, wac.session.ClientId, true}
|
|||
|
initChan, err := wac.writeJson(init)
|
|||
|
if err != nil {
|
|||
|
return fmt.Errorf("error writing admin init: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
//admin login with takeover
|
|||
|
login := []interface{}{"admin", "login", wac.session.ClientToken, wac.session.ServerToken, wac.session.ClientId, "takeover"}
|
|||
|
loginChan, err := wac.writeJson(login)
|
|||
|
if err != nil {
|
|||
|
return fmt.Errorf("error writing admin login: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
select {
|
|||
|
case r := <-initChan:
|
|||
|
var resp map[string]interface{}
|
|||
|
if err = json.Unmarshal([]byte(r), &resp); err != nil {
|
|||
|
return fmt.Errorf("error decoding login connResp: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
if int(resp["status"].(float64)) != 200 {
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("init responded with %d", resp["status"])
|
|||
|
}
|
|||
|
case <-time.After(wac.msgTimeout):
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("restore session init timed out")
|
|||
|
}
|
|||
|
|
|||
|
//wait for s1
|
|||
|
var connResp []interface{}
|
|||
|
select {
|
|||
|
case r1 := <-s1:
|
|||
|
if err := json.Unmarshal([]byte(r1), &connResp); err != nil {
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("error decoding s1 message: %v\n", err)
|
|||
|
}
|
|||
|
case <-time.After(wac.msgTimeout):
|
|||
|
wac.timeTag = ""
|
|||
|
//check for an error message
|
|||
|
select {
|
|||
|
case r := <-loginChan:
|
|||
|
var resp map[string]interface{}
|
|||
|
if err = json.Unmarshal([]byte(r), &resp); err != nil {
|
|||
|
return fmt.Errorf("error decoding login connResp: %v\n", err)
|
|||
|
}
|
|||
|
if int(resp["status"].(float64)) != 200 {
|
|||
|
return fmt.Errorf("admin login responded with %d", int(resp["status"].(float64)))
|
|||
|
}
|
|||
|
default:
|
|||
|
// not even an error message – assume timeout
|
|||
|
return fmt.Errorf("restore session connection timed out")
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
//check if challenge is present
|
|||
|
if len(connResp) == 2 && connResp[0] == "Cmd" && connResp[1].(map[string]interface{})["type"] == "challenge" {
|
|||
|
s2 := make(chan string, 1)
|
|||
|
wac.listener.Lock()
|
|||
|
wac.listener.m["s2"] = s2
|
|||
|
wac.listener.Unlock()
|
|||
|
|
|||
|
if err := wac.resolveChallenge(connResp[1].(map[string]interface{})["challenge"].(string)); err != nil {
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("error resolving challenge: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
select {
|
|||
|
case r := <-s2:
|
|||
|
if err := json.Unmarshal([]byte(r), &connResp); err != nil {
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("error decoding s2 message: %v\n", err)
|
|||
|
}
|
|||
|
case <-time.After(wac.msgTimeout):
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("restore session challenge timed out")
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
//check for login 200 --> login success
|
|||
|
select {
|
|||
|
case r := <-loginChan:
|
|||
|
var resp map[string]interface{}
|
|||
|
if err = json.Unmarshal([]byte(r), &resp); err != nil {
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("error decoding login connResp: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
if int(resp["status"].(float64)) != 200 {
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("admin login responded with %d", resp["status"])
|
|||
|
}
|
|||
|
case <-time.After(wac.msgTimeout):
|
|||
|
wac.timeTag = ""
|
|||
|
return fmt.Errorf("restore session login timed out")
|
|||
|
}
|
|||
|
|
|||
|
info := connResp[1].(map[string]interface{})
|
|||
|
|
|||
|
wac.Info = newInfoFromReq(info)
|
|||
|
|
|||
|
//set new tokens
|
|||
|
wac.session.ClientToken = info["clientToken"].(string)
|
|||
|
wac.session.ServerToken = info["serverToken"].(string)
|
|||
|
wac.session.Wid = info["wid"].(string)
|
|||
|
wac.loggedIn = true
|
|||
|
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
func (wac *Conn) resolveChallenge(challenge string) error {
|
|||
|
decoded, err := base64.StdEncoding.DecodeString(challenge)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
h2 := hmac.New(sha256.New, wac.session.MacKey)
|
|||
|
h2.Write([]byte(decoded))
|
|||
|
|
|||
|
ch := []interface{}{"admin", "challenge", base64.StdEncoding.EncodeToString(h2.Sum(nil)), wac.session.ServerToken, wac.session.ClientId}
|
|||
|
challengeChan, err := wac.writeJson(ch)
|
|||
|
if err != nil {
|
|||
|
return fmt.Errorf("error writing challenge: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
select {
|
|||
|
case r := <-challengeChan:
|
|||
|
var resp map[string]interface{}
|
|||
|
if err := json.Unmarshal([]byte(r), &resp); err != nil {
|
|||
|
return fmt.Errorf("error decoding login resp: %v\n", err)
|
|||
|
}
|
|||
|
if int(resp["status"].(float64)) != 200 {
|
|||
|
return fmt.Errorf("challenge responded with %d\n", resp["status"])
|
|||
|
}
|
|||
|
case <-time.After(wac.msgTimeout):
|
|||
|
return fmt.Errorf("connection timed out")
|
|||
|
}
|
|||
|
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
/*
|
|||
|
Logout is the function to logout from a WhatsApp session. Logging out means invalidating the current session.
|
|||
|
The session can not be resumed and will disappear on your phone in the WhatsAppWeb client list.
|
|||
|
*/
|
|||
|
func (wac *Conn) Logout() error {
|
|||
|
login := []interface{}{"admin", "Conn", "disconnect"}
|
|||
|
_, err := wac.writeJson(login)
|
|||
|
if err != nil {
|
|||
|
return fmt.Errorf("error writing logout: %v\n", err)
|
|||
|
}
|
|||
|
|
|||
|
wac.loggedIn = false
|
|||
|
|
|||
|
return nil
|
|||
|
}
|