mirror of
https://github.com/status-im/matterbridge.git
synced 2025-01-20 11:09:02 +00:00
add support for Status whisper protocol
Signed-off-by: Jakub Sokołowski <jakub@status.im>
This commit is contained in:
parent
2977a5957e
commit
b60437d12e
30
Dockerfile
30
Dockerfile
@ -1,16 +1,22 @@
|
||||
FROM alpine:edge AS builder
|
||||
|
||||
COPY . /go/src/github.com/42wim/matterbridge
|
||||
RUN apk update && apk add go git gcc musl-dev \
|
||||
&& cd /go/src/github.com/42wim/matterbridge \
|
||||
&& export GOPATH=/go \
|
||||
&& go get \
|
||||
&& go build -x -ldflags "-X main.githash=$(git log --pretty=format:'%h' -n 1)" -o /bin/matterbridge
|
||||
RUN apk update && apk add go git gcc musl-dev linux-headers
|
||||
|
||||
COPY . /go/src/github.com/42wim/matterbridge
|
||||
|
||||
WORKDIR /go/src/github.com/42wim/matterbridge
|
||||
|
||||
ENV GOPATH /go
|
||||
ENV CGOENABLE 1
|
||||
ENV GO111MODULE on
|
||||
|
||||
RUN go get
|
||||
RUN go build -x -ldflags "-X main.githash=$(git log --pretty=format:'%h' -n 1)" -o /bin/matterbridge
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk update && apk add ca-certificates
|
||||
|
||||
FROM alpine:edge
|
||||
RUN apk --no-cache add ca-certificates mailcap
|
||||
COPY --from=builder /bin/matterbridge /bin/matterbridge
|
||||
RUN mkdir /etc/matterbridge \
|
||||
&& touch /etc/matterbridge/matterbridge.toml \
|
||||
&& ln -sf /matterbridge.toml /etc/matterbridge/matterbridge.toml
|
||||
ENTRYPOINT ["/bin/matterbridge", "-conf", "/etc/matterbridge/matterbridge.toml"]
|
||||
|
||||
ENTRYPOINT ["/bin/matterbridge"]
|
||||
|
@ -92,6 +92,8 @@ type Protocol struct {
|
||||
Jid string // xmpp
|
||||
JoinDelay string // all protocols
|
||||
Label string // all protocols
|
||||
ListenPort int // status
|
||||
ListenAddr string // status
|
||||
Login string // mattermost, matrix
|
||||
MediaDownloadBlackList []string
|
||||
MediaDownloadPath string // Basically MediaServerUpload, but instead of uploading it, just write it to a file on the same server.
|
||||
@ -143,6 +145,7 @@ type Protocol struct {
|
||||
TeamID string // msteams
|
||||
TenantID string // msteams
|
||||
Token string // gitter, slack, discord, api
|
||||
PrivateKey string // status
|
||||
Topic string // zulip
|
||||
URL string // mattermost, slack // DEPRECATED
|
||||
UseAPI bool // mattermost, slack
|
||||
@ -200,6 +203,7 @@ type BridgeValues struct {
|
||||
Matrix map[string]Protocol
|
||||
Slack map[string]Protocol
|
||||
SlackLegacy map[string]Protocol
|
||||
Status map[string]Protocol
|
||||
Steam map[string]Protocol
|
||||
Gitter map[string]Protocol
|
||||
XMPP map[string]Protocol
|
||||
|
35
bridge/status/README.md
Normal file
35
bridge/status/README.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Description
|
||||
|
||||
This is an implementation of support for the [Status](https://status.im/) chat protocol.
|
||||
|
||||
It makes use of the [status-go](https://github.com/status-im/status-go).
|
||||
|
||||
# Configuration
|
||||
|
||||
Here's a basic config using a randomly generated `PrivateKey` for bridge identity:
|
||||
```yaml
|
||||
---
|
||||
Nick: "bridge-bot"
|
||||
|
||||
status:
|
||||
bridge:
|
||||
Nick: 'mybridge.stateofus.eth'
|
||||
PrivateKey: '0xeb87e5780fef3a83fa6a7f5c19fb206715a66e1c10aab50471686c6347b1ede4'
|
||||
RemoteNickFormat: '**{NICK}**@*{PROTOCOL}*: '
|
||||
|
||||
gateway:
|
||||
- name: "status-bridge-test"
|
||||
enable: true
|
||||
inout:
|
||||
- account: "status.bridge"
|
||||
channel: "test-channel-1"
|
||||
|
||||
- account: "status.bridge"
|
||||
channel: "test-channel-2"
|
||||
```
|
||||
|
||||
# TODO
|
||||
|
||||
* Drop usage of the SQLite database entirely
|
||||
* Improve handling of logs for the Whisper node
|
||||
* Handle properly verifying successful delivery
|
18
bridge/status/helpers.go
Normal file
18
bridge/status/helpers.go
Normal file
@ -0,0 +1,18 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/hex"
|
||||
|
||||
crypto "github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
func publicKeyToHex(pubkey *ecdsa.PublicKey) string {
|
||||
return "0x" + hex.EncodeToString(crypto.FromECDSAPub(pubkey))
|
||||
}
|
||||
|
||||
// isPubKeyEqual checks that two public keys are equal
|
||||
func isPubKeyEqual(a, b *ecdsa.PublicKey) bool {
|
||||
// the curve is always the same, just compare the points
|
||||
return a.X.Cmp(b.X) == 0 && a.Y.Cmp(b.Y) == 0
|
||||
}
|
328
bridge/status/status.go
Normal file
328
bridge/status/status.go
Normal file
@ -0,0 +1,328 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
|
||||
crypto "github.com/ethereum/go-ethereum/crypto"
|
||||
gethbridge "github.com/status-im/status-go/eth-node/bridge/geth"
|
||||
gonode "github.com/status-im/status-go/node"
|
||||
params "github.com/status-im/status-go/params"
|
||||
status "github.com/status-im/status-go/protocol"
|
||||
alias "github.com/status-im/status-go/protocol/identity/alias"
|
||||
"github.com/status-im/status-go/protocol/protobuf"
|
||||
)
|
||||
|
||||
type Bstatus struct {
|
||||
*bridge.Config
|
||||
|
||||
// Maximum number of characters a username can have
|
||||
maxUsernameLen int
|
||||
|
||||
// message fetching loop controls
|
||||
fetchInterval time.Duration
|
||||
fetchTimeout time.Duration
|
||||
fetchDone chan bool
|
||||
|
||||
// Whisper node settings
|
||||
whisperListenPort int
|
||||
whisperListenAddr string
|
||||
whisperDataDir string
|
||||
|
||||
// ENS settings
|
||||
ensName string // Make sure the bridge account owns the ENS name
|
||||
ensVerifyURL string // URL of Infura endpoint to call
|
||||
ensContract string // Address of ENS resolving contract
|
||||
ensDone chan bool // Control channel for stopping ENS checking loop
|
||||
ensInterval time.Duration // Frequency of ENS verification checks
|
||||
|
||||
privateKey *ecdsa.PrivateKey // secret for Status chat identity
|
||||
nodeConfig *params.NodeConfig // configuration for Whisper node
|
||||
statusNode *gonode.StatusNode // Ethereum Whisper node to run in background
|
||||
messenger *status.Messenger // Status messaging layer instance
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Bstatus{
|
||||
Config: cfg,
|
||||
fetchDone: make(chan bool),
|
||||
whisperListenPort: 30303,
|
||||
whisperListenAddr: "0.0.0.0",
|
||||
// TODO parametrize those
|
||||
maxUsernameLen: 40,
|
||||
whisperDataDir: "/tmp/matterbridge-status-data",
|
||||
fetchTimeout: 500 * time.Millisecond,
|
||||
fetchInterval: 500 * time.Millisecond,
|
||||
// ENS checks are slow, also db has a lock
|
||||
ensInterval: 30 * time.Second,
|
||||
ensVerifyURL: "https://mainnet.infura.io/v3/f315575765b14720b32382a61a89341a",
|
||||
ensContract: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bstatus) Connect() error {
|
||||
// Will be displayed to other users if registered
|
||||
b.ensName = b.GetString("Nick")
|
||||
|
||||
keyHex := strings.TrimPrefix(b.GetString("PrivateKey"), "0x")
|
||||
if privKey, err := crypto.HexToECDSA(keyHex); err != nil {
|
||||
return errors.Wrap(err, "Failed to parse PrivateKey")
|
||||
} else {
|
||||
b.privateKey = privKey
|
||||
}
|
||||
|
||||
b.nodeConfig = b.generateConfig()
|
||||
b.statusNode = gonode.New()
|
||||
accsMgr, _ := b.statusNode.AccountManager()
|
||||
|
||||
if err := b.statusNode.Start(b.nodeConfig, accsMgr); err != nil {
|
||||
return errors.Wrap(err, "Failed to start Status node")
|
||||
}
|
||||
|
||||
// Create a custom logger to suppress DEBUG messages
|
||||
logger, _ := zap.NewProduction()
|
||||
|
||||
// Using an in-memory SQLite DB since we have nothing worth preserving
|
||||
db, err := sql.Open("sqlite3", "file:mem?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to open sqlite database")
|
||||
}
|
||||
options := []status.Option{
|
||||
status.WithDatabase(db),
|
||||
status.WithCustomLogger(logger),
|
||||
}
|
||||
|
||||
var instID string = uuid.New().String()
|
||||
|
||||
messenger, err := status.NewMessenger(
|
||||
b.privateKey,
|
||||
gethbridge.NewNodeBridge(b.statusNode.GethNode()),
|
||||
instID,
|
||||
options...,
|
||||
)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create Messenger")
|
||||
}
|
||||
if err := messenger.Start(); err != nil {
|
||||
return errors.Wrap(err, "Failed to start Messenger")
|
||||
}
|
||||
if err := messenger.Init(); err != nil {
|
||||
return errors.Wrap(err, "Failed to init Messenger")
|
||||
}
|
||||
b.messenger = messenger
|
||||
|
||||
// Start a routine for periodically fetching messages
|
||||
go b.fetchMessagesLoop()
|
||||
// Start a routine for periodically checking ENS names
|
||||
go b.checkEnsNamesLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bstatus) Disconnect() error {
|
||||
b.stopMessagesLoop()
|
||||
b.stopEnsNamesLoop()
|
||||
if err := b.messenger.Shutdown(); err != nil {
|
||||
return errors.Wrap(err, "Failed to stop Status messenger")
|
||||
}
|
||||
if err := b.statusNode.Stop(); err != nil {
|
||||
return errors.Wrap(err, "Failed to stop Status node")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bstatus) JoinChannel(channel config.ChannelInfo) error {
|
||||
chat := status.CreatePublicChat(channel.Name, b.messenger.Timesource())
|
||||
b.messenger.Join(chat)
|
||||
b.messenger.SaveChat(&chat)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bstatus) Send(msg config.Message) (string, error) {
|
||||
if !b.Connected() {
|
||||
return "", fmt.Errorf("bridge %s not connected, dropping message %#v to bridge", b.Account, msg)
|
||||
}
|
||||
|
||||
if skipBridgeMessage(msg) {
|
||||
return "", nil
|
||||
}
|
||||
b.Log.Infof("=> Sending message %#v", msg)
|
||||
|
||||
// Use a timeout for sending messages
|
||||
ctx, cancel := context.WithTimeout(context.Background(), b.fetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
msgHash, err := b.messenger.SendChatMessage(ctx, b.genStatusMsg(msg))
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to send message")
|
||||
}
|
||||
// TODO handle the delivery event?
|
||||
return fmt.Sprintf("%#x", msgHash), nil
|
||||
}
|
||||
|
||||
func (b *Bstatus) Connected() bool {
|
||||
return b.statusNode.IsRunning()
|
||||
}
|
||||
|
||||
// Converts a bridge message into a Status message
|
||||
func (b *Bstatus) genStatusMsg(msg config.Message) (sMsg *status.Message) {
|
||||
sMsg = &status.Message{}
|
||||
sMsg.EnsName = b.ensName
|
||||
sMsg.ChatId = msg.Channel
|
||||
sMsg.ContentType = protobuf.ChatMessage_TEXT_PLAIN
|
||||
// We need to prefix messages with usernames
|
||||
sMsg.Text = fmt.Sprintf("%s%s", msg.Username, msg.Text)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a sane configuration for a Status Node
|
||||
func (b *Bstatus) generateConfig() *params.NodeConfig {
|
||||
options := []params.Option{
|
||||
params.WithFleet("eth.prod"),
|
||||
b.withListenAddr(),
|
||||
}
|
||||
|
||||
var configFiles []string
|
||||
config, err := params.NewNodeConfigWithDefaultsAndFiles(
|
||||
b.whisperDataDir,
|
||||
params.MainNetworkID,
|
||||
options,
|
||||
configFiles,
|
||||
)
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Error("Failed to generate config")
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func (b *Bstatus) stopMessagesLoop() {
|
||||
close(b.fetchDone)
|
||||
}
|
||||
|
||||
func (b *Bstatus) stopEnsNamesLoop() {
|
||||
close(b.ensDone)
|
||||
}
|
||||
|
||||
// Main loop for fetching Status messages and relaying them to the bridge
|
||||
func (b *Bstatus) fetchMessagesLoop() {
|
||||
ticker := time.NewTicker(b.fetchInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
mResp, err := b.messenger.RetrieveAll()
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Error("Failed to retrieve messages")
|
||||
continue
|
||||
}
|
||||
for _, msg := range mResp.Messages {
|
||||
if b.skipStatusMessage(msg) {
|
||||
continue
|
||||
}
|
||||
b.propagateMessage(msg)
|
||||
}
|
||||
case <-b.fetchDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bstatus) checkEnsNamesLoop() {
|
||||
ticker := time.NewTicker(b.ensInterval)
|
||||
defer ticker.Stop()
|
||||
ctx, cancelVerifyENS := context.WithCancel(context.Background())
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
_, err := b.messenger.VerifyENSNames(ctx, b.ensVerifyURL, b.ensContract)
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Error("Failed to validate ENS name")
|
||||
continue
|
||||
}
|
||||
case <-b.ensDone:
|
||||
cancelVerifyENS()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bstatus) propagateMessage(msg *status.Message) {
|
||||
pubKey := publicKeyToHex(msg.SigPubKey)
|
||||
var username string
|
||||
// Contact can have an ENS Name, but needs to be verified
|
||||
contact, err := b.messenger.GetContactByID(pubKey)
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Error("Not yet verified contact:", pubKey)
|
||||
username, err = alias.GenerateFromPublicKeyString(pubKey)
|
||||
if err != nil { // fallback to full public key
|
||||
b.Log.WithError(err).Error("Failed to generate Chat name")
|
||||
username = pubKey
|
||||
}
|
||||
} else if contact.ENSVerified { // trim our domain for brevity
|
||||
username = strings.TrimSuffix(contact.Name, ".stateofus.eth")
|
||||
} else { // fallback to 3-word chat name
|
||||
username = contact.Alias
|
||||
}
|
||||
// Trim username in case of LONG ENS names
|
||||
if len(username) > b.maxUsernameLen {
|
||||
username = username[:b.maxUsernameLen]
|
||||
}
|
||||
// Send message for processing
|
||||
b.Remote <- config.Message{
|
||||
Timestamp: time.Unix(int64(msg.WhisperTimestamp), 0),
|
||||
Username: username,
|
||||
UserID: pubKey,
|
||||
Text: msg.Text,
|
||||
Channel: msg.ChatId,
|
||||
ID: fmt.Sprintf("%#x", msg.ID),
|
||||
Account: b.Account,
|
||||
}
|
||||
}
|
||||
|
||||
// skipStatusMessage defines which Status messages can be ignored
|
||||
func (b *Bstatus) skipStatusMessage(msg *status.Message) bool {
|
||||
// skip messages from ourselves
|
||||
if isPubKeyEqual(msg.SigPubKey, &b.privateKey.PublicKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
// skip empty messages
|
||||
if msg.Text == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// skipBridgeMessage defines which messages from the bridge should be ignored
|
||||
func skipBridgeMessage(msg config.Message) bool {
|
||||
// skip delete messages
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Bstatus) withListenAddr() params.Option {
|
||||
if addr := b.GetString("ListenAddr"); addr != "" {
|
||||
b.whisperListenAddr = addr
|
||||
}
|
||||
if port := b.GetInt("ListenPort"); port != 0 {
|
||||
b.whisperListenPort = port
|
||||
}
|
||||
return func(c *params.NodeConfig) error {
|
||||
c.ListenAddr = fmt.Sprintf("%s:%d", b.whisperListenAddr, b.whisperListenPort)
|
||||
return nil
|
||||
}
|
||||
}
|
11
gateway/bridgemap/status.go
Normal file
11
gateway/bridgemap/status.go
Normal file
@ -0,0 +1,11 @@
|
||||
// +build !nostatus
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bstatus "github.com/42wim/matterbridge/bridge/status"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["status"] = bstatus.New
|
||||
}
|
10
go.mod
10
go.mod
@ -8,10 +8,12 @@ require (
|
||||
github.com/Rhymen/go-whatsapp v0.1.1-0.20200421062035-31e8111ac334
|
||||
github.com/d5/tengo/v2 v2.4.2
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/ethereum/go-ethereum v1.9.5
|
||||
github.com/fsnotify/fsnotify v1.4.9
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20181225215658-ec221ba9ea45+incompatible
|
||||
github.com/gomarkdown/markdown v0.0.0-20200127000047-1813ea067497
|
||||
github.com/google/gops v0.3.6
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/gopackage/ddp v0.0.0-20170117053602-652027933df4 // indirect
|
||||
github.com/gorilla/schema v1.1.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
@ -36,10 +38,8 @@ require (
|
||||
github.com/mrexodia/wray v0.0.0-20160318003008-78a2c1f284ff // indirect
|
||||
github.com/nelsonken/gomf v0.0.0-20180504123937-a9dd2f9deae9
|
||||
github.com/nicksnyder/go-i18n v1.4.0 // indirect
|
||||
github.com/onsi/ginkgo v1.6.0 // indirect
|
||||
github.com/onsi/gomega v1.4.1 // indirect
|
||||
github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c
|
||||
github.com/pborman/uuid v0.0.0-20160216163710-c55201b03606 // indirect
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/xid v1.2.1
|
||||
github.com/russross/blackfriday v1.5.2
|
||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca
|
||||
@ -47,12 +47,14 @@ require (
|
||||
github.com/sirupsen/logrus v1.6.0
|
||||
github.com/slack-go/slack v0.6.4
|
||||
github.com/spf13/viper v1.7.0
|
||||
github.com/status-im/status-go v0.49.0
|
||||
github.com/stretchr/testify v1.5.1
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect
|
||||
github.com/yaegashi/msgraph.go v0.1.2
|
||||
github.com/zfjagann/golang-ring v0.0.0-20190304061218-d34796e0a6c2
|
||||
go.uber.org/zap v1.13.0
|
||||
golang.org/x/image v0.0.0-20200430140353-33d19683fad8
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
|
||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||
@ -62,4 +64,6 @@ require (
|
||||
|
||||
//replace github.com/bwmarrin/discordgo v0.20.2 => github.com/matterbridge/discordgo v0.18.1-0.20200109173909-ed873362fa43
|
||||
|
||||
replace github.com/ethereum/go-ethereum v1.9.5 => github.com/status-im/go-ethereum v1.9.5-status.9
|
||||
|
||||
go 1.13
|
||||
|
Loading…
x
Reference in New Issue
Block a user