forked from MirrorHub/mautrix-whatsapp
Add basic end-to-bridge encryption support
Still missing persisting sync tokens and crypto state in DB
This commit is contained in:
parent
edd91510f1
commit
baae66ed04
12 changed files with 460 additions and 38 deletions
42
commands.go
42
commands.go
|
@ -18,6 +18,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Rhymen/go-whatsapp"
|
"github.com/Rhymen/go-whatsapp"
|
||||||
|
@ -118,6 +119,8 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
|
||||||
handler.CommandDeleteAllPortals(ce)
|
handler.CommandDeleteAllPortals(ce)
|
||||||
case "dev-test":
|
case "dev-test":
|
||||||
handler.CommandDevTest(ce)
|
handler.CommandDevTest(ce)
|
||||||
|
case "set-pl":
|
||||||
|
handler.CommandSetPowerLevel(ce)
|
||||||
case "login-matrix", "logout", "sync", "list", "open", "pm":
|
case "login-matrix", "logout", "sync", "list", "open", "pm":
|
||||||
if !ce.User.HasSession() {
|
if !ce.User.HasSession() {
|
||||||
ce.Reply("You are not logged in. Use the `login` command to log into WhatsApp.")
|
ce.Reply("You are not logged in. Use the `login` command to log into WhatsApp.")
|
||||||
|
@ -169,6 +172,45 @@ func (handler *CommandHandler) CommandDevTest(ce *CommandEvent) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *CommandHandler) CommandSetPowerLevel(ce *CommandEvent) {
|
||||||
|
portal := ce.Bridge.GetPortalByMXID(ce.RoomID)
|
||||||
|
if portal == nil {
|
||||||
|
ce.Reply("Not a portal room")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var level int
|
||||||
|
var userID id.UserID
|
||||||
|
var err error
|
||||||
|
if len(ce.Args) == 1 {
|
||||||
|
level, err = strconv.Atoi(ce.Args[0])
|
||||||
|
if err != nil {
|
||||||
|
ce.Reply("Invalid power level \"%s\"", ce.Args[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID = ce.User.MXID
|
||||||
|
} else if len(ce.Args) == 2 {
|
||||||
|
userID = id.UserID(ce.Args[0])
|
||||||
|
_, _, err := userID.Parse()
|
||||||
|
if err != nil {
|
||||||
|
ce.Reply("Invalid user ID \"%s\"", ce.Args[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
level, err = strconv.Atoi(ce.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
ce.Reply("Invalid power level \"%s\"", ce.Args[1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ce.Reply("**Usage:** `set-pl [user] <level>`")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
intent := portal.MainIntent()
|
||||||
|
_, err = intent.SetPowerLevel(ce.RoomID, userID, level)
|
||||||
|
if err != nil {
|
||||||
|
ce.Reply("Failed to set power levels: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cmdLoginHelp = `login - Authenticate this Bridge as WhatsApp Web Client`
|
const cmdLoginHelp = `login - Authenticate this Bridge as WhatsApp Web Client`
|
||||||
|
|
||||||
// CommandLogin handles login command
|
// CommandLogin handles login command
|
||||||
|
|
|
@ -64,6 +64,11 @@ type BridgeConfig struct {
|
||||||
|
|
||||||
CommandPrefix string `yaml:"command_prefix"`
|
CommandPrefix string `yaml:"command_prefix"`
|
||||||
|
|
||||||
|
Encryption struct {
|
||||||
|
Allow bool `yaml:"allow"`
|
||||||
|
Default bool `yaml:"default"`
|
||||||
|
} `yaml:"encryption"`
|
||||||
|
|
||||||
Permissions PermissionConfig `yaml:"permissions"`
|
Permissions PermissionConfig `yaml:"permissions"`
|
||||||
|
|
||||||
Relaybot RelaybotConfig `yaml:"relaybot"`
|
Relaybot RelaybotConfig `yaml:"relaybot"`
|
||||||
|
|
219
crypto.go
Normal file
219
crypto.go
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// +build cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/crypto"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
var levelTrace = maulogger.Level{
|
||||||
|
Name: "Trace",
|
||||||
|
Severity: -10,
|
||||||
|
Color: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
type CryptoHelper struct {
|
||||||
|
bridge *Bridge
|
||||||
|
client *mautrix.Client
|
||||||
|
mach *crypto.OlmMachine
|
||||||
|
log maulogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bridge *Bridge) initCrypto() error {
|
||||||
|
if !bridge.Config.Bridge.Encryption.Allow {
|
||||||
|
bridge.Log.Debugln("Bridge built with end-to-bridge encryption, but disabled in config")
|
||||||
|
return nil
|
||||||
|
} else if bridge.Config.Bridge.LoginSharedSecret == "" {
|
||||||
|
bridge.Log.Warnln("End-to-bridge encryption enabled, but login_shared_secret not set")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bridge.Log.Debugln("Initializing end-to-bridge encryption...")
|
||||||
|
client, err := bridge.loginBot()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// TODO put this in the database
|
||||||
|
cryptoStore, err := crypto.NewGobStore("crypto.gob")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log := bridge.Log.Sub("Crypto")
|
||||||
|
logger := &cryptoLogger{log}
|
||||||
|
stateStore := &cryptoStateStore{bridge}
|
||||||
|
helper := &CryptoHelper{
|
||||||
|
bridge: bridge,
|
||||||
|
client: client,
|
||||||
|
log: log.Sub("Helper"),
|
||||||
|
mach: crypto.NewOlmMachine(client, logger, cryptoStore, stateStore),
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Logger = logger.int.Sub("Bot")
|
||||||
|
client.Syncer = &cryptoSyncer{helper.mach}
|
||||||
|
// TODO put this in the database too
|
||||||
|
client.Store = mautrix.NewInMemoryStore()
|
||||||
|
|
||||||
|
err = helper.mach.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge.Crypto = helper
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (helper *CryptoHelper) Start() {
|
||||||
|
helper.log.Debugln("Starting syncer for receiving to-device messages")
|
||||||
|
err := helper.client.Sync()
|
||||||
|
if err != nil {
|
||||||
|
helper.log.Errorln("Fatal error syncing:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (helper *CryptoHelper) Stop() {
|
||||||
|
helper.client.StopSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bridge *Bridge) loginBot() (*mautrix.Client, error) {
|
||||||
|
mac := hmac.New(sha512.New, []byte(bridge.Config.Bridge.LoginSharedSecret))
|
||||||
|
mac.Write([]byte(bridge.AS.BotMXID()))
|
||||||
|
resp, err := bridge.AS.BotClient().Login(&mautrix.ReqLogin{
|
||||||
|
Type: "m.login.password",
|
||||||
|
Identifier: mautrix.UserIdentifier{Type: "m.id.user", User: string(bridge.AS.BotMXID())},
|
||||||
|
Password: hex.EncodeToString(mac.Sum(nil)),
|
||||||
|
DeviceID: "WhatsApp Bridge",
|
||||||
|
InitialDeviceDisplayName: "WhatsApp Bridge",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client, err := mautrix.NewClient(bridge.AS.HomeserverURL, bridge.AS.BotMXID(), resp.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client.DeviceID = "WhatsApp Bridge"
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (helper *CryptoHelper) Decrypt(evt *event.Event) (*event.Event, error) {
|
||||||
|
return helper.mach.DecryptMegolmEvent(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (helper *CryptoHelper) Encrypt(roomID id.RoomID, evtType event.Type, content event.Content) (*event.EncryptedEventContent, error) {
|
||||||
|
encrypted, err := helper.mach.EncryptMegolmEvent(roomID, evtType, content)
|
||||||
|
if err != nil {
|
||||||
|
if err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
helper.log.Debugfln("Got %v while encrypting event for %s, sharing group session and trying again...", err, roomID)
|
||||||
|
users, err := helper.bridge.StateStore.GetRoomMemberList(roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to get room member list")
|
||||||
|
}
|
||||||
|
err = helper.mach.ShareGroupSession(roomID, users)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to share group session")
|
||||||
|
}
|
||||||
|
encrypted, err = helper.mach.EncryptMegolmEvent(roomID, evtType, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to encrypt event after re-sharing group session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (helper *CryptoHelper) HandleMemberEvent(evt *event.Event) {
|
||||||
|
helper.mach.HandleMemberEvent(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cryptoSyncer struct {
|
||||||
|
*crypto.OlmMachine
|
||||||
|
}
|
||||||
|
|
||||||
|
func (syncer *cryptoSyncer) ProcessResponse(resp *mautrix.RespSync, since string) error {
|
||||||
|
syncer.ProcessSyncResponse(resp, since)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
|
||||||
|
syncer.Log.Error("Error /syncing, waiting 10 seconds: %v", err)
|
||||||
|
return 10 * time.Second, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
|
||||||
|
everything := []event.Type{{Type: "*"}}
|
||||||
|
return &mautrix.Filter{
|
||||||
|
Presence: mautrix.FilterPart{NotTypes: everything},
|
||||||
|
AccountData: mautrix.FilterPart{NotTypes: everything},
|
||||||
|
Room: mautrix.RoomFilter{
|
||||||
|
IncludeLeave: false,
|
||||||
|
Ephemeral: mautrix.FilterPart{NotTypes: everything},
|
||||||
|
AccountData: mautrix.FilterPart{NotTypes: everything},
|
||||||
|
State: mautrix.FilterPart{NotTypes: everything},
|
||||||
|
Timeline: mautrix.FilterPart{NotTypes: everything},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cryptoLogger struct {
|
||||||
|
int maulogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoLogger) Error(message string, args ...interface{}) {
|
||||||
|
c.int.Errorfln(message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoLogger) Warn(message string, args ...interface{}) {
|
||||||
|
c.int.Warnfln(message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoLogger) Debug(message string, args ...interface{}) {
|
||||||
|
c.int.Debugfln(message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoLogger) Trace(message string, args ...interface{}) {
|
||||||
|
c.int.Logfln(levelTrace, message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cryptoStateStore struct {
|
||||||
|
bridge *Bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoStateStore) IsEncrypted(id id.RoomID) bool {
|
||||||
|
portal := c.bridge.GetPortalByMXID(id)
|
||||||
|
if portal != nil {
|
||||||
|
return portal.Encrypted
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoStateStore) FindSharedRooms(id id.UserID) []id.RoomID {
|
||||||
|
return c.bridge.StateStore.FindSharedRooms(id)
|
||||||
|
}
|
|
@ -22,8 +22,9 @@ import (
|
||||||
|
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/types"
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix-whatsapp/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PortalKey struct {
|
type PortalKey struct {
|
||||||
|
@ -114,11 +115,12 @@ type Portal struct {
|
||||||
Topic string
|
Topic string
|
||||||
Avatar string
|
Avatar string
|
||||||
AvatarURL id.ContentURI
|
AvatarURL id.ContentURI
|
||||||
|
Encrypted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (portal *Portal) Scan(row Scannable) *Portal {
|
func (portal *Portal) Scan(row Scannable) *Portal {
|
||||||
var mxid, avatarURL sql.NullString
|
var mxid, avatarURL sql.NullString
|
||||||
err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.Topic, &portal.Avatar, &avatarURL)
|
err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.Topic, &portal.Avatar, &avatarURL, &portal.Encrypted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != sql.ErrNoRows {
|
if err != sql.ErrNoRows {
|
||||||
portal.log.Errorln("Database scan failed:", err)
|
portal.log.Errorln("Database scan failed:", err)
|
||||||
|
@ -138,8 +140,8 @@ func (portal *Portal) mxidPtr() *id.RoomID {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (portal *Portal) Insert() {
|
func (portal *Portal) Insert() {
|
||||||
_, err := portal.db.Exec("INSERT INTO portal VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
_, err := portal.db.Exec("INSERT INTO portal (jid, receiver, mxid, name, topic, avatar, avatar_url, encrypted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||||
portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String())
|
portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err)
|
portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err)
|
||||||
}
|
}
|
||||||
|
@ -150,8 +152,8 @@ func (portal *Portal) Update() {
|
||||||
if len(portal.MXID) > 0 {
|
if len(portal.MXID) > 0 {
|
||||||
mxid = &portal.MXID
|
mxid = &portal.MXID
|
||||||
}
|
}
|
||||||
_, err := portal.db.Exec("UPDATE portal SET mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5 WHERE jid=$6 AND receiver=$7",
|
_, err := portal.db.Exec("UPDATE portal SET mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5, encrypted=$6 WHERE jid=$7 AND receiver=$8",
|
||||||
mxid, portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Key.JID, portal.Key.Receiver)
|
mxid, portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.Key.JID, portal.Key.Receiver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
portal.log.Warnfln("Failed to update %s: %v", portal.Key, err)
|
portal.log.Warnfln("Failed to update %s: %v", portal.Key, err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,6 +90,24 @@ func (store *SQLStateStore) GetRoomMembers(roomID id.RoomID) map[id.UserID]*even
|
||||||
return members
|
return members
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *SQLStateStore) GetRoomMemberList(roomID id.RoomID) (members []id.UserID, err error) {
|
||||||
|
var rows *sql.Rows
|
||||||
|
rows, err = store.db.Query("SELECT user_id FROM mx_user_profile WHERE room_id=$1", roomID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var userID id.UserID
|
||||||
|
err := rows.Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
store.log.Warnfln("Failed to scan member in %s: %v", roomID, err)
|
||||||
|
} else {
|
||||||
|
members = append(members, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (store *SQLStateStore) GetMembership(roomID id.RoomID, userID id.UserID) event.Membership {
|
func (store *SQLStateStore) GetMembership(roomID id.RoomID, userID id.UserID) event.Membership {
|
||||||
row := store.db.QueryRow("SELECT membership FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID)
|
row := store.db.QueryRow("SELECT membership FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID)
|
||||||
membership := event.MembershipLeave
|
membership := event.MembershipLeave
|
||||||
|
@ -118,6 +136,26 @@ func (store *SQLStateStore) TryGetMember(roomID id.RoomID, userID id.UserID) (*e
|
||||||
return &member, err == nil
|
return &member, err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *SQLStateStore) FindSharedRooms(userID id.UserID) (rooms []id.RoomID) {
|
||||||
|
rows, err := store.db.Query(`
|
||||||
|
SELECT room_id FROM mx_user_profile WHERE user_id=$2 AND portal.encrypted=true
|
||||||
|
LEFT JOIN portal WHEN portal.mxid=mx_user_profile.room_id`, userID)
|
||||||
|
if err != nil {
|
||||||
|
store.log.Warnfln("Failed to query shared rooms with %s: %v", userID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var roomID id.RoomID
|
||||||
|
err := rows.Scan(&roomID)
|
||||||
|
if err != nil {
|
||||||
|
store.log.Warnfln("Failed to scan room ID: %v", err)
|
||||||
|
} else {
|
||||||
|
rooms = append(rooms, roomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (store *SQLStateStore) IsInRoom(roomID id.RoomID, userID id.UserID) bool {
|
func (store *SQLStateStore) IsInRoom(roomID id.RoomID, userID id.UserID) bool {
|
||||||
return store.IsMembership(roomID, userID, "join")
|
return store.IsMembership(roomID, userID, "join")
|
||||||
}
|
}
|
||||||
|
|
12
database/upgrades/2020-05-09-add-portal-encrypted-field.go
Normal file
12
database/upgrades/2020-05-09-add-portal-encrypted-field.go
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
package upgrades
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
upgrades[12] = upgrade{"Add encryption status to portal table", func(tx *sql.Tx, ctx context) error {
|
||||||
|
_, err := tx.Exec(`ALTER TABLE portal ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT false`)
|
||||||
|
return err
|
||||||
|
}}
|
||||||
|
}
|
|
@ -28,7 +28,7 @@ type upgrade struct {
|
||||||
fn upgradeFunc
|
fn upgradeFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
const NumberOfUpgrades = 12
|
const NumberOfUpgrades = 13
|
||||||
|
|
||||||
var upgrades [NumberOfUpgrades]upgrade
|
var upgrades [NumberOfUpgrades]upgrade
|
||||||
|
|
||||||
|
|
|
@ -138,6 +138,18 @@ bridge:
|
||||||
# The prefix for commands. Only required in non-management rooms.
|
# The prefix for commands. Only required in non-management rooms.
|
||||||
command_prefix: "!wa"
|
command_prefix: "!wa"
|
||||||
|
|
||||||
|
# End-to-bridge encryption support options. This requires login_shared_secret to be configured
|
||||||
|
# in order to get a device for the bridge bot.
|
||||||
|
#
|
||||||
|
# Additionally, https://github.com/matrix-org/synapse/pull/5758 is required if using a normal
|
||||||
|
# application service.
|
||||||
|
encryption:
|
||||||
|
# Allow encryption, work in group chat rooms with e2ee enabled
|
||||||
|
allow: false
|
||||||
|
# Default to encryption, force-enable encryption in all portals the bridge creates
|
||||||
|
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
||||||
|
default: false
|
||||||
|
|
||||||
# Permissions for using the bridge.
|
# Permissions for using the bridge.
|
||||||
# Permitted values:
|
# Permitted values:
|
||||||
# relaybot - Talk through the relaybot (if enabled), no access otherwise
|
# relaybot - Talk through the relaybot (if enabled), no access otherwise
|
||||||
|
|
17
main.go
17
main.go
|
@ -26,6 +26,7 @@ import (
|
||||||
|
|
||||||
flag "maunium.net/go/mauflag"
|
flag "maunium.net/go/mauflag"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix-appservice"
|
"maunium.net/go/mautrix-appservice"
|
||||||
|
@ -106,6 +107,7 @@ type Bridge struct {
|
||||||
Bot *appservice.IntentAPI
|
Bot *appservice.IntentAPI
|
||||||
Formatter *Formatter
|
Formatter *Formatter
|
||||||
Relaybot *User
|
Relaybot *User
|
||||||
|
Crypto Crypto
|
||||||
|
|
||||||
usersByMXID map[id.UserID]*User
|
usersByMXID map[id.UserID]*User
|
||||||
usersByJID map[types.WhatsAppID]*User
|
usersByJID map[types.WhatsAppID]*User
|
||||||
|
@ -120,6 +122,14 @@ type Bridge struct {
|
||||||
puppetsLock sync.Mutex
|
puppetsLock sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Crypto interface {
|
||||||
|
HandleMemberEvent(*event.Event)
|
||||||
|
Decrypt(*event.Event) (*event.Event, error)
|
||||||
|
Encrypt(id.RoomID, event.Type, event.Content) (*event.EncryptedEventContent, error)
|
||||||
|
Start()
|
||||||
|
Stop()
|
||||||
|
}
|
||||||
|
|
||||||
func NewBridge() *Bridge {
|
func NewBridge() *Bridge {
|
||||||
bridge := &Bridge{
|
bridge := &Bridge{
|
||||||
usersByMXID: make(map[id.UserID]*User),
|
usersByMXID: make(map[id.UserID]*User),
|
||||||
|
@ -215,6 +225,11 @@ func (bridge *Bridge) Init() {
|
||||||
bridge.Log.Debugln("Initializing Matrix event handler")
|
bridge.Log.Debugln("Initializing Matrix event handler")
|
||||||
bridge.MatrixHandler = NewMatrixHandler(bridge)
|
bridge.MatrixHandler = NewMatrixHandler(bridge)
|
||||||
bridge.Formatter = NewFormatter(bridge)
|
bridge.Formatter = NewFormatter(bridge)
|
||||||
|
err = bridge.initCrypto()
|
||||||
|
if err != nil {
|
||||||
|
bridge.Log.Fatalln("Error initializing end-to-bridge encryption:", err)
|
||||||
|
os.Exit(19)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bridge *Bridge) Start() {
|
func (bridge *Bridge) Start() {
|
||||||
|
@ -235,6 +250,7 @@ func (bridge *Bridge) Start() {
|
||||||
bridge.Log.Debugln("Starting event processor")
|
bridge.Log.Debugln("Starting event processor")
|
||||||
go bridge.EventProcessor.Start()
|
go bridge.EventProcessor.Start()
|
||||||
go bridge.UpdateBotProfile()
|
go bridge.UpdateBotProfile()
|
||||||
|
go bridge.Crypto.Start()
|
||||||
go bridge.StartUsers()
|
go bridge.StartUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -299,6 +315,7 @@ func (bridge *Bridge) StartUsers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bridge *Bridge) Stop() {
|
func (bridge *Bridge) Stop() {
|
||||||
|
bridge.Crypto.Stop()
|
||||||
bridge.AS.Stop()
|
bridge.AS.Stop()
|
||||||
bridge.EventProcessor.Stop()
|
bridge.EventProcessor.Stop()
|
||||||
for _, user := range bridge.usersByJID {
|
for _, user := range bridge.usersByJID {
|
||||||
|
|
59
matrix.go
59
matrix.go
|
@ -21,7 +21,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"maunium.net/go/maulogger/v2"
|
"maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix-appservice"
|
"maunium.net/go/mautrix-appservice"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/format"
|
"maunium.net/go/mautrix/format"
|
||||||
|
@ -43,15 +42,30 @@ func NewMatrixHandler(bridge *Bridge) *MatrixHandler {
|
||||||
cmd: NewCommandHandler(bridge),
|
cmd: NewCommandHandler(bridge),
|
||||||
}
|
}
|
||||||
bridge.EventProcessor.On(event.EventMessage, handler.HandleMessage)
|
bridge.EventProcessor.On(event.EventMessage, handler.HandleMessage)
|
||||||
|
bridge.EventProcessor.On(event.EventEncrypted, handler.HandleEncrypted)
|
||||||
bridge.EventProcessor.On(event.EventSticker, handler.HandleMessage)
|
bridge.EventProcessor.On(event.EventSticker, handler.HandleMessage)
|
||||||
bridge.EventProcessor.On(event.EventRedaction, handler.HandleRedaction)
|
bridge.EventProcessor.On(event.EventRedaction, handler.HandleRedaction)
|
||||||
bridge.EventProcessor.On(event.StateMember, handler.HandleMembership)
|
bridge.EventProcessor.On(event.StateMember, handler.HandleMembership)
|
||||||
bridge.EventProcessor.On(event.StateRoomName, handler.HandleRoomMetadata)
|
bridge.EventProcessor.On(event.StateRoomName, handler.HandleRoomMetadata)
|
||||||
bridge.EventProcessor.On(event.StateRoomAvatar, handler.HandleRoomMetadata)
|
bridge.EventProcessor.On(event.StateRoomAvatar, handler.HandleRoomMetadata)
|
||||||
bridge.EventProcessor.On(event.StateTopic, handler.HandleRoomMetadata)
|
bridge.EventProcessor.On(event.StateTopic, handler.HandleRoomMetadata)
|
||||||
|
bridge.EventProcessor.On(event.StateEncryption, handler.HandleEncryption)
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mx *MatrixHandler) HandleEncryption(evt *event.Event) {
|
||||||
|
if evt.Content.AsEncryption().Algorithm != id.AlgorithmMegolmV1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
portal := mx.bridge.GetPortalByMXID(evt.RoomID)
|
||||||
|
mx.log.Debugln(portal)
|
||||||
|
if portal != nil && !portal.Encrypted {
|
||||||
|
mx.log.Debugfln("%s enabled encryption in %s", evt.Sender, evt.RoomID)
|
||||||
|
portal.Encrypted = true
|
||||||
|
portal.Update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (mx *MatrixHandler) HandleBotInvite(evt *event.Event) {
|
func (mx *MatrixHandler) HandleBotInvite(evt *event.Event) {
|
||||||
intent := mx.as.BotIntent()
|
intent := mx.as.BotIntent()
|
||||||
|
|
||||||
|
@ -115,6 +129,10 @@ func (mx *MatrixHandler) HandleBotInvite(evt *event.Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mx *MatrixHandler) HandleMembership(evt *event.Event) {
|
func (mx *MatrixHandler) HandleMembership(evt *event.Event) {
|
||||||
|
if mx.bridge.Crypto != nil {
|
||||||
|
mx.bridge.Crypto.HandleMemberEvent(evt)
|
||||||
|
}
|
||||||
|
|
||||||
content := evt.Content.AsMember()
|
content := evt.Content.AsMember()
|
||||||
if content.Membership == event.MembershipInvite && id.UserID(evt.GetStateKey()) == mx.as.BotMXID() {
|
if content.Membership == event.MembershipInvite && id.UserID(evt.GetStateKey()) == mx.as.BotMXID() {
|
||||||
mx.HandleBotInvite(evt)
|
mx.HandleBotInvite(evt)
|
||||||
|
@ -125,7 +143,7 @@ func (mx *MatrixHandler) HandleMembership(evt *event.Event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := mx.bridge.GetUserByMXID(id.UserID(evt.Sender))
|
user := mx.bridge.GetUserByMXID(evt.Sender)
|
||||||
if user == nil || !user.Whitelisted || !user.IsConnected() {
|
if user == nil || !user.Whitelisted || !user.IsConnected() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -148,7 +166,7 @@ func (mx *MatrixHandler) HandleMembership(evt *event.Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mx *MatrixHandler) HandleRoomMetadata(evt *event.Event) {
|
func (mx *MatrixHandler) HandleRoomMetadata(evt *event.Event) {
|
||||||
user := mx.bridge.GetUserByMXID(id.UserID(evt.Sender))
|
user := mx.bridge.GetUserByMXID(evt.Sender)
|
||||||
if user == nil || !user.Whitelisted || !user.IsConnected() {
|
if user == nil || !user.Whitelisted || !user.IsConnected() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -176,21 +194,40 @@ func (mx *MatrixHandler) HandleRoomMetadata(evt *event.Event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mx *MatrixHandler) HandleMessage(evt *event.Event) {
|
func (mx *MatrixHandler) shouldIgnoreEvent(evt *event.Event) bool {
|
||||||
if _, isPuppet := mx.bridge.ParsePuppetMXID(evt.Sender); evt.Sender == mx.bridge.Bot.UserID || isPuppet {
|
if _, isPuppet := mx.bridge.ParsePuppetMXID(evt.Sender); evt.Sender == mx.bridge.Bot.UserID || isPuppet {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
isCustomPuppet, ok := evt.Content.Raw["net.maunium.whatsapp.puppet"].(bool)
|
isCustomPuppet, ok := evt.Content.Raw["net.maunium.whatsapp.puppet"].(bool)
|
||||||
if ok && isCustomPuppet && mx.bridge.GetPuppetByCustomMXID(evt.Sender) != nil {
|
if ok && isCustomPuppet && mx.bridge.GetPuppetByCustomMXID(evt.Sender) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
user := mx.bridge.GetUserByMXID(evt.Sender)
|
||||||
|
if !user.RelaybotWhitelisted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mx *MatrixHandler) HandleEncrypted(evt *event.Event) {
|
||||||
|
if mx.shouldIgnoreEvent(evt) || mx.bridge.Crypto == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := mx.bridge.Crypto.Decrypt(evt)
|
||||||
|
if err != nil {
|
||||||
|
mx.log.Warnln("Failed to decrypt %s: %v", evt.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mx.bridge.EventProcessor.Dispatch(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mx *MatrixHandler) HandleMessage(evt *event.Event) {
|
||||||
|
if mx.shouldIgnoreEvent(evt) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := mx.bridge.GetUserByMXID(evt.Sender)
|
user := mx.bridge.GetUserByMXID(evt.Sender)
|
||||||
|
|
||||||
if !user.RelaybotWhitelisted {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
content := evt.Content.AsMessage()
|
content := evt.Content.AsMessage()
|
||||||
if user.Whitelisted && content.MsgType == event.MsgText {
|
if user.Whitelisted && content.MsgType == event.MsgText {
|
||||||
commandPrefix := mx.bridge.Config.Bridge.CommandPrefix
|
commandPrefix := mx.bridge.Config.Bridge.CommandPrefix
|
||||||
|
@ -215,7 +252,7 @@ func (mx *MatrixHandler) HandleRedaction(evt *event.Event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := mx.bridge.GetUserByMXID(id.UserID(evt.Sender))
|
user := mx.bridge.GetUserByMXID(evt.Sender)
|
||||||
|
|
||||||
if !user.Whitelisted {
|
if !user.Whitelisted {
|
||||||
return
|
return
|
||||||
|
|
26
nocrypto.go
Normal file
26
nocrypto.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// +build !cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func (bridge *Bridge) initCrypto() error {
|
||||||
|
if !bridge.Config.Bridge.Encryption.Allow {
|
||||||
|
bridge.Log.Warnln("Bridge built without end-to-bridge encryption, but encryption is enabled in config")
|
||||||
|
}
|
||||||
|
bridge.Log.Debugln("Bridge built without end-to-bridge encryption")
|
||||||
|
}
|
52
portal.go
52
portal.go
|
@ -35,6 +35,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chai2010/webp"
|
"github.com/chai2010/webp"
|
||||||
|
"github.com/pkg/errors"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"github.com/Rhymen/go-whatsapp"
|
"github.com/Rhymen/go-whatsapp"
|
||||||
|
@ -908,6 +909,32 @@ func (portal *Portal) HandleFakeMessage(source *User, message FakeMessage) {
|
||||||
portal.recentlyHandled[index] = message.ID
|
portal.recentlyHandled[index] = message.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) sendMainIntentMessage(content interface{}) (*mautrix.RespSendEvent, error) {
|
||||||
|
return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
|
||||||
|
wrappedContent := event.Content{Parsed: content}
|
||||||
|
if timestamp != 0 && intent.IsCustomPuppet {
|
||||||
|
wrappedContent.Raw = map[string]interface{}{
|
||||||
|
"net.maunium.whatsapp.puppet": intent.IsCustomPuppet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if portal.Encrypted && portal.bridge.Crypto != nil {
|
||||||
|
encrypted, err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, wrappedContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to encrypt event")
|
||||||
|
}
|
||||||
|
eventType = event.EventEncrypted
|
||||||
|
wrappedContent.Parsed = encrypted
|
||||||
|
}
|
||||||
|
if timestamp == 0 {
|
||||||
|
return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent)
|
||||||
|
} else {
|
||||||
|
return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessage) {
|
func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessage) {
|
||||||
if !portal.startHandling(message.Info) {
|
if !portal.startHandling(message.Info) {
|
||||||
return
|
return
|
||||||
|
@ -927,12 +954,7 @@ func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessa
|
||||||
portal.SetReply(content, message.ContextInfo)
|
portal.SetReply(content, message.ContextInfo)
|
||||||
|
|
||||||
_, _ = intent.UserTyping(portal.MXID, false, 0)
|
_, _ = intent.UserTyping(portal.MXID, false, 0)
|
||||||
resp, err := intent.SendMassagedMessageEvent(portal.MXID, event.EventMessage, &event.Content{
|
resp, err := portal.sendMessage(intent, event.EventMessage, content, int64(message.Info.Timestamp*1000))
|
||||||
Parsed: content,
|
|
||||||
Raw: map[string]interface{}{
|
|
||||||
"net.maunium.whatsapp.puppet": intent.IsCustomPuppet,
|
|
||||||
},
|
|
||||||
}, int64(message.Info.Timestamp*1000))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err)
|
portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err)
|
||||||
return
|
return
|
||||||
|
@ -1042,12 +1064,7 @@ func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte,
|
||||||
if sendAsSticker {
|
if sendAsSticker {
|
||||||
eventType = event.EventSticker
|
eventType = event.EventSticker
|
||||||
}
|
}
|
||||||
resp, err := intent.SendMassagedMessageEvent(portal.MXID, eventType, &event.Content{
|
resp, err := portal.sendMessage(intent, eventType, content, ts)
|
||||||
Parsed: content,
|
|
||||||
Raw: map[string]interface{}{
|
|
||||||
"net.maunium.whatsapp.puppet": intent.IsCustomPuppet,
|
|
||||||
},
|
|
||||||
}, ts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
portal.log.Errorfln("Failed to handle message %s: %v", info.Id, err)
|
portal.log.Errorfln("Failed to handle message %s: %v", info.Id, err)
|
||||||
return
|
return
|
||||||
|
@ -1061,12 +1078,7 @@ func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte,
|
||||||
|
|
||||||
portal.bridge.Formatter.ParseWhatsApp(captionContent)
|
portal.bridge.Formatter.ParseWhatsApp(captionContent)
|
||||||
|
|
||||||
_, err := intent.SendMassagedMessageEvent(portal.MXID, event.EventMessage, &event.Content{
|
_, err := portal.sendMessage(intent, event.EventMessage, content, ts)
|
||||||
Parsed: content,
|
|
||||||
Raw: map[string]interface{}{
|
|
||||||
"net.maunium.whatsapp.puppet": intent.IsCustomPuppet,
|
|
||||||
},
|
|
||||||
}, ts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
portal.log.Warnfln("Failed to handle caption of message %s: %v", info.Id, err)
|
portal.log.Warnfln("Failed to handle caption of message %s: %v", info.Id, err)
|
||||||
}
|
}
|
||||||
|
@ -1178,7 +1190,7 @@ func (portal *Portal) sendMatrixConnectionError(sender *User, eventID id.EventID
|
||||||
}
|
}
|
||||||
msg := format.RenderMarkdown("\u26a0 You are not connected to WhatsApp, so your message was not bridged. " + reconnect, true, false)
|
msg := format.RenderMarkdown("\u26a0 You are not connected to WhatsApp, so your message was not bridged. " + reconnect, true, false)
|
||||||
msg.MsgType = event.MsgNotice
|
msg.MsgType = event.MsgNotice
|
||||||
_, err := portal.MainIntent().SendMessageEvent(portal.MXID, event.EventMessage, msg)
|
_, err := portal.sendMainIntentMessage(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
portal.log.Errorln("Failed to send bridging failure message:", err)
|
portal.log.Errorln("Failed to send bridging failure message:", err)
|
||||||
}
|
}
|
||||||
|
@ -1353,7 +1365,7 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) {
|
||||||
portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err)
|
portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err)
|
||||||
msg := format.RenderMarkdown(fmt.Sprintf("\u26a0 Your message may not have been bridged: %v", err), false, false)
|
msg := format.RenderMarkdown(fmt.Sprintf("\u26a0 Your message may not have been bridged: %v", err), false, false)
|
||||||
msg.MsgType = event.MsgNotice
|
msg.MsgType = event.MsgNotice
|
||||||
_, err := portal.MainIntent().SendMessageEvent(portal.MXID, event.EventMessage, msg)
|
_, err := portal.sendMainIntentMessage(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
portal.log.Errorln("Failed to send bridging failure message:", err)
|
portal.log.Errorln("Failed to send bridging failure message:", err)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue