From 214a6a13861b9fd495797386f36507373728e577 Mon Sep 17 00:00:00 2001 From: Sebastian P <5564491+s3lph@users.noreply.github.com> Date: Thu, 1 Oct 2020 22:50:56 +0200 Subject: Add Mumble support (#1245) --- bridge/mumble/handlers.go | 90 ++++++++++++++++ bridge/mumble/helpers.go | 143 +++++++++++++++++++++++++ bridge/mumble/mumble.go | 259 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 bridge/mumble/handlers.go create mode 100644 bridge/mumble/helpers.go create mode 100644 bridge/mumble/mumble.go (limited to 'bridge/mumble') diff --git a/bridge/mumble/handlers.go b/bridge/mumble/handlers.go new file mode 100644 index 00000000..a6845955 --- /dev/null +++ b/bridge/mumble/handlers.go @@ -0,0 +1,90 @@ +package bmumble + +import ( + "strconv" + "time" + + "layeh.com/gumble/gumble" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" +) + +func (b *Bmumble) handleServerConfig(event *gumble.ServerConfigEvent) { + b.serverConfigUpdate <- *event +} + +func (b *Bmumble) handleTextMessage(event *gumble.TextMessageEvent) { + sender := "unknown" + if event.TextMessage.Sender != nil { + sender = event.TextMessage.Sender.Name + } + // Convert Mumble HTML messages to markdown + parts, err := b.convertHTMLtoMarkdown(event.TextMessage.Message) + if err != nil { + b.Log.Error(err) + } + now := time.Now().UTC() + for i, part := range parts { + // Construct matterbridge message and pass on to the gateway + rmsg := config.Message{ + Channel: strconv.FormatUint(uint64(event.Client.Self.Channel.ID), 10), + Username: sender, + UserID: sender + "@" + b.Host, + Account: b.Account, + } + if part.Image == nil { + rmsg.Text = part.Text + } else { + fname := b.Account + "_" + strconv.FormatInt(now.UnixNano(), 10) + "_" + strconv.Itoa(i) + part.FileExtension + rmsg.Extra = make(map[string][]interface{}) + if err = helper.HandleDownloadSize(b.Log, &rmsg, fname, int64(len(part.Image)), b.General); err != nil { + b.Log.WithError(err).Warn("not including image in message") + continue + } + helper.HandleDownloadData(b.Log, &rmsg, fname, "", "", &part.Image, b.General) + } + b.Log.Debugf("Sending message to gateway: %+v", rmsg) + b.Remote <- rmsg + } +} + +func (b *Bmumble) handleConnect(event *gumble.ConnectEvent) { + // Set the user's "bio"/comment + if comment := b.GetString("UserComment"); comment != "" && event.Client.Self != nil { + event.Client.Self.SetComment(comment) + } + // No need to talk or listen + event.Client.Self.SetSelfDeafened(true) + event.Client.Self.SetSelfMuted(true) + // if the Channel variable is set, this is a reconnect -> rejoin channel + if b.Channel != nil { + if err := b.doJoin(event.Client, *b.Channel); err != nil { + b.Log.Error(err) + } + b.Remote <- config.Message{ + Username: "system", + Text: "rejoin", + Channel: "", + Account: b.Account, + Event: config.EventRejoinChannels, + } + } +} + +func (b *Bmumble) handleUserChange(event *gumble.UserChangeEvent) { + // Only care about changes to self + if event.User != event.Client.Self { + return + } + // Someone attempted to move the user out of the configured channel; attempt to join back + if b.Channel != nil { + if err := b.doJoin(event.Client, *b.Channel); err != nil { + b.Log.Error(err) + } + } +} + +func (b *Bmumble) handleDisconnect(event *gumble.DisconnectEvent) { + b.connected <- *event +} diff --git a/bridge/mumble/helpers.go b/bridge/mumble/helpers.go new file mode 100644 index 00000000..c828df2c --- /dev/null +++ b/bridge/mumble/helpers.go @@ -0,0 +1,143 @@ +package bmumble + +import ( + "fmt" + "mime" + "net/http" + "regexp" + "strings" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/mattn/godown" + "github.com/vincent-petithory/dataurl" +) + +type MessagePart struct { + Text string + FileExtension string + Image []byte +} + +func (b *Bmumble) decodeImage(uri string, parts *[]MessagePart) error { + // Decode the data:image/... URI + image, err := dataurl.DecodeString(uri) + if err != nil { + b.Log.WithError(err).Info("No image extracted") + return err + } + // Determine the file extensions for that image + ext, err := mime.ExtensionsByType(image.MediaType.ContentType()) + if err != nil || len(ext) == 0 { + b.Log.WithError(err).Infof("No file extension registered for MIME type '%s'", image.MediaType.ContentType()) + return err + } + // Add the image to the MessagePart slice + *parts = append(*parts, MessagePart{"", ext[0], image.Data}) + return nil +} + +func (b *Bmumble) tokenize(t *string) ([]MessagePart, error) { + // `^(.*?)` matches everything before the image + // `!\[[^\]]*\]\(` matches the `![alt](` part of markdown images + // `(data:image\/[^)]+)` matches the data: URI used by Mumble + // `\)` matches the closing parenthesis after the URI + // `(.*)$` matches the remaining text to be examined in the next iteration + p := regexp.MustCompile(`^(?ms)(.*?)!\[[^\]]*\]\((data:image\/[^)]+)\)(.*)$`) + remaining := *t + var parts []MessagePart + for { + tokens := p.FindStringSubmatch(remaining) + if tokens == nil { + // no match -> remaining string is non-image text + pre := strings.TrimSpace(remaining) + if len(pre) > 0 { + parts = append(parts, MessagePart{pre, "", nil}) + } + return parts, nil + } + + // tokens[1] is the text before the image + if len(tokens[1]) > 0 { + pre := strings.TrimSpace(tokens[1]) + parts = append(parts, MessagePart{pre, "", nil}) + } + // tokens[2] is the image URL + uri, err := dataurl.UnescapeToString(strings.TrimSpace(strings.ReplaceAll(tokens[2], " ", ""))) + if err != nil { + b.Log.WithError(err).Info("URL unescaping failed") + remaining = strings.TrimSpace(tokens[3]) + continue + } + err = b.decodeImage(uri, &parts) + if err != nil { + b.Log.WithError(err).Info("Decoding the image failed") + } + // tokens[3] is the text after the image, processed in the next iteration + remaining = strings.TrimSpace(tokens[3]) + } +} + +func (b *Bmumble) convertHTMLtoMarkdown(html string) ([]MessagePart, error) { + var sb strings.Builder + err := godown.Convert(&sb, strings.NewReader(html), nil) + if err != nil { + return nil, err + } + markdown := sb.String() + b.Log.Debugf("### to markdown: %s", markdown) + return b.tokenize(&markdown) +} + +func (b *Bmumble) extractFiles(msg *config.Message) []config.Message { + var messages []config.Message + if msg.Extra == nil || len(msg.Extra["file"]) == 0 { + return messages + } + // Create a separate message for each file + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + imsg := config.Message{ + Channel: msg.Channel, + Username: msg.Username, + UserID: msg.UserID, + Account: msg.Account, + Protocol: msg.Protocol, + Timestamp: msg.Timestamp, + Event: "mumble_image", + } + // If no data is present for the file, send a link instead + if fi.Data == nil || len(*fi.Data) == 0 { + if len(fi.URL) > 0 { + imsg.Text = fmt.Sprintf(`%s`, fi.URL, fi.URL) + messages = append(messages, imsg) + } else { + b.Log.Infof("Not forwarding file without local data") + } + continue + } + mimeType := http.DetectContentType(*fi.Data) + // Mumble only supports images natively, send a link instead + if !strings.HasPrefix(mimeType, "image/") { + if len(fi.URL) > 0 { + imsg.Text = fmt.Sprintf(`%s`, fi.URL, fi.URL) + messages = append(messages, imsg) + } else { + b.Log.Infof("Not forwarding file of type %s", mimeType) + } + continue + } + mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) + // Build data:image/...;base64,... style image URL and embed image directly into the message + du := dataurl.New(*fi.Data, mimeType) + dataURL, err := du.MarshalText() + if err != nil { + b.Log.WithError(err).Infof("Image Serialization into data URL failed (type: %s, length: %d)", mimeType, len(*fi.Data)) + continue + } + imsg.Text = fmt.Sprintf(``, dataURL) + messages = append(messages, imsg) + } + // Remove files from original message + msg.Extra["file"] = nil + return messages +} diff --git a/bridge/mumble/mumble.go b/bridge/mumble/mumble.go new file mode 100644 index 00000000..2281d1c2 --- /dev/null +++ b/bridge/mumble/mumble.go @@ -0,0 +1,259 @@ +package bmumble + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/ioutil" + "net" + "strconv" + "time" + + "layeh.com/gumble/gumble" + "layeh.com/gumble/gumbleutil" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + stripmd "github.com/writeas/go-strip-markdown" + + // We need to import the 'data' package as an implicit dependency. + // See: https://godoc.org/github.com/paulrosania/go-charset/charset + _ "github.com/paulrosania/go-charset/data" +) + +type Bmumble struct { + client *gumble.Client + Nick string + Host string + Channel *uint32 + local chan config.Message + running chan error + connected chan gumble.DisconnectEvent + serverConfigUpdate chan gumble.ServerConfigEvent + serverConfig gumble.ServerConfigEvent + tlsConfig tls.Config + + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bmumble{} + b.Config = cfg + b.Nick = b.GetString("Nick") + b.local = make(chan config.Message) + b.running = make(chan error) + b.connected = make(chan gumble.DisconnectEvent) + b.serverConfigUpdate = make(chan gumble.ServerConfigEvent) + return b +} + +func (b *Bmumble) Connect() error { + b.Log.Infof("Connecting %s", b.GetString("Server")) + host, portstr, err := net.SplitHostPort(b.GetString("Server")) + if err != nil { + return err + } + b.Host = host + _, err = strconv.Atoi(portstr) + if err != nil { + return err + } + + if err = b.buildTLSConfig(); err != nil { + return err + } + + go b.doSend() + go b.connectLoop() + err = <-b.running + return err +} + +func (b *Bmumble) Disconnect() error { + return b.client.Disconnect() +} + +func (b *Bmumble) JoinChannel(channel config.ChannelInfo) error { + cid, err := strconv.ParseUint(channel.Name, 10, 32) + if err != nil { + return err + } + channelID := uint32(cid) + if b.Channel != nil && *b.Channel != channelID { + b.Log.Fatalf("Cannot join channel ID '%d', already joined to channel ID %d", channelID, *b.Channel) + return errors.New("the Mumble bridge can only join a single channel") + } + b.Channel = &channelID + return b.doJoin(b.client, channelID) +} + +func (b *Bmumble) Send(msg config.Message) (string, error) { + // Only process text messages + b.Log.Debugf("=> Received local message %#v", msg) + if msg.Event != "" && msg.Event != config.EventUserAction { + return "", nil + } + + attachments := b.extractFiles(&msg) + b.local <- msg + for _, a := range attachments { + b.local <- a + } + return "", nil +} + +func (b *Bmumble) buildTLSConfig() error { + b.tlsConfig = tls.Config{} + // Load TLS client certificate keypair required for registered user authentication + if cpath := b.GetString("TLSClientCertificate"); cpath != "" { + if ckey := b.GetString("TLSClientKey"); ckey != "" { + cert, err := tls.LoadX509KeyPair(cpath, ckey) + if err != nil { + return err + } + b.tlsConfig.Certificates = []tls.Certificate{cert} + } + } + // Load TLS CA used for server verification. If not provided, the Go system trust anchor is used + if capath := b.GetString("TLSCACertificate"); capath != "" { + ca, err := ioutil.ReadFile(capath) + if err != nil { + return err + } + b.tlsConfig.RootCAs = x509.NewCertPool() + b.tlsConfig.RootCAs.AppendCertsFromPEM(ca) + } + b.tlsConfig.InsecureSkipVerify = b.GetBool("SkipTLSVerify") + return nil +} + +func (b *Bmumble) connectLoop() { + firstConnect := true + for { + err := b.doConnect() + if firstConnect { + b.running <- err + } + if err != nil { + b.Log.Errorf("Connection to server failed: %#v", err) + if firstConnect { + break + } else { + b.Log.Info("Retrying in 10s") + time.Sleep(10 * time.Second) + continue + } + } + firstConnect = false + d := <-b.connected + switch d.Type { + case gumble.DisconnectError: + b.Log.Errorf("Lost connection to the server (%s), attempting reconnect", d.String) + continue + case gumble.DisconnectKicked: + b.Log.Errorf("Kicked from the server (%s), attempting reconnect", d.String) + continue + case gumble.DisconnectBanned: + b.Log.Errorf("Banned from the server (%s), not attempting reconnect", d.String) + close(b.connected) + close(b.running) + return + case gumble.DisconnectUser: + b.Log.Infof("Disconnect successful") + close(b.connected) + close(b.running) + return + } + } +} + +func (b *Bmumble) doConnect() error { + // Create new gumble config and attach event handlers + gumbleConfig := gumble.NewConfig() + gumbleConfig.Attach(gumbleutil.Listener{ + ServerConfig: b.handleServerConfig, + TextMessage: b.handleTextMessage, + Connect: b.handleConnect, + Disconnect: b.handleDisconnect, + UserChange: b.handleUserChange, + }) + gumbleConfig.Username = b.GetString("Nick") + if password := b.GetString("Password"); password != "" { + gumbleConfig.Password = password + } + + client, err := gumble.DialWithDialer(new(net.Dialer), b.GetString("Server"), gumbleConfig, &b.tlsConfig) + if err != nil { + return err + } + b.client = client + return nil +} + +func (b *Bmumble) doJoin(client *gumble.Client, channelID uint32) error { + channel, ok := client.Channels[channelID] + if !ok { + return fmt.Errorf("no channel with ID %d", channelID) + } + client.Self.Move(channel) + return nil +} + +func (b *Bmumble) doSend() { + // Message sending loop that makes sure server-side + // restrictions and client-side message traits don't conflict + // with each other. + for { + select { + case serverConfig := <-b.serverConfigUpdate: + b.Log.Debugf("Received server config update: AllowHTML=%#v, MaximumMessageLength=%#v", serverConfig.AllowHTML, serverConfig.MaximumMessageLength) + b.serverConfig = serverConfig + case msg := <-b.local: + b.processMessage(&msg) + } + } +} + +func (b *Bmumble) processMessage(msg *config.Message) { + b.Log.Debugf("Processing message %s", msg.Text) + + allowHTML := true + if b.serverConfig.AllowHTML != nil { + allowHTML = *b.serverConfig.AllowHTML + } + + // If this is a specially generated image message, send it unmodified + if msg.Event == "mumble_image" { + if allowHTML { + b.client.Self.Channel.Send(msg.Username+msg.Text, false) + } else { + b.Log.Info("Can't send image, server does not allow HTML messages") + } + return + } + + // Don't process empty messages + if len(msg.Text) == 0 { + return + } + // If HTML is allowed, convert markdown into HTML, otherwise strip markdown + if allowHTML { + msg.Text = helper.ParseMarkdown(msg.Text) + } else { + msg.Text = stripmd.Strip(msg.Text) + } + + // If there is a maximum message length, split and truncate the lines + var msgLines []string + if maxLength := b.serverConfig.MaximumMessageLength; maxLength != nil { + msgLines = helper.GetSubLines(msg.Text, *maxLength-len(msg.Username)) + } else { + msgLines = helper.GetSubLines(msg.Text, 0) + } + // Send the individual lindes + for i := range msgLines { + b.client.Self.Channel.Send(msg.Username+msgLines[i], false) + } +} -- cgit v1.2.3