From cbae29b5dd0383d45536741579bf160bba530833 Mon Sep 17 00:00:00 2001 From: 7x11x13 Date: Fri, 12 Jul 2024 13:15:24 -0400 Subject: [PATCH] Implement soulseek bridge --- bridge/soulseek/handlers.go | 87 ++++++++ bridge/soulseek/messages.go | 366 +++++++++++++++++++++++++++++++++ bridge/soulseek/soulseek.go | 231 +++++++++++++++++++++ gateway/bridgemap/bsoulseek.go | 11 + 4 files changed, 695 insertions(+) create mode 100644 bridge/soulseek/handlers.go create mode 100644 bridge/soulseek/messages.go create mode 100644 bridge/soulseek/soulseek.go create mode 100644 gateway/bridgemap/bsoulseek.go diff --git a/bridge/soulseek/handlers.go b/bridge/soulseek/handlers.go new file mode 100644 index 00000000..d4440ece --- /dev/null +++ b/bridge/soulseek/handlers.go @@ -0,0 +1,87 @@ +package bsoulseek + +import ( + "fmt" + "strings" + + "github.com/42wim/matterbridge/bridge/config" +) + +func (b *Bsoulseek) handleMessage(msg soulseekMessageResponse) { + if msg != nil { + b.Log.Debugf("Handling message: %v", msg) + } + switch msg := msg.(type) { + case loginMessageResponseSuccess, loginMessageResponseFailure: + b.loginResponse <- msg + case joinRoomMessageResponse: + b.joinRoomResponse <- msg + case kickedMessageResponse: + b.fatalErrors <- fmt.Errorf("Logged in somewhere else") + case privateMessageReceive: + b.handleDM(msg) + case sayChatroomMessageReceive: + b.handleChatMessage(msg) + case userJoinedRoomMessage: + b.handleJoinMessage(msg) + case userLeftRoomMessage: + b.handleLeaveMessage(msg) + default: + // do nothing + } +} + +func (b *Bsoulseek) handleChatMessage(msg sayChatroomMessageReceive) { + b.Log.Debugf("Handle chat message: %v", msg) + if msg.Username == b.Config.GetString("Nick") { + return + } + bridgeMessage := config.Message{ + Account: b.Account, + Text: msg.Message, + Channel: msg.Room, + Username: msg.Username, + } + b.local <- bridgeMessage +} + +func (b *Bsoulseek) handleJoinMessage(msg userJoinedRoomMessage) { + b.Log.Debugf("Handle join message: %v", msg) + if msg.Username == b.Config.GetString("Nick") { + return + } + bridgeMessage := config.Message{ + Account: b.Account, + Event: config.EventJoinLeave, + Text: fmt.Sprintf("%s has joined the room", msg.Username), + Channel: msg.Room, + Username: "system", + } + b.local <- bridgeMessage +} + +func (b *Bsoulseek) handleLeaveMessage(msg userLeftRoomMessage) { + b.Log.Debugf("Handle leave message: %v", msg) + if msg.Username == b.Config.GetString("Nick") { + return + } + bridgeMessage := config.Message{ + Account: b.Account, + Event: config.EventJoinLeave, + Text: fmt.Sprintf("%s has left the room", msg.Username), + Channel: msg.Room, + Username: "system", + } + b.local <- bridgeMessage +} + +func (b *Bsoulseek) handleDM(msg privateMessageReceive) { + b.Log.Debugf("Received private message: %+v", msg) + if msg.Username == "server" { + b.Log.Infof("Received system message: %s", msg.Message) + if strings.HasPrefix(msg.Message, "System Message: You have been banned") { + b.Log.Errorf("Banned from server. Message: %s", msg.Message) + b.doDisconnect() + } + } +} \ No newline at end of file diff --git a/bridge/soulseek/messages.go b/bridge/soulseek/messages.go new file mode 100644 index 00000000..afcb6d55 --- /dev/null +++ b/bridge/soulseek/messages.go @@ -0,0 +1,366 @@ +package bsoulseek + +import ( + "bytes" + "crypto/md5" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "reflect" +) + +type soulseekMessage interface {} +type soulseekMessageResponse interface {} + +const ( + loginMessageCode uint32 = 1 + sayInChatRoomMessageCode uint32 = 13 + joinRoomMessageCode uint32 = 14 + userJoinedRoomMessageCode uint32 = 16 + userLeftRoomMessageCode uint32 = 17 + privateMessageCode uint32 = 22 + kickedMessageCode uint32 = 41 + changePasswordMessageCode uint32 = 142 +) + +var ignoreMessageCodes = map[uint32]bool { + 7: true, + 64: true, + 69: true, + 83: true, + 84: true, + 104: true, + 113: true, + 114: true, + 115: true, + 130: true, + 133: true, + 139: true, + 140: true, + 145: true, + 146: true, + 148: true, + 160: true, + 1003: true, +} + + +// 1: Login +type loginMessage struct { + Code uint32 + Username string + Password string + Version uint32 + Hash string + MinorVersion uint32 +} + +type loginMessageResponseSuccess struct { + Greet string + Address uint32 + Hash string + IsSupporter bool +} + +type loginMessageResponseFailure struct { + Reason string +} + + +// 13: Say in chatroom +type sayChatroomMessage struct { + Code uint32 + Room string + Message string +} + +type sayChatroomMessageReceive struct { + Room string + Username string + Message string +} + + +// 14: Join room +type joinRoomMessage struct { + Code uint32 + Room string + Private uint32 +} + +type userStat struct { + AvgSpeed uint32 + UploadNum uint64 + Files uint32 + Dirs uint32 +} + +type joinRoomMessageResponse struct { + Room string + Users []string + Statuses []uint32 + Stats []userStat + SlotsFree []uint32 + Countries []uint32 + Owner string + Operators []string +} + + +// 16: User joined room +type userJoinedRoomMessage struct { + Room string + Username string + Status uint32 + AvgSpeed uint32 + UploadNum uint64 + Files uint32 + Dirs uint32 + SlotsFree uint32 + CountryCode string +} + + +// 16: User left room +type userLeftRoomMessage struct { + Room string + Username string +} + + +// 22: Private message (sometimes used by server to tell us errors) +type privateMessageReceive struct { + ID uint32 + Timestamp uint32 + Username string + Message string + NewMessage bool +} + + +// 41: Kicked from server (relogged) +type kickedMessageResponse struct {} + + +// 142: Change password +type changePasswordMessage struct { + Code uint32 + Password string +} + +type changePasswordMessageResponse struct { + Password string +} + + +func packMessage(message soulseekMessage) ([]byte, error) { + buf := &bytes.Buffer{} + var length uint32 = 0 + binary.Write(buf, binary.LittleEndian, length) // placeholder + v := reflect.ValueOf(message) + var err error + for i := range v.NumField() { + val := v.Field(i).Interface() + switch val := val.(type) { + case string: + s_len := uint32(len(val)) + err = binary.Write(buf, binary.LittleEndian, s_len) + buf.WriteString(val) + length += s_len + 4 + case bool, uint8: + length += 1 + err = binary.Write(buf, binary.LittleEndian, val) + case uint16: + length += 2 + err = binary.Write(buf, binary.LittleEndian, val) + case uint32: + length += 4 + err = binary.Write(buf, binary.LittleEndian, val) + case uint64: + length += 8 + err = binary.Write(buf, binary.LittleEndian, val) + default: + panic("Unsupported struct field type") + } + if err != nil { + return nil, err + } + } + bytes := buf.Bytes() + binary.LittleEndian.PutUint32(bytes, length) + return bytes, nil +} + +func unpackStructField(reader io.Reader, field reflect.Value) error { + switch field.Kind() { + case reflect.Struct: + for i := range field.NumField() { + field := field.Field(i) + err := unpackStructField(reader, field) + if err != nil { + return err + } + } + case reflect.Slice: + var length uint32 + err := binary.Read(reader, binary.LittleEndian, &length) + if err != nil { + return err + } + ilen := int(length) + newval := reflect.MakeSlice(field.Type(), ilen, ilen) + field.Set(newval) + for j := range ilen { + err := unpackStructField(reader, field.Index(j)) + if err != nil { + return err + } + } + case reflect.String: + var length uint32 + err := binary.Read(reader, binary.LittleEndian, &length) + if err != nil { + return err + } + val := make([]byte, length) + _, err = reader.Read(val) + if err != nil { + return err + } + field.SetString(string(val)) + case reflect.Bool: + var val bool + err := binary.Read(reader, binary.LittleEndian, &val) + if err != nil { + return err + } + field.SetBool(val) + case reflect.Uint8: + var val uint8 + err := binary.Read(reader, binary.LittleEndian, &val) + if err != nil { + return err + } + field.SetUint(uint64(val)) + case reflect.Uint16: + var val uint16 + err := binary.Read(reader, binary.LittleEndian, &val) + if err != nil { + return err + } + field.SetUint(uint64(val)) + case reflect.Uint32: + var val uint32 + err := binary.Read(reader, binary.LittleEndian, &val) + if err != nil { + return err + } + field.SetUint(uint64(val)) + case reflect.Uint64: + var val uint64 + err := binary.Read(reader, binary.LittleEndian, &val) + if err != nil { + return err + } + field.SetUint(val) + default: + panic(fmt.Sprintf("Unsupported struct field type: %d", field.Kind())) + } + return nil +} + +func unpackMessage[T soulseekMessage](reader io.Reader) (T, error) { + var data T + v := reflect.ValueOf(&data).Elem() + err := unpackStructField(reader, v) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return data, err + } + return data, nil +} + +func readMessage(reader io.Reader) (soulseekMessage, error) { + var length uint32 + err := binary.Read(reader, binary.LittleEndian, &length) + if err != nil { + return nil, err + } + + buf := make([]byte, int(length)) + _, err = io.ReadAtLeast(reader, buf, len(buf)) + if err != nil { + return nil, err + } + + reader = bytes.NewReader(buf) + + var code uint32 + err = binary.Read(reader, binary.LittleEndian, &code) + if err != nil { + return nil, err + } + switch code { + case loginMessageCode: + // login message is special; has two possible responses + var success bool + err := binary.Read(reader, binary.LittleEndian, &success) + if err != nil { + return nil, err + } + if success { + return unpackMessage[loginMessageResponseSuccess](reader) + } else { + return unpackMessage[loginMessageResponseFailure](reader) + } + case sayInChatRoomMessageCode: + return unpackMessage[sayChatroomMessageReceive](reader) + case joinRoomMessageCode: + return unpackMessage[joinRoomMessageResponse](reader) + case kickedMessageCode: + return unpackMessage[kickedMessageResponse](reader) + case userJoinedRoomMessageCode: + return unpackMessage[userJoinedRoomMessage](reader) + case userLeftRoomMessageCode: + return unpackMessage[userLeftRoomMessage](reader) + case changePasswordMessageCode: + return unpackMessage[changePasswordMessageResponse](reader) + case privateMessageCode: + return unpackMessage[privateMessageReceive](reader) + default: + _, ignore := ignoreMessageCodes[code] + if ignore { + return nil, nil + } + return nil, fmt.Errorf("Unknown message code: %d", code) + } +} + +func makeLoginMessage(username string, password string) soulseekMessage { + hash := md5.Sum([]byte(username + password)) + msg := loginMessage{ + loginMessageCode, + username, + password, + 160, + hex.EncodeToString(hash[:]), + 1, + } + return msg +} + +func makeJoinRoomMessage(room string) joinRoomMessage { + return joinRoomMessage{ + joinRoomMessageCode, + room, + 0, + } +} + +func makeSayChatroomMessage(room string, text string) sayChatroomMessage { + return sayChatroomMessage{ + sayInChatRoomMessageCode, + room, + text, + } +} \ No newline at end of file diff --git a/bridge/soulseek/soulseek.go b/bridge/soulseek/soulseek.go new file mode 100644 index 00000000..9042afa1 --- /dev/null +++ b/bridge/soulseek/soulseek.go @@ -0,0 +1,231 @@ +package bsoulseek + +import ( + "fmt" + "net" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" +) + +type Bsoulseek struct { + conn net.Conn + messagesToSend chan soulseekMessage + local chan config.Message + loginResponse chan soulseekMessageResponse + joinRoomResponse chan joinRoomMessageResponse + fatalErrors chan error + disconnect chan bool + firstConnectResponse chan error + + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bsoulseek{} + b.Config = cfg + b.messagesToSend = make(chan soulseekMessage, 256) + b.local = make(chan config.Message, 256) + b.loginResponse = make(chan soulseekMessageResponse) + b.joinRoomResponse = make(chan joinRoomMessageResponse) + b.fatalErrors = make(chan error) + b.disconnect = make(chan bool) + b.firstConnectResponse = make(chan error) + return b +} + +func (b *Bsoulseek) receiveMessages() { + for { + msg, err := readMessage(b.conn) + if err != nil { + b.fatalErrors <- fmt.Errorf("Reading message failed: %s", err) + return + } + b.handleMessage(msg) + } +} + +func sliceEqual(s []string) bool { + // Return true if every element in s is equal to each other + if len(s) <= 1 { + return true + } + for _, x := range(s) { + if x != s[0] { + return false + } + } + return true +} + +func (b *Bsoulseek) sendMessages() { + lastFourChatMessages := []string {"1", "2", "3", ""} + for { + message, more := <-b.messagesToSend + if !more { + return + } + msg, is_say := message.(sayChatroomMessage) + if is_say { + // can't send 5 of the same message in a row or we get banned + if (sliceEqual(append(lastFourChatMessages, msg.Message))) { + b.Log.Warnf("Dropping message: %s", msg.Message) + continue + } + } + data, err := packMessage(message) + if err != nil { + b.fatalErrors <- fmt.Errorf("Packing message failed: %s", err) + return + } + _, err = b.conn.Write(data) + if err != nil { + b.fatalErrors <- fmt.Errorf("Sending message failed: %s", err) + return + } + b.Log.Debugf("Sent message: %v", message) + if is_say { + lastFourChatMessages = append(lastFourChatMessages[1:], msg.Message) + time.Sleep(3500 * time.Millisecond) // rate limit so less than 20 can be sent per min + } + } +} + +func (b *Bsoulseek) sendLocalToRemote() { + for { + message, more := <-b.local + if !more { + return + } + b.Remote <- message + } +} + +func (b *Bsoulseek) loginLoop() { + firstConnect := true + for { + if !firstConnect { + // Cleanup as we are making new sender/receiver routines + b.fatalErrors = make(chan error) + close(b.messagesToSend) + } + // Connect to slsk server + server := b.GetString("Server") + b.Log.Infof("Connecting %s", server) + conn, err := net.Dial("tcp", server) + b.conn = conn + if err != nil { + if firstConnect { + b.firstConnectResponse <- err + return + } + } + + // Init sender and receiver + go b.receiveMessages() + go b.sendMessages() + go b.sendLocalToRemote() + + // Attempt login + b.messagesToSend <- makeLoginMessage(b.GetString("Nick"), b.GetString("Password")) + var msg soulseekMessageResponse + connected := false + select { + case msg = <-b.loginResponse: + switch msg := msg.(type) { + case loginMessageResponseSuccess: + if firstConnect { + b.firstConnectResponse <- nil + } + connected = true + case loginMessageResponseFailure: + if firstConnect { + b.firstConnectResponse <- fmt.Errorf("Login failed: %s", msg.Reason) + return + } + b.Log.Errorf("Login failed: %s", msg.Reason) + default: + panic("Unreachable") + } + case err := <-b.fatalErrors: + // error + if firstConnect { + b.firstConnectResponse <- fmt.Errorf("Login failed: %s", err) + return + } + b.Log.Errorf("Login failed: %s", err) + case <-time.After(30 * time.Second): + // timeout + if firstConnect { + b.firstConnectResponse <- fmt.Errorf("Login failed: timeout") + return + } + b.Log.Errorf("Login failed: timeout") + } + + if !connected { + // If we reach here, we are not logged in and + // it is not the first connect, so we should try again + b.Log.Info("Retrying in 30s") + time.Sleep(30 * time.Second) + continue + } + + // Now we are connected + select { + case err = <- b.fatalErrors: + b.Log.Errorf("%s", err) + // Retry connect + continue + case <- b.disconnect: + // We are done + return + } + } +} + +func (b *Bsoulseek) Connect() error { + go b.loginLoop() + err := <-b.firstConnectResponse + return err +} + + +func (b *Bsoulseek) JoinChannel(channel config.ChannelInfo) error { + b.messagesToSend <- makeJoinRoomMessage(channel.Name) + select { + case <-b.joinRoomResponse: + b.Log.Infof("Joined room: '%s'", channel.Name) + return nil + case <-time.After(30 * time.Second): + return fmt.Errorf("Could not join room '%s': timeout", channel.Name) + } +} + + +func (b *Bsoulseek) 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 && msg.Event != config.EventJoinLeave { + return "", nil + } + b.messagesToSend <- makeSayChatroomMessage(msg.Channel, msg.Username + msg.Text) + return "", nil +} + + +func (b *Bsoulseek) doDisconnect() error { + b.disconnect <- true + close(b.messagesToSend) + close(b.joinRoomResponse) + close(b.loginResponse) + close(b.local) + return nil +} + + +func (b *Bsoulseek) Disconnect() error { + b.doDisconnect() + return nil +} \ No newline at end of file diff --git a/gateway/bridgemap/bsoulseek.go b/gateway/bridgemap/bsoulseek.go new file mode 100644 index 00000000..da3fdddc --- /dev/null +++ b/gateway/bridgemap/bsoulseek.go @@ -0,0 +1,11 @@ +// +build !nosoulseek + +package bridgemap + +import ( + bsoulseek "github.com/42wim/matterbridge/bridge/soulseek" +) + +func init() { + FullMap["soulseek"] = bsoulseek.New +}