package main import ( "bytes" "encoding/base64" "encoding/json" "flag" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io/ioutil" "log" "net/http" "net/url" "os" "strings" "time" "github.com/gabriel-vasile/mimetype" "github.com/gorilla/websocket" "github.com/mitchellh/go-wordwrap" "gopkg.in/ini.v1" "mcmillian.dev/go/woodstock" ) var cfgfile *string var done chan interface{} var interrupt chan os.Signal type Config struct { PnutAccessToken string `ini:"pnut_access_token"` PnutUsername string `ini:"pnut_username"` MbURL string `ini:"matterbridge_url"` MbToken string `ini:"matterbridge_token"` Rooms []Room } type Room struct { ChannelID string `ini:"pnut_channel"` Gateway string `ini:"gateway"` } type Meta struct { Timestamp int `json:"timestamp"` ID string `json:"id"` ChannelType string `json:"channel_type"` SubscriptionIds []string `json:"subscription_ids"` Stream Stream `json:"stream"` ConnectionID string `json:"connection_id"` } type Stream struct { Endpoint string `json:"endpoint"` } type Msg struct { Meta Meta `json:"meta"` } type Messages struct { Meta Meta `json:"meta"` Data []woodstock.Message `json:"data"` } type MbMessage struct { Text string `json:"text"` Channel string `json:"channel"` Username string `json:"username"` UserID string `json:"userid"` Avatar string `json:"avatar"` Account string `json:"account"` Event string `json:"event"` Protocol string `json:"protocol"` Gateway string `json:"gateway"` ParentID string `json:"parent_id"` Timestamp string `json:"timestamp"` ID string `json:"id"` Extra MbExtra `json:"extra"` } type MbExtra struct { File []MbFile `json:"file"` } type MbFile struct { Name string `json:"name"` Data string `json:"data"` Comment string `json:"comment"` URL string `json:"url"` Size int `json:"size"` Avatar bool `json:"avatar"` SHA string `json:"sha"` } type MbOutMsg struct { Text string `json:"text"` Username string `json:"username"` Gateway string `json:"gateway"` } type PnutOembed struct { Version string `json:"version"` Type string `json:"type"` Title string `json:"title"` URL string `json:"url"` Width int `json:"width"` Height int `json:"height"` } type Raw struct { Type string `json:"type"` Value interface{} `json:"value"` } func subscribe(connectionID string, accessToken string, rooms []Room) { params := url.Values{} params.Set("connection_id", connectionID) for _, _room := range rooms { log.Printf(">> connecting channel %s ...", _room.ChannelID) url := "https://api.pnut.io/v1/channels/" + _room.ChannelID + "/messages?" + params.Encode() req, err := http.NewRequest("GET", url, nil) if err != nil { log.Println("!! unable to create request", err) } req.Header.Set("User-Agent", "pnut-bridge") req.Header.Add("Authorization", "Bearer "+accessToken) client := &http.Client{} resp, err := client.Do(req) if err != nil { log.Println("!! error on reponse", err) } defer resp.Body.Close() log.Printf("<< %s", resp.Status) } } func receiveHandler(connection *websocket.Conn, conf *Config) { defer close(done) for { _, msg, err := connection.ReadMessage() if err != nil { log.Println("!! error in websocket receive:", err) return } log.Printf("<< received: %s", msg) var m Msg if err := json.Unmarshal(msg, &m); err != nil { log.Println("!! error unmarshaling msg", err) } if len(m.Meta.ConnectionID) > 0 { log.Println("<< connection", m.Meta.ConnectionID) subscribe(m.Meta.ConnectionID, conf.PnutAccessToken, conf.Rooms) continue } switch m.Meta.ChannelType { case "io.pnut.core.chat": var messages Messages if err := json.Unmarshal(msg, &messages); err != nil { log.Println("!! error unmarshaling message objects", err) } for _, _message := range messages.Data { pnutMsgHandler(conf, _message) } default: } } } func pnutMsgHandler(conf *Config, msg woodstock.Message) { gateway := getRoomGateway(conf.Rooms, msg.ChannelID) if len(gateway) < 1 { return } if msg.User.Username == conf.PnutUsername { return } msgtext := msg.Content.Text linktext := "" for _, v := range msg.Content.Entities.Links { linktext = "| " + linktext + v.URL + "\n" } for rawtype, value := range msg.Raw { if rawtype == "io.pnut.core.oembed" { for _, v := range value { var embed woodstock.RawEmbed byteData, _ := json.Marshal(v) jerr := json.Unmarshal(byteData, &embed) if jerr != nil { log.Println("Error unmarshaling raw", jerr) } switch embed.Type { case "photo": var photo woodstock.PhotoEmbed jerr := json.Unmarshal(byteData, &photo) if jerr != nil { log.Println("Error unmarshaling photo", jerr) } linktext = linktext + "🖼️\n" + photo.URL + "\n" case "video": var video woodstock.VideoEmbed jerr := json.Unmarshal(byteData, &video) if jerr != nil { log.Println("Error unmarshaling video", jerr) } linktext = linktext + "📽️\n" + video.URL + "\n" case "audio": var audio woodstock.AudioEmbed jerr := json.Unmarshal(byteData, &audio) if jerr != nil { log.Println("Error unmarshaling audio", jerr) } linktext = linktext + "🔈\n" + audio.URL + "\n" default: } } } } if len(linktext) > 0 { msgtext = msgtext + "\n\n" + linktext } log.Printf(">> sending message from channel %s to %s ...", msg.ChannelID, gateway) data := MbOutMsg{ Text: msgtext, Username: msg.User.Username, Gateway: gateway, } jsonStr, err := json.Marshal(data) if err != nil { log.Println("!! there is a problem creating json string", err) return } url := conf.MbURL + "/api/message" req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr)) if err != nil { log.Println("!! unable to create request", err) } req.Header.Set("User-Agent", "pnut-bridge") req.Header.Set("Content-Type", "application/json; charset=UTF-8") req.Header.Add("Authorization", "Bearer "+conf.MbToken) client := &http.Client{} resp, err := client.Do(req) if err != nil { log.Println("!! error on reponse", err) } defer resp.Body.Close() log.Printf("<< %s", resp.Status) } func getRoomGateway(rooms []Room, chanID string) string { for _, v := range rooms { if v.ChannelID == chanID { return v.Gateway } } return "" } func bridgeMsgHandler(conf *Config, msg MbMessage) { client := woodstock.NewClient("", "") client.SetAccessToken(conf.PnutAccessToken) channelID := getPnutChannel(conf.Rooms, msg.Gateway) text := msg.Text var raw []Raw for _, file := range msg.Extra.File { fdata, err := base64.StdEncoding.DecodeString(file.Data) if err != nil { log.Println("!! error decoding file", err) } mtype := mimetype.Detect(fdata) ir := bytes.NewReader(fdata) im, _, err := image.DecodeConfig(ir) if err != nil { log.Println("Couldn't decode image?", err) } if strings.HasPrefix(mtype.String(), "image") { oembed := PnutOembed{ Version: "1.0", Type: "photo", Title: file.Name, URL: file.URL, Width: im.Width, Height: im.Height, } raw = append(raw, Raw{Type: "io.pnut.core.oembed", Value: oembed}) } else { text = text + "\n" + file.URL } } maxlen := 2048 prelen := len(msg.Username) + 1 wrapped := wordwrap.WrapString(text, uint(maxlen-prelen)) lines := strings.Split(wrapped, "\n") for _, line := range lines { line := msg.Username + " " + line log.Printf(">> sending message from %s to channel %s ...", msg.Gateway, channelID) newmessage := woodstock.NewMessage{Text: line, Raw: raw} _, err := client.CreateMessage(channelID, newmessage, url.Values{}) if err != nil { log.Println("!! problem posting message to pnut.io", err) } log.Println("<< ok") raw = nil } } func getPnutChannel(rooms []Room, gateway string) string { for _, v := range rooms { if v.Gateway == gateway { return v.ChannelID } } return "" } func init() { cfgfile = flag.String("c", "config.ini", "config file") } func main() { flag.Parse() done = make(chan interface{}) interrupt = make(chan os.Signal) conf := new(Config) file, err := ini.Load(*cfgfile) if err != nil { log.Printf("!! there was a problem loading the config: %v", err) os.Exit(1) } var rooms []Room for _, _sec := range file.SectionStrings() { if _sec == "DEFAULT" { err = file.Section("DEFAULT").MapTo(conf) if err != nil { log.Println("!! unable to map default config section", err) } } else { room := new(Room) err = file.Section(_sec).MapTo(room) if err != nil { log.Printf("!! unable to map config section %s: %v", _sec, err) } rooms = append(rooms, *room) } } conf.Rooms = rooms params := url.Values{} params.Set("access_token", conf.PnutAccessToken) params.Set("include_raw", "1") socketURL := "wss://stream.pnut.io/v1/user?" + params.Encode() conn, _, err := websocket.DefaultDialer.Dial(socketURL, nil) if err != nil { log.Fatal("!! error connectiong to pnut.io:", err) } defer conn.Close() go receiveHandler(conn, conf) for { select { case <-time.After(time.Duration(1) * time.Millisecond * 1000): err := conn.WriteMessage(websocket.TextMessage, []byte(".")) if err != nil { log.Fatal("!! error during write to websocket:", err) } url := conf.MbURL + "/api/messages" req, err := http.NewRequest("GET", url, nil) if err != nil { log.Println("!! unable to create request", err) } req.Header.Set("User-Agent", "pnut-bridge") req.Header.Add("Authorization", "Bearer "+conf.MbToken) client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Do(req) if err != nil { log.Println(">> error on reponse", err) } body, _ := ioutil.ReadAll(resp.Body) resp.Body.Close() //log.Println("<< ", string([]byte(body))) var m []MbMessage jerr := json.Unmarshal(body, &m) if jerr != nil { log.Println("Error unmarshaling msg", jerr) } if len(m) > 0 { for _, _m := range m { bridgeMsgHandler(conf, _m) } } case <-interrupt: log.Println("Received SIGINT.") err := conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) if err != nil { log.Println("Error during closing websocket:", err) return } select { case <-done: log.Println("Receiver Channel Closed! Exiting...") case <-time.After(time.Duration(1) * time.Second): log.Println("Timeout in closing receiving channel. Exiting...") } return } } }