diff --git a/.gitignore b/.gitignore
index 138bbf8..58cd300 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
.idea
*.session
+
+*.yaml
+!example-config.yaml
diff --git a/config/bridge.go b/config/bridge.go
index c6e02f6..623b7aa 100644
--- a/config/bridge.go
+++ b/config/bridge.go
@@ -22,24 +22,26 @@ import (
)
type BridgeConfig struct {
- RawUsernameTemplate string `yaml:"username_template"`
- RawDisplaynameTemplate string `yaml:"displayname_template"`
- UsernameTemplate *template.Template `yaml:"-"`
- DisplaynameTemplate *template.Template `yaml:"-"`
+ UsernameTemplate string `yaml:"username_template"`
+ DisplaynameTemplate string `yaml:"displayname_template"`
+ usernameTemplate *template.Template `yaml:"-"`
+ displaynameTemplate *template.Template `yaml:"-"`
}
-func (bc BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
- err := unmarshal(bc)
+type umBridgeConfig BridgeConfig
+
+func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+ err := unmarshal((*umBridgeConfig)(bc))
if err != nil {
return err
}
- bc.UsernameTemplate, err = template.New("username").Parse(bc.RawUsernameTemplate)
+ bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
if err != nil {
return err
}
- bc.DisplaynameTemplate, err = template.New("displayname").Parse(bc.RawDisplaynameTemplate)
+ bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
return err
}
@@ -54,7 +56,7 @@ type UsernameTemplateArgs struct {
func (bc BridgeConfig) FormatDisplayname(displayname string) string {
var buf bytes.Buffer
- bc.DisplaynameTemplate.Execute(&buf, DisplaynameTemplateArgs{
+ bc.displaynameTemplate.Execute(&buf, DisplaynameTemplateArgs{
Displayname: displayname,
})
return buf.String()
@@ -62,7 +64,7 @@ func (bc BridgeConfig) FormatDisplayname(displayname string) string {
func (bc BridgeConfig) FormatUsername(receiver, userID string) string {
var buf bytes.Buffer
- bc.UsernameTemplate.Execute(&buf, UsernameTemplateArgs{
+ bc.usernameTemplate.Execute(&buf, UsernameTemplateArgs{
Receiver: receiver,
UserID: userID,
})
@@ -70,7 +72,7 @@ func (bc BridgeConfig) FormatUsername(receiver, userID string) string {
}
func (bc BridgeConfig) MarshalYAML() (interface{}, error) {
- bc.RawDisplaynameTemplate = bc.FormatDisplayname("{{.Displayname}}")
- bc.RawUsernameTemplate = bc.FormatUsername("{{.Receiver}}", "{{.UserID}}")
+ bc.DisplaynameTemplate = bc.FormatDisplayname("{{.Displayname}}")
+ bc.UsernameTemplate = bc.FormatUsername("{{.Receiver}}", "{{.UserID}}")
return bc, nil
}
diff --git a/config/registration.go b/config/registration.go
index 2b78fe5..1ea1724 100644
--- a/config/registration.go
+++ b/config/registration.go
@@ -19,6 +19,7 @@ package config
import (
"maunium.net/go/mautrix-appservice"
"regexp"
+ "fmt"
)
func (config *Config) NewRegistration() (*appservice.Registration, error) {
@@ -53,7 +54,9 @@ func (config *Config) copyToRegistration(registration *appservice.Registration)
registration.RateLimited = false
registration.SenderLocalpart = config.AppService.Bot.Username
- userIDRegex, err := regexp.Compile(config.Bridge.FormatUsername("[0-9]+", "[0-9]+"))
+ userIDRegex, err := regexp.Compile(fmt.Sprintf("@%s:%s",
+ config.Bridge.FormatUsername("[0-9]+", "[0-9]+"),
+ config.Homeserver.Domain))
if err != nil {
return err
}
diff --git a/database/database.go b/database/database.go
index 429cc70..341aa64 100644
--- a/database/database.go
+++ b/database/database.go
@@ -26,7 +26,9 @@ type Database struct {
*sql.DB
log *log.Sublogger
- User *UserQuery
+ User *UserQuery
+ Portal *PortalQuery
+ Puppet *PuppetQuery
}
func New(file string) (*Database, error) {
@@ -43,6 +45,14 @@ func New(file string) (*Database, error) {
db: db,
log: log.CreateSublogger("Database/User", log.LevelDebug),
}
+ db.Portal = &PortalQuery{
+ db: db,
+ log: log.CreateSublogger("Database/Portal", log.LevelDebug),
+ }
+ db.Puppet = &PuppetQuery{
+ db: db,
+ log: log.CreateSublogger("Database/Puppet", log.LevelDebug),
+ }
return db, nil
}
diff --git a/database/portal.go b/database/portal.go
index af2feca..5f5b4e3 100644
--- a/database/portal.go
+++ b/database/portal.go
@@ -44,8 +44,8 @@ func (pq *PortalQuery) New() *Portal {
}
}
-func (pq *PortalQuery) GetAll() (portals []*Portal) {
- rows, err := pq.db.Query("SELECT * FROM portal")
+func (pq *PortalQuery) GetAll(owner string) (portals []*Portal) {
+ rows, err := pq.db.Query("SELECT * FROM portal WHERE owner=?", owner)
if err != nil || rows == nil {
return nil
}
diff --git a/database/puppet.go b/database/puppet.go
new file mode 100644
index 0000000..93b22b9
--- /dev/null
+++ b/database/puppet.go
@@ -0,0 +1,98 @@
+// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2018 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 database
+
+import (
+ log "maunium.net/go/maulogger"
+)
+
+type PuppetQuery struct {
+ db *Database
+ log *log.Sublogger
+}
+
+func (pq *PuppetQuery) CreateTable() error {
+ _, err := pq.db.Exec(`CREATE TABLE IF NOT EXISTS puppet (
+ jid VARCHAR(255),
+ receiver VARCHAR(255),
+
+ displayname VARCHAR(255),
+ avatar VARCHAR(255),
+
+ PRIMARY KEY(jid, receiver)
+ )`)
+ return err
+}
+
+func (pq *PuppetQuery) New() *Puppet {
+ return &Puppet{
+ db: pq.db,
+ log: pq.log,
+ }
+}
+
+func (pq *PuppetQuery) GetAll() (puppets []*Puppet) {
+ rows, err := pq.db.Query("SELECT * FROM puppet")
+ if err != nil || rows == nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ puppets = append(puppets, pq.New().Scan(rows))
+ }
+ return
+}
+
+func (pq *PuppetQuery) Get(jid, receiver string) *Puppet {
+ row := pq.db.QueryRow("SELECT * FROM user WHERE jid=? AND receiver=?", jid, receiver)
+ if row == nil {
+ return nil
+ }
+ return pq.New().Scan(row)
+}
+
+type Puppet struct {
+ db *Database
+ log *log.Sublogger
+
+ JID string
+ Receiver string
+
+ Displayname string
+ Avatar string
+}
+
+func (puppet *Puppet) Scan(row Scannable) *Puppet {
+ err := row.Scan(&puppet.JID, &puppet.Receiver, &puppet.Displayname, &puppet.Avatar)
+ if err != nil {
+ puppet.log.Fatalln("Database scan failed:", err)
+ }
+ return puppet
+}
+
+func (puppet *Puppet) Insert() error {
+ _, err := puppet.db.Exec("INSERT INTO puppet VALUES (?, ?, ?, ?)",
+ puppet.JID, puppet.Receiver, puppet.Displayname, puppet.Avatar)
+ return err
+}
+
+func (puppet *Puppet) Update() error {
+ _, err := puppet.db.Exec("UPDATE puppet SET displayname=?, avatar=? WHERE jid=? AND receiver=?",
+ puppet.Displayname, puppet.Avatar,
+ puppet.JID, puppet.Receiver)
+ return err
+}
diff --git a/database/user.go b/database/user.go
index 4d90f66..cbb9f47 100644
--- a/database/user.go
+++ b/database/user.go
@@ -28,7 +28,9 @@ type UserQuery struct {
func (uq *UserQuery) CreateTable() error {
_, err := uq.db.Exec(`CREATE TABLE IF NOT EXISTS user (
- mxid VARCHAR(255) PRIMARY KEY,
+ mxid VARCHAR(255) PRIMARY KEY,
+
+ management_room VARCHAR(255),
client_id VARCHAR(255),
client_token VARCHAR(255),
@@ -71,29 +73,43 @@ type User struct {
db *Database
log *log.Sublogger
- UserID string
-
- session whatsapp.Session
+ UserID string
+ ManagementRoom string
+ Session *whatsapp.Session
}
func (user *User) Scan(row Scannable) *User {
- err := row.Scan(&user.UserID, &user.session.ClientId, &user.session.ClientToken, &user.session.ServerToken,
- &user.session.EncKey, &user.session.MacKey, &user.session.Wid)
+ sess := whatsapp.Session{}
+ err := row.Scan(&user.UserID, &user.ManagementRoom, &sess.ClientId, &sess.ClientToken, &sess.ServerToken,
+ &sess.EncKey, &sess.MacKey, &sess.Wid)
if err != nil {
user.log.Fatalln("Database scan failed:", err)
}
+ if len(sess.ClientId) > 0 {
+ user.Session = &sess
+ } else {
+ user.Session = nil
+ }
return user
}
func (user *User) Insert() error {
- _, err := user.db.Exec("INSERT INTO user VALUES (?, ?, ?, ?, ?, ?, ?)", user.UserID, user.session.ClientId,
- user.session.ClientToken, user.session.ServerToken, user.session.EncKey, user.session.MacKey, user.session.Wid)
+ var sess whatsapp.Session
+ if user.Session != nil {
+ sess = *user.Session
+ }
+ _, err := user.db.Exec("INSERT INTO user VALUES (?, ?, ?, ?, ?, ?, ?, ?)", user.UserID, user.ManagementRoom,
+ sess.ClientId, sess.ClientToken, sess.ServerToken, sess.EncKey, sess.MacKey, sess.Wid)
return err
}
func (user *User) Update() error {
- _, err := user.db.Exec("UPDATE user SET client_id=?, client_token=?, server_token=?, enc_key=?, mac_key=?, wid=? WHERE mxid=?",
- user.session.ClientId, user.session.ClientToken, user.session.ServerToken, user.session.EncKey, user.session.MacKey,
- user.session.Wid, user.UserID)
+ var sess whatsapp.Session
+ if user.Session != nil {
+ sess = *user.Session
+ }
+ _, err := user.db.Exec("UPDATE user SET management_room=?, client_id=?, client_token=?, server_token=?, enc_key=?, mac_key=?, wid=? WHERE mxid=?",
+ user.ManagementRoom,
+ sess.ClientId, sess.ClientToken, sess.ServerToken, sess.EncKey, sess.MacKey, sess.Wid, user.UserID)
return err
}
diff --git a/example-config.yaml b/example-config.yaml
index a06d4be..9c97d18 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -52,7 +52,7 @@ logging:
# The directory for log files. Will be created if not found.
directory: ./logs
# Available variables: .date for the file date and .index for different log files on the same day.
- file_name_format: {{.date}}-{{.index}.log
+ file_name_format: "{{.date}}-{{.index}.log"
# Date format for file names in the Go time format: https://golang.org/pkg/time/#pkg-constants
file_date_format: 2006-01-02
# Log file permissions.
diff --git a/main.go b/main.go
index eff3133..67a1927 100644
--- a/main.go
+++ b/main.go
@@ -67,6 +67,8 @@ type Bridge struct {
Log *log.Logger
MatrixListener *MatrixListener
+
+ users map[string]*User
}
func NewBridge() *Bridge {
@@ -133,7 +135,9 @@ func (bridge *Bridge) Main() {
}
func main() {
- flag.SetHelpTitles("mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.", "[-h] [-c ] [-r ] [-g]")
+ flag.SetHelpTitles(
+ "mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.",
+ "mautrix-whatsapp [-h] [-c ] [-r ] [-g]")
err := flag.Parse()
if err != nil {
fmt.Fprintln(os.Stderr, err)
diff --git a/matrix.go b/matrix.go
index ee70b4b..c71c3a0 100644
--- a/matrix.go
+++ b/matrix.go
@@ -18,10 +18,13 @@ package main
import (
log "maunium.net/go/maulogger"
+ "maunium.net/go/mautrix-appservice"
+ "maunium.net/go/gomatrix"
)
type MatrixListener struct {
bridge *Bridge
+ as *appservice.AppService
log *log.Sublogger
stop chan struct{}
}
@@ -29,8 +32,9 @@ type MatrixListener struct {
func NewMatrixListener(bridge *Bridge) *MatrixListener {
return &MatrixListener{
bridge: bridge,
+ as: bridge.AppService,
stop: make(chan struct{}, 1),
- log: bridge.Log.CreateSublogger("Matrix", log.LevelDebug),
+ log: bridge.Log.CreateSublogger("Matrix", log.LevelDebug),
}
}
@@ -39,12 +43,69 @@ func (ml *MatrixListener) Start() {
select {
case evt := <-ml.bridge.AppService.Events:
log.Debugln("Received Matrix event:", evt)
+ switch evt.Type {
+ case gomatrix.StateMember:
+ ml.HandleMembership(evt)
+ case gomatrix.EventMessage:
+ ml.HandleMessage(evt)
+ }
case <-ml.stop:
return
}
}
}
+func (ml *MatrixListener) HandleBotInvite(evt *gomatrix.Event) {
+ cli := ml.as.BotClient()
+
+ resp, err := cli.JoinRoom(evt.RoomID, "", nil)
+ if err != nil {
+ ml.log.Debugln("Failed to join room", evt.RoomID, "with invite from", evt.Sender)
+ return
+ }
+
+ members, err := cli.JoinedMembers(resp.RoomID)
+ if err != nil {
+ ml.log.Debugln("Failed to get members in room", resp.RoomID, "after accepting invite from", evt.Sender)
+ cli.LeaveRoom(resp.RoomID)
+ return
+ }
+
+ if len(members.Joined) < 2 {
+ ml.log.Debugln("Leaving empty room", resp.RoomID, "after accepting invite from", evt.Sender)
+ cli.LeaveRoom(resp.RoomID)
+ return
+ }
+ for mxid, _ := range members.Joined {
+ if mxid == cli.UserID || mxid == evt.Sender {
+ continue
+ } else if true { // TODO check if mxid is WhatsApp puppet
+
+ continue
+ }
+ ml.log.Debugln("Leaving multi-user room", resp.RoomID, "after accepting invite from", evt.Sender)
+ cli.SendNotice(resp.RoomID, "This bridge is user-specific, please don't invite me into rooms with other users.")
+ cli.LeaveRoom(resp.RoomID)
+ return
+ }
+
+ user := ml.bridge.GetUser(evt.Sender)
+ user.ManagementRoom = resp.RoomID
+ user.Update()
+ cli.SendNotice(user.ManagementRoom, "This room has been registered as your bridge management/status room.")
+ ml.log.Debugln(resp.RoomID, "registered as a management room with", evt.Sender)
+}
+
+func (ml *MatrixListener) HandleMembership(evt *gomatrix.Event) {
+ if evt.Content.Membership == "invite" && evt.GetStateKey() == ml.as.BotMXID() {
+ ml.HandleBotInvite(evt)
+ }
+}
+
+func (ml *MatrixListener) HandleMessage(evt *gomatrix.Event) {
+
+}
+
func (ml *MatrixListener) Stop() {
ml.stop <- struct{}{}
}
diff --git a/portal.go b/portal.go
new file mode 100644
index 0000000..5a82e91
--- /dev/null
+++ b/portal.go
@@ -0,0 +1,89 @@
+// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2018 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 (
+ "maunium.net/go/mautrix-whatsapp/database"
+ log "maunium.net/go/maulogger"
+ "fmt"
+)
+
+func (user *User) GetPortalByMXID(mxid string) *Portal {
+ portal, ok := user.portalsByMXID[mxid]
+ if !ok {
+ dbPortal := user.bridge.DB.Portal.GetByMXID(mxid)
+ if dbPortal == nil || dbPortal.Owner != user.UserID {
+ return nil
+ }
+ portal = user.NewPortal(dbPortal)
+ user.portalsByJID[portal.JID] = portal
+ if len(portal.MXID) > 0 {
+ user.portalsByMXID[portal.MXID] = portal
+ }
+ }
+ return portal
+}
+
+func (user *User) GetPortalByJID(jid string) *Portal {
+ portal, ok := user.portalsByJID[jid]
+ if !ok {
+ dbPortal := user.bridge.DB.Portal.GetByJID(user.UserID, jid)
+ if dbPortal == nil {
+ return nil
+ }
+ portal = user.NewPortal(dbPortal)
+ user.portalsByJID[portal.JID] = portal
+ if len(portal.MXID) > 0 {
+ user.portalsByMXID[portal.MXID] = portal
+ }
+ }
+ return portal
+}
+
+func (user *User) GetAllPortals() []*Portal {
+ dbPortals := user.bridge.DB.Portal.GetAll(user.UserID)
+ output := make([]*Portal, len(dbPortals))
+ for index, dbPortal := range dbPortals {
+ portal, ok := user.portalsByJID[dbPortal.JID]
+ if !ok {
+ portal = user.NewPortal(dbPortal)
+ user.portalsByJID[dbPortal.JID] = portal
+ if len(dbPortal.MXID) > 0 {
+ user.portalsByMXID[dbPortal.MXID] = portal
+ }
+ }
+ output[index] = portal
+ }
+ return output
+}
+
+func (user *User) NewPortal(dbPortal *database.Portal) *Portal {
+ return &Portal{
+ Portal: dbPortal,
+ user: user,
+ bridge: user.bridge,
+ log: user.bridge.Log.CreateSublogger(fmt.Sprintf("Portal/%s/%s", user.UserID, dbPortal.JID), log.LevelDebug),
+ }
+}
+
+type Portal struct {
+ *database.Portal
+
+ user *User
+ bridge *Bridge
+ log *log.Sublogger
+}
diff --git a/user.go b/user.go
index 460fbb3..0c3dc9a 100644
--- a/user.go
+++ b/user.go
@@ -15,3 +15,161 @@
// along with this program. If not, see .
package main
+
+import (
+ "maunium.net/go/mautrix-whatsapp/database"
+ "github.com/Rhymen/go-whatsapp"
+ "time"
+ "fmt"
+ "os"
+ "github.com/skip2/go-qrcode"
+ log "maunium.net/go/maulogger"
+)
+
+type User struct {
+ *database.User
+ Conn *whatsapp.Conn
+
+ bridge *Bridge
+ log *log.Sublogger
+
+ portalsByMXID map[string]*Portal
+ portalsByJID map[string]*Portal
+ puppets map[string]*Portal
+}
+
+func (bridge *Bridge) GetUser(userID string) *User {
+ user, ok := bridge.users[userID]
+ if !ok {
+ dbUser := bridge.DB.User.Get(userID)
+ if dbUser == nil {
+ dbUser = bridge.DB.User.New()
+ dbUser.Insert()
+ }
+ user = bridge.NewUser(dbUser)
+ bridge.users[user.UserID] = user
+ }
+ return user
+}
+
+func (bridge *Bridge) GetAllUsers() []*User {
+ dbUsers := bridge.DB.User.GetAll()
+ output := make([]*User, len(dbUsers))
+ for index, dbUser := range dbUsers {
+ user, ok := bridge.users[dbUser.UserID]
+ if !ok {
+ user = bridge.NewUser(dbUser)
+ bridge.users[user.UserID] = user
+ }
+ output[index] = user
+ }
+ return output
+}
+
+func (bridge *Bridge) InitWhatsApp() {
+ users := bridge.GetAllUsers()
+ for _, user := range users {
+ user.Connect()
+ }
+}
+
+func (bridge *Bridge) NewUser(dbUser *database.User) *User {
+ return &User{
+ User: dbUser,
+ bridge: bridge,
+ log: bridge.Log.CreateSublogger(fmt.Sprintf("User/%s", dbUser.UserID), log.LevelDebug),
+ }
+}
+
+func (user *User) Connect() {
+ var err error
+ user.Conn, err = whatsapp.NewConn(20 * time.Second)
+ if err != nil {
+ user.log.Errorln("Failed to connect to WhatsApp:", err)
+ return
+ }
+ user.Conn.AddHandler(user)
+ user.RestoreSession()
+}
+
+func (user *User) RestoreSession() {
+ if user.Session != nil {
+ sess, err := user.Conn.RestoreSession(*user.Session)
+ if err != nil {
+ user.log.Errorln("Failed to restore session:", err)
+ user.Session = nil
+ return
+ }
+ user.Session = &sess
+ user.log.Debugln("Session restored")
+ }
+ return
+}
+
+func (user *User) Login(roomID string) {
+ bot := user.bridge.AppService.BotClient()
+
+ qrChan := make(chan string, 2)
+ go func() {
+ code := <-qrChan
+ if code == "error" {
+ return
+ }
+ qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
+ if err != nil {
+ user.log.Errorln("Failed to encode QR code:", err)
+ bot.SendNotice(roomID, "Failed to encode QR code (see logs for details)")
+ return
+ }
+
+ resp, err := bot.UploadBytes(qrCode, "image/png")
+ if err != nil {
+ user.log.Errorln("Failed to upload QR code:", err)
+ bot.SendNotice(roomID, "Failed to upload QR code (see logs for details)")
+ return
+ }
+
+ bot.SendImage(roomID, string(qrCode), resp.ContentURI)
+ }()
+ session, err := user.Conn.Login(qrChan)
+ if err != nil {
+ user.log.Warnln("Failed to log in:", err)
+ bot.SendNotice(roomID, "Failed to log in: "+err.Error())
+ qrChan <- "error"
+ return
+ }
+ user.Session = &session
+ user.Update()
+ bot.SendNotice(roomID, "Successfully logged in. Synchronizing chats...")
+ go user.Sync()
+}
+
+func (user *User) Sync() {
+ chats, err := user.Conn.Chats()
+ if err != nil {
+ user.log.Warnln("Failed to get chats")
+ return
+ }
+ user.log.Debugln(chats)
+}
+
+func (user *User) HandleError(err error) {
+ user.log.Errorln("WhatsApp error:", err)
+ fmt.Fprintf(os.Stderr, "%v", err)
+}
+
+func (user *User) HandleTextMessage(message whatsapp.TextMessage) {
+ fmt.Println(message)
+}
+
+func (user *User) HandleImageMessage(message whatsapp.ImageMessage) {
+ fmt.Println(message)
+}
+
+func (user *User) HandleVideoMessage(message whatsapp.VideoMessage) {
+ fmt.Println(message)
+}
+
+func (user *User) HandleJsonMessage(message string) {
+ fmt.Println(message)
+}