This commit is contained in:
gavin 2024-08-30 12:57:58 +08:00 committed by GitHub
commit 323eabce39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 739 additions and 15 deletions

View File

@ -0,0 +1,92 @@
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,
}
if strings.HasPrefix(msg.Message, "/me ") {
// user action
bridgeMessage.Text = msg.Message[4:]
bridgeMessage.Event = config.EventUserAction
}
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()
}
}
}

359
bridge/soulseek/messages.go Normal file
View File

@ -0,0 +1,359 @@
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,
36: 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 (b* Bsoulseek) 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 {
b.Log.Errorf("Unknown message code: %d", code)
}
return nil, nil
}
}
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,
}
}

235
bridge/soulseek/soulseek.go Normal file
View File

@ -0,0 +1,235 @@
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 := b.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)
err := b.conn.Close()
b.Log.Errorf("Error closing conn: %s", err)
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
}
continue
}
// 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
firstConnect = false;
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
}
text := msg.Username+msg.Text
if msg.Event == config.EventUserAction {
text = msg.Username + "/me " + msg.Text
}
b.messagesToSend <- makeSayChatroomMessage(msg.Channel, 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
}

View File

@ -0,0 +1,11 @@
// +build !nosoulseek
package bridgemap
import (
bsoulseek "github.com/42wim/matterbridge/bridge/soulseek"
)
func init() {
FullMap["soulseek"] = bsoulseek.New
}

View File

@ -188,7 +188,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -325,7 +325,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -492,12 +492,12 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
#Do not send joins/parts to other bridges #Do not send joins/parts to other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
NoSendJoinPart=false NoSendJoinPart=false
@ -610,7 +610,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -749,12 +749,12 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
#Do not send joins/parts to other bridges #Do not send joins/parts to other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
NoSendJoinPart=false NoSendJoinPart=false
@ -909,8 +909,9 @@ Label=""
# See the [general] config section for default options # See the [general] config section for default options
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
# ShowJoinPart emits messages that show joins/parts from other bridges #Enable to show users joins/parts from other bridges
# Supported from the following bridges: irc, mattermost, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
# StripNick strips non-alphanumeric characters from nicknames. # StripNick strips non-alphanumeric characters from nicknames.
@ -1068,7 +1069,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -1205,7 +1206,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -1317,7 +1318,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -1414,7 +1415,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -1503,7 +1504,33 @@ SkipTLSVerify=false
MessageClipped="<clipped message>" MessageClipped="<clipped message>"
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false)
ShowJoinPart=false
#Do not send joins/parts to other bridges
#OPTIONAL (default false)
NoSendJoinPart=false
###################################################################
# Soulseek
###################################################################
[soulseek.bridge]
# Host and port of the Soulseek server
# Probably will always be server.slsknet.org:2242
Server = "server.slsknet.org:2242"
# Nickname to log in as
Nick = "matterbridge"
# Password for Soulseek account
# If no account with Nick exists, it will create a new account with this password
Password = "userpasswordhere"
#Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false
@ -1623,7 +1650,7 @@ Label=""
RemoteNickFormat="[{PROTOCOL}] <{NICK}> " RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
#Enable to show users joins/parts from other bridges #Enable to show users joins/parts from other bridges
#Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord #Currently works for messages from the following bridges: irc, mattermost, mumble, slack, discord, soulseek
#OPTIONAL (default false) #OPTIONAL (default false)
ShowJoinPart=false ShowJoinPart=false