diff --git a/helpers.go b/helpers.go index 9052d59..aabb365 100644 --- a/helpers.go +++ b/helpers.go @@ -71,12 +71,26 @@ func DecodeResponse(lines []string, v interface{}) error { for _, part := range strings.Split(lines[0], "|") { for _, val := range strings.Split(part, " ") { parts := strings.SplitN(val, "=", 2) - // TODO(steve): support groups key := Decode(parts[0]) if len(parts) == 2 { v := Decode(parts[1]) if i, err := strconv.Atoi(v); err != nil { - input[key] = v + // Only support comma seperated lists + // by keyname to avoid incorrect decoding. + if key == "client_servergroups" { + parts := strings.Split(v, ",") + serverGroups := make([]int, len(parts)) + for i, s := range parts { + group, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("decode server group: %w", err) + } + serverGroups[i] = group + } + input[key] = serverGroups + } else { + input[key] = v + } } else { input[key] = i } @@ -136,10 +150,56 @@ func decodeSlice(elemType reflect.Type, slice reflect.Value, input map[string]in return fmt.Errorf("can't interface %#v", v) } + // The mapstructure's decoder doesn't support squashing + // for embedded pointers to structs (the type is lost when + // using reflection for nil values). We need to add pointers + // to empty structs within the interface to get around this. + switch v.Interface().(type) { + case *OnlineClient: + ext := &OnlineClientExt{ + OnlineClientGroups: &OnlineClientGroups{}, + OnlineClientInfo: &OnlineClientInfo{}, + OnlineClientTimes: &OnlineClientTimes{}, + OnlineClientVoice: &OnlineClientVoice{}, + } + v.Interface().(*OnlineClient).OnlineClientExt = ext + } + if err := decodeMap(input, v.Interface()); err != nil { return err } + // nil out empty structs + switch v.Interface().(type) { + case *OnlineClient: + ext := v.Interface().(*OnlineClient).OnlineClientExt + emptyExt := OnlineClientExt{} + emptyExtGroups := OnlineClientGroups{} + emptyExtInfo := OnlineClientInfo{} + emptyExtTimes := OnlineClientTimes{} + emptyExtVoice := OnlineClientVoice{} + + if *ext.OnlineClientGroups == emptyExtGroups { + v.Interface().(*OnlineClient).OnlineClientExt.OnlineClientGroups = nil + } + + if *ext.OnlineClientInfo == emptyExtInfo { + v.Interface().(*OnlineClient).OnlineClientExt.OnlineClientInfo = nil + } + + if *ext.OnlineClientTimes == emptyExtTimes { + v.Interface().(*OnlineClient).OnlineClientExt.OnlineClientTimes = nil + } + + if *ext.OnlineClientVoice == emptyExtVoice { + v.Interface().(*OnlineClient).OnlineClientExt.OnlineClientVoice = nil + } + + if *ext == emptyExt { + v.Interface().(*OnlineClient).OnlineClientExt = nil + } + } + if elemType.Kind() == reflect.Struct { v = v.Elem() } diff --git a/mockserver_test.go b/mockserver_test.go index 726e346..8c503f9 100644 --- a/mockserver_test.go +++ b/mockserver_test.go @@ -43,10 +43,11 @@ var commands = map[string]string{ "instanceinfo": "serverinstance_database_version=26 serverinstance_filetransfer_port=30033 serverinstance_max_download_total_bandwidth=18446744073709551615 serverinstance_max_upload_total_bandwidth=18446744073709551615 serverinstance_guest_serverquery_group=1 serverinstance_serverquery_flood_commands=50 serverinstance_serverquery_flood_time=3 serverinstance_serverquery_ban_time=600 serverinstance_template_serveradmin_group=3 serverinstance_template_serverdefault_group=5 serverinstance_template_channeladmin_group=1 serverinstance_template_channeldefault_group=4 serverinstance_permissions_version=19 serverinstance_pending_connections_per_ip=0", "serverrequestconnectioninfo": "connection_filetransfer_bandwidth_sent=0 connection_filetransfer_bandwidth_received=0 connection_filetransfer_bytes_sent_total=617 connection_filetransfer_bytes_received_total=0 connection_packets_sent_total=926413 connection_bytes_sent_total=92911395 connection_packets_received_total=650335 connection_bytes_received_total=61940731 connection_bandwidth_sent_last_second_total=0 connection_bandwidth_sent_last_minute_total=0 connection_bandwidth_received_last_second_total=0 connection_bandwidth_received_last_minute_total=0 connection_connected_time=49408 connection_packetloss_total=0.0000 connection_ping=0.0000 connection_packets_sent_speech=320432180 connection_bytes_sent_speech=43805818511 connection_packets_received_speech=174885295 connection_bytes_received_speech=24127808273 connection_packets_sent_keepalive=55230363 connection_bytes_sent_keepalive=2264444883 connection_packets_received_keepalive=55149547 connection_bytes_received_keepalive=2316390993 connection_packets_sent_control=2376088 connection_bytes_sent_control=525691022 connection_packets_received_control=2376138 connection_bytes_received_control=227044870", "channellist": "cid=499 pid=0 channel_order=0 channel_name=Default\\sChannel total_clients=1 channel_needed_subscribe_power=0", - "clientlist": "clid=5 cid=7 client_database_id=40 client_nickname=ScP client_type=0 client_away=1 client_away_message=not\\shere", - "clientdblist": "cldbid=7 client_unique_identifier=DZhdQU58qyooEK4Fr8Ly738hEmc= client_nickname=MuhChy client_created=1259147468 client_lastconnected=1259421233", - "whoami": "virtualserver_status=online virtualserver_id=18 virtualserver_unique_identifier=gNITtWtKs9+Uh3L4LKv8\\/YHsn5c= virtualserver_port=9987 client_id=94 client_channel_id=432 client_nickname=serveradmin\\sfrom\\s127.0.0.1:49725 client_database_id=1 client_login_name=serveradmin client_unique_identifier=serveradmin client_origin_server_id=0", - cmdQuit: "", + "clientlist": `clid=42087 cid=39 client_database_id=19 client_nickname=bdeb1337 client_type=0 client_away=0 client_away_message`, + "clientlist -uid -away -voice -times -groups -info -icon -country -ip -badges": `clid=42087 cid=39 client_database_id=19 client_nickname=bdeb1337 client_type=0 client_away=1 client_away_message=afk client_flag_talking=0 client_input_muted=0 client_output_muted=0 client_input_hardware=1 client_output_hardware=1 client_talk_power=75 client_is_talker=0 client_is_priority_speaker=0 client_is_recording=0 client_is_channel_commander=0 client_unique_identifier=DZhdQU58qyooEK4Fr8Ly738hEmc= client_servergroups=6,8 client_channel_group_id=8 client_channel_group_inherited_channel_id=39 client_version=3.6.1\s[Build:\s1690193193] client_platform=OS\sX client_idle_time=1280228 client_created=1661793049 client_lastconnected=1691527133 client_icon_id=0 client_country=BE connection_client_ip=1.3.3.7 client_badges`, + "clientdblist": "cldbid=7 client_unique_identifier=DZhdQU58qyooEK4Fr8Ly738hEmc= client_nickname=MuhChy client_created=1259147468 client_lastconnected=1259421233", + "whoami": "virtualserver_status=online virtualserver_id=18 virtualserver_unique_identifier=gNITtWtKs9+Uh3L4LKv8\\/YHsn5c= virtualserver_port=9987 client_id=94 client_channel_id=432 client_nickname=serveradmin\\sfrom\\s127.0.0.1:49725 client_database_id=1 client_login_name=serveradmin client_unique_identifier=serveradmin client_origin_server_id=0", + cmdQuit: "", } // newLockListener creates a new listener on the local IP. @@ -256,6 +257,11 @@ func (s *server) handle(conn net.Conn) { l := sc.Text() parts := strings.Split(l, " ") cmd := strings.TrimSpace(parts[0]) + // Support server commands with specific optional parameters, + // they can be bypassed from the usual parameter trimming here. + if cmd == "clientlist" { + cmd = l + } resp, ok := commands[cmd] var err error switch { diff --git a/server_cmds.go b/server_cmds.go index 411af27..d986225 100644 --- a/server_cmds.go +++ b/server_cmds.go @@ -7,6 +7,29 @@ import ( const ( // ExtendedServerList can be passed to List to get extended server information. ExtendedServerList = "-extended" + + // ClientUID can be passed to ClientList to retrieve client UID information. + ClientUID = "-uid" + // ClientAway can be passed to ClientList to retrieve client away information. + ClientAway = "-away" + // ClientVoice can be passed to ClientList to retrieve client voice information. + ClientVoice = "-voice" + // ClientTimes can be passed to ClientList to retrieve client time information. + ClientTimes = "-times" + // ClientGroups can be passed to ClientList to retrieve client groups information. + ClientGroups = "-groups" + // ClientInfo can be passed to ClientList to retrieve client information. + ClientInfo = "-info" + // ClientIcon can be passed to ClientList to retrieve client icon information. + ClientIcon = "-icon" + // ClientCountry can be passed to ClientList to retrieve client country information. + ClientCountry = "-country" + // ClientIP can be passed to ClientList to retrieve client IP information. + ClientIP = "-ip" + // ClientBadges can be passed to ClientList to retrieve client badge information. + ClientBadges = "-badges" + // ClientListFull can be passed to ClientList to get all extended client information. + ClientListFull = "-uid -away -voice -times -groups -info -icon -country -ip -badges" ) // ServerMethods groups server methods. @@ -351,19 +374,70 @@ func (s *ServerMethods) PrivilegeKeyAdd(ttype, id1, id2 int, options ...CmdArg) // OnlineClient represents a client online on a virtual server. type OnlineClient struct { - ID int `ms:"clid"` - ChannelID int `ms:"cid"` - DatabaseID int `ms:"client_database_id"` - Nickname string `ms:"client_nickname"` - Type int `ms:"client_type"` - Away bool `ms:"client_away"` - AwayMessage string `ms:"client_away_message"` + // Following variables are always returned by ClientList(). + ID int `ms:"clid"` + ChannelID int `ms:"cid"` + DatabaseID int `ms:"client_database_id"` + Nickname string `ms:"client_nickname"` + Type int `ms:"client_type"` + // Following variables are optional and can be requested in ClientList() to get extended client information. + // note: Away and AwayMessage are currently optional but not using pointers for compatibility considerations. + Away bool `ms:"client_away"` // Only populated if ClientAway or ClientListFull is passed to ClientList. + AwayMessage string `ms:"client_away_message"` // Only populated if ClientAway or ClientListFull is passed to ClientList. + *OnlineClientExt `ms:",squash"` // Only populated if any of the options is passed to ClientList. +} + +// OnlineClientExt represents all ClientList extensions. +type OnlineClientExt struct { + UniqueIdentifier *string `ms:"client_unique_identifier"` // Only populated if ClientUID or ClientListFull is passed to ClientList. + *OnlineClientVoice `ms:",squash"` // Only populated if ClientVoice or ClientListFull is passed to ClientList. + *OnlineClientTimes `ms:",squash"` // Only populated if ClientTimes or ClientListFull is passed to ClientList. + *OnlineClientGroups `ms:",squash"` // Only populated if ClientGroups or ClientListFull is passed to ClientList. + *OnlineClientInfo `ms:",squash"` // Only populated if ClientInfo or ClientListFull is passed to ClientList. + Country *string `ms:"client_country"` // Only populated if ClientCountry or ClientListFull is passed to ClientList. + IP *string `ms:"connection_client_ip"` // Only populated if ClientIP or ClientListFull is passed to ClientList. + Badges *string `ms:"client_badges"` // Only populated if ClientBadges or ClientListFull is passed to ClientList. + IconID *int `ms:"client_icon_id"` // Only populated if ClientIcon or ClientListFull is passed to ClientList. +} + +// OnlineClientVoice represents all ClientList extensions when the ClientVoice parameter is passed. +type OnlineClientVoice struct { + FlagTalking *bool `ms:"client_flag_talking"` + InputMuted *bool `ms:"client_input_muted"` + OutputMuted *bool `ms:"client_output_muted"` + InputHardware *bool `ms:"client_input_hardware"` + OutputHardware *bool `ms:"client_output_hardware"` + TalkPower *int `ms:"client_talk_power"` + IsTalker *bool `ms:"client_is_talker"` + IsPrioritySpeaker *bool `ms:"client_is_priority_speaker"` + IsRecording *bool `ms:"client_is_recording"` + IsChannelCommander *bool `ms:"client_is_channel_commander"` +} + +// OnlineClientTimes represents all ClientList extensions when the ClientTimes parameter is passed. +type OnlineClientTimes struct { + IdleTime *int `ms:"client_idle_time"` + Created *int `ms:"client_created"` + LastConnected *int `ms:"client_lastconnected"` +} + +// OnlineClientGroups represents all ClientList extensions when the ClientGroups parameter is passed. +type OnlineClientGroups struct { + ChannelGroupID *int `ms:"client_channel_group_id"` + ChannelGroupInheritedChannelID *int `ms:"client_channel_group_inherited_channel_id"` + ServerGroups *[]int `ms:"client_servergroups"` +} + +// OnlineClientInfo represents all ClientList extensions when the ClientInfo parameter is passed. +type OnlineClientInfo struct { + Version *string `ms:"client_version"` + Platform *string `ms:"client_platform"` } // ClientList returns a list of online clients. -func (s *ServerMethods) ClientList() ([]*OnlineClient, error) { +func (s *ServerMethods) ClientList(options ...string) ([]*OnlineClient, error) { var clients []*OnlineClient - if _, err := s.ExecCmd(NewCmd("clientlist").WithResponse(&clients)); err != nil { + if _, err := s.ExecCmd(NewCmd("clientlist").WithOptions(options...).WithResponse(&clients)); err != nil { return nil, err } return clients, nil diff --git a/server_cmds_test.go b/server_cmds_test.go index 1243b1f..451fbac 100644 --- a/server_cmds_test.go +++ b/server_cmds_test.go @@ -306,17 +306,80 @@ func testCmdsServer(t *testing.T, c *Client) { expected := []*OnlineClient{ { - ID: 5, - ChannelID: 7, - DatabaseID: 40, - Nickname: "ScP", + ID: 42087, + ChannelID: 39, + DatabaseID: 19, + Nickname: "bdeb1337", + Type: 0, + }, + } + + assert.Equal(t, expected, clients) + } + + clientlistextended := func(t *testing.T) { + t.Helper() + clientz, err := c.Server.ClientList(ClientListFull) + if !assert.NoError(t, err) { + return + } + + // helper variables & functions for pointers + falseP := false + trueP := true + stringptr := func(s string) *string { + return &s + } + intptr := func(i int) *int { + return &i + } + + expected := []*OnlineClient{ + { + ID: 42087, + ChannelID: 39, + DatabaseID: 19, + Nickname: "bdeb1337", Type: 0, Away: true, - AwayMessage: "not here", + AwayMessage: "afk", + OnlineClientExt: &OnlineClientExt{ + UniqueIdentifier: stringptr("DZhdQU58qyooEK4Fr8Ly738hEmc="), + OnlineClientVoice: &OnlineClientVoice{ + FlagTalking: &falseP, + InputMuted: &falseP, + OutputMuted: &falseP, + InputHardware: &trueP, + OutputHardware: &trueP, + TalkPower: intptr(75), + IsTalker: &falseP, + IsPrioritySpeaker: &falseP, + IsRecording: &falseP, + IsChannelCommander: &falseP, + }, + OnlineClientTimes: &OnlineClientTimes{ + IdleTime: intptr(1280228), + Created: intptr(1661793049), + LastConnected: intptr(1691527133), + }, + OnlineClientGroups: &OnlineClientGroups{ + ChannelGroupID: intptr(8), + ChannelGroupInheritedChannelID: intptr(39), + ServerGroups: &[]int{6, 8}, + }, + OnlineClientInfo: &OnlineClientInfo{ + Version: stringptr("3.6.1 [Build: 1690193193]"), + Platform: stringptr("OS X"), + }, + IconID: intptr(0), + Country: stringptr("BE"), + IP: stringptr("1.3.3.7"), + Badges: stringptr(""), + }, }, } - assert.Equal(t, expected, clients) + assert.Equal(t, expected, clientz) } clientdblist := func(t *testing.T) { @@ -358,6 +421,7 @@ func testCmdsServer(t *testing.T, c *Client) { {"instanceinfo", instanceinfo}, {"channellist", channellist}, {"clientlist", clientlist}, + {"clientlistextended", clientlistextended}, {"clientdblist", clientdblist}, }