commit 2217d19353302837170dd1043040cdaf331df780 Author: Morgan McMillian Date: Fri Jul 2 16:32:03 2021 -0700 initial implementation diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3906dbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Morgan McMillian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c18c8d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.sr.ht/~thrrgilag/pnut-bridge + +go 1.16 + +require ( + git.sr.ht/~thrrgilag/woodstock v0.0.0-20210613221914-3b9e0ef92799 + github.com/gorilla/websocket v1.4.2 + github.com/smartystreets/goconvey v1.6.4 // indirect + gopkg.in/ini.v1 v1.62.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..642b5ed --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +git.sr.ht/~thrrgilag/woodstock v0.0.0-20210613221914-3b9e0ef92799 h1:iiUiZTW7yksLArreiNO1KcAkMmAvmMquz6etgaKzpCU= +git.sr.ht/~thrrgilag/woodstock v0.0.0-20210613221914-3b9e0ef92799/go.mod h1:giZm2mNVkz9BeA31LtUC2gwIYfUSUPQINp+tI8EGFTc= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/pnut-bridge.go b/pnut-bridge.go new file mode 100644 index 0000000..c67d56b --- /dev/null +++ b/pnut-bridge.go @@ -0,0 +1,345 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "time" + + "git.sr.ht/~thrrgilag/woodstock" + "github.com/gorilla/websocket" + "gopkg.in/ini.v1" +) + +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"` +} + +func subscribe(connection_id string, access_token string, rooms []Room) { + params := url.Values{} + params.Set("connection_id", connection_id) + 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 "+access_token) + 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) + //body, _ := ioutil.ReadAll(resp.Body) + //log.Println(string([]byte(body))) + } +} + +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 + } + log.Printf(">> sending message from channel %s to %s ...", msg.ChannelID, gateway) + data := MbOutMsg{ + Text: msg.Content.Text, + 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) + //body, _ := ioutil.ReadAll(resp.Body) + //log.Println(string([]byte(body))) +} + +func getRoomGateway(rooms []Room, chan_id string) string { + for _, v := range rooms { + if v.ChannelID == chan_id { + return v.Gateway + } + } + return "" +} + +func bridgeMsgHandler(conf *Config, msg MbMessage) { + client := woodstock.NewClient("", "") + client.SetAccessToken(conf.PnutAccessToken) + channel_id := getPnutChannel(conf.Rooms, msg.Gateway) + text := msg.Username + " " + msg.Text + for _, file := range msg.Extra.File { + text = text + "\n" + file.URL + // log.Println("name, ", file.Name) + // log.Println("url, ", file.URL) + // fdata, err := base64.StdEncoding.DecodeString(file.Data) + // if err != nil { + // log.Println("!! error decoding file", err) + // } + // mtype := mimetype.Detect(fdata) + // log.Println("type, ", mtype) + // if strings.HasPrefix(mtype, "image") { + // oembed := PnutOembed{ + // Version: "1.0", + // Type: "photo", + // Title: file.Name, + // Url: file.URL, + // } + // // TODO finish this when woodstock is fixed + // } + } + log.Printf(">> sending message from %s to channel %s ...", msg.Gateway, channel_id) + _, err := client.CreateMessage(channel_id, text) + if err != nil { + log.Println("!! problem posting message to pnut.io", err) + } + log.Println("<< ok") +} + +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) + + 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.Println("!! error during write to websocket:", err) + return + } + + 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{} + resp, err := client.Do(req) + if err != nil { + log.Println(">> error on reponse", err) + } + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + //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 + } + } +}