pnut-bridge/pnut-bridge.go
Morgan McMillian db21b02342
All checks were successful
thrrgilag/pnut-bridge/pipeline/head This commit looks good
Add media links to messages from pnut.io
Updated to woodstock v0.4.0 and added links to media from pnut.io
messages.
2023-12-17 18:15:31 -08:00

420 lines
10 KiB
Go

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
}
}
}