2026-01-15 22:06:35 +01:00
|
|
|
package ts3client
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"go-ts/internal/client"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Client is the main TeamSpeak 3 client
|
|
|
|
|
type Client struct {
|
|
|
|
|
address string
|
|
|
|
|
config Config
|
|
|
|
|
|
|
|
|
|
// Internal client
|
|
|
|
|
internal *client.Client
|
|
|
|
|
|
|
|
|
|
// Event handlers
|
|
|
|
|
handlers map[EventType][]any
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
|
|
|
|
|
// State
|
|
|
|
|
connected bool
|
|
|
|
|
channels map[uint64]*Channel
|
|
|
|
|
clients map[uint16]*ClientInfo
|
|
|
|
|
serverInfo *ServerInfo
|
|
|
|
|
selfInfo *SelfInfo
|
|
|
|
|
channelsMu sync.RWMutex
|
|
|
|
|
clientsMu sync.RWMutex
|
2026-01-16 19:50:44 +01:00
|
|
|
|
|
|
|
|
// Voice activity tracking
|
|
|
|
|
talkingClients map[uint16]time.Time // ClientID -> last voice packet time
|
|
|
|
|
talkingMu sync.RWMutex
|
2026-01-15 22:06:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New creates a new TeamSpeak client
|
|
|
|
|
func New(address string, config Config) *Client {
|
|
|
|
|
// Apply defaults
|
|
|
|
|
if config.Nickname == "" {
|
|
|
|
|
config.Nickname = "GoTS3Bot"
|
|
|
|
|
}
|
|
|
|
|
if config.SecurityLevel == 0 {
|
|
|
|
|
config.SecurityLevel = 8
|
|
|
|
|
}
|
|
|
|
|
if config.Version == "" {
|
|
|
|
|
config.Version = "3.6.2 [Build: 1690976575]"
|
|
|
|
|
}
|
|
|
|
|
if config.Platform == "" {
|
|
|
|
|
config.Platform = "Windows"
|
|
|
|
|
}
|
|
|
|
|
if config.HWID == "" {
|
|
|
|
|
config.HWID = "1234567890"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &Client{
|
2026-01-16 19:50:44 +01:00
|
|
|
address: address,
|
|
|
|
|
config: config,
|
|
|
|
|
handlers: make(map[EventType][]any),
|
|
|
|
|
channels: make(map[uint64]*Channel),
|
|
|
|
|
clients: make(map[uint16]*ClientInfo),
|
|
|
|
|
talkingClients: make(map[uint16]time.Time),
|
2026-01-15 22:06:35 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// On registers an event handler
|
|
|
|
|
// The handler function signature must match the event type:
|
|
|
|
|
// - EventConnected: func(*ConnectedEvent)
|
|
|
|
|
// - EventMessage: func(*MessageEvent)
|
|
|
|
|
// - EventAudio: func(*AudioEvent)
|
|
|
|
|
// - etc.
|
|
|
|
|
func (c *Client) On(event EventType, handler any) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
c.handlers[event] = append(c.handlers[event], handler)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// emit calls all handlers registered for the given event
|
|
|
|
|
func (c *Client) emit(event EventType, data any) {
|
|
|
|
|
c.mu.RLock()
|
|
|
|
|
handlers := c.handlers[event]
|
|
|
|
|
c.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
for _, h := range handlers {
|
|
|
|
|
switch event {
|
|
|
|
|
case EventConnected:
|
|
|
|
|
if fn, ok := h.(func(*ConnectedEvent)); ok {
|
|
|
|
|
fn(data.(*ConnectedEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventDisconnected:
|
|
|
|
|
if fn, ok := h.(func(*DisconnectedEvent)); ok {
|
|
|
|
|
fn(data.(*DisconnectedEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventMessage:
|
|
|
|
|
if fn, ok := h.(func(*MessageEvent)); ok {
|
|
|
|
|
fn(data.(*MessageEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventClientEnter:
|
|
|
|
|
if fn, ok := h.(func(*ClientEnterEvent)); ok {
|
|
|
|
|
fn(data.(*ClientEnterEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventClientLeft:
|
|
|
|
|
if fn, ok := h.(func(*ClientLeftEvent)); ok {
|
|
|
|
|
fn(data.(*ClientLeftEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventClientMoved:
|
|
|
|
|
if fn, ok := h.(func(*ClientMovedEvent)); ok {
|
|
|
|
|
fn(data.(*ClientMovedEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventChannelList:
|
|
|
|
|
if fn, ok := h.(func(*ChannelListEvent)); ok {
|
|
|
|
|
fn(data.(*ChannelListEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventAudio:
|
|
|
|
|
if fn, ok := h.(func(*AudioEvent)); ok {
|
|
|
|
|
fn(data.(*AudioEvent))
|
|
|
|
|
}
|
|
|
|
|
case EventError:
|
|
|
|
|
if fn, ok := h.(func(*ErrorEvent)); ok {
|
|
|
|
|
fn(data.(*ErrorEvent))
|
|
|
|
|
}
|
2026-01-16 19:50:44 +01:00
|
|
|
case EventTalkingStatus:
|
|
|
|
|
if fn, ok := h.(func(*TalkingStatusEvent)); ok {
|
|
|
|
|
fn(data.(*TalkingStatusEvent))
|
|
|
|
|
}
|
2026-01-15 22:06:35 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Connect establishes a connection to the TeamSpeak server
|
|
|
|
|
// This method blocks until disconnected or an error occurs
|
|
|
|
|
func (c *Client) Connect() error {
|
|
|
|
|
c.internal = client.NewClient(c.config.Nickname)
|
|
|
|
|
|
|
|
|
|
// Set event callback on internal client
|
|
|
|
|
c.internal.SetEventHandler(c.handleInternalEvent)
|
|
|
|
|
|
2026-01-16 19:50:44 +01:00
|
|
|
// Start talking timeout checker goroutine
|
|
|
|
|
go c.talkingTimeoutChecker()
|
|
|
|
|
|
2026-01-15 22:06:35 +01:00
|
|
|
log.Printf("Connecting to %s as %s...", c.address, c.config.Nickname)
|
|
|
|
|
|
|
|
|
|
err := c.internal.Connect(c.address)
|
|
|
|
|
if err != nil {
|
|
|
|
|
c.emit(EventDisconnected, &DisconnectedEvent{Reason: err.Error()})
|
2026-01-16 10:39:27 +01:00
|
|
|
log.Printf("[TS3Client] Connect returning with error: %v", err)
|
2026-01-15 22:06:35 +01:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 10:39:27 +01:00
|
|
|
log.Printf("[TS3Client] Connect returning cleanly")
|
2026-01-15 22:06:35 +01:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 19:50:44 +01:00
|
|
|
// talkingTimeoutChecker runs in background and emits TalkingStatus=false when clients stop talking
|
|
|
|
|
func (c *Client) talkingTimeoutChecker() {
|
|
|
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
const talkingTimeout = 300 * time.Millisecond
|
|
|
|
|
|
|
|
|
|
for range ticker.C {
|
|
|
|
|
if !c.connected {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
var stoppedTalking []uint16
|
|
|
|
|
|
|
|
|
|
c.talkingMu.Lock()
|
|
|
|
|
for clientID, lastTime := range c.talkingClients {
|
|
|
|
|
if now.Sub(lastTime) > talkingTimeout {
|
|
|
|
|
stoppedTalking = append(stoppedTalking, clientID)
|
|
|
|
|
delete(c.talkingClients, clientID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
c.talkingMu.Unlock()
|
|
|
|
|
|
|
|
|
|
// Emit events for clients who stopped talking
|
|
|
|
|
for _, clientID := range stoppedTalking {
|
|
|
|
|
c.emit(EventTalkingStatus, &TalkingStatusEvent{
|
|
|
|
|
ClientID: clientID,
|
|
|
|
|
Talking: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 22:06:35 +01:00
|
|
|
// ConnectAsync connects in the background and returns immediately
|
|
|
|
|
func (c *Client) ConnectAsync() <-chan error {
|
|
|
|
|
errChan := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
if err := c.Connect(); err != nil {
|
|
|
|
|
errChan <- err
|
|
|
|
|
}
|
|
|
|
|
close(errChan)
|
|
|
|
|
}()
|
|
|
|
|
// Give it a moment to start
|
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
|
return errChan
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Disconnect closes the connection gracefully
|
|
|
|
|
func (c *Client) Disconnect() {
|
2026-01-16 10:39:27 +01:00
|
|
|
log.Println("[Disconnect] Starting disconnect sequence...")
|
2026-01-15 22:06:35 +01:00
|
|
|
if c.internal != nil {
|
|
|
|
|
// Send disconnect command to server
|
2026-01-16 10:39:27 +01:00
|
|
|
log.Println("[Disconnect] Sending disconnect command...")
|
2026-01-15 22:06:35 +01:00
|
|
|
c.sendDisconnect("leaving")
|
2026-01-16 10:39:27 +01:00
|
|
|
// Wait for packet to be sent and ACKed - the internal loop must still be running
|
|
|
|
|
log.Println("[Disconnect] Waiting for disconnect to be processed...")
|
|
|
|
|
time.Sleep(1000 * time.Millisecond)
|
2026-01-15 22:06:35 +01:00
|
|
|
// Stop the internal loop
|
2026-01-16 10:39:27 +01:00
|
|
|
log.Println("[Disconnect] Stopping internal loop...")
|
2026-01-15 22:06:35 +01:00
|
|
|
c.internal.Stop()
|
|
|
|
|
if c.internal.Conn != nil {
|
2026-01-16 10:39:27 +01:00
|
|
|
log.Println("[Disconnect] Closing connection...")
|
2026-01-15 22:06:35 +01:00
|
|
|
c.internal.Conn.Close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
c.connected = false
|
2026-01-16 10:39:27 +01:00
|
|
|
log.Println("[Disconnect] Done")
|
2026-01-15 22:06:35 +01:00
|
|
|
c.emit(EventDisconnected, &DisconnectedEvent{Reason: "client disconnect"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sendDisconnect sends the disconnect command to the server
|
|
|
|
|
func (c *Client) sendDisconnect(reason string) {
|
|
|
|
|
if c.internal == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Use internal client's SendCommand
|
|
|
|
|
cmd := "clientdisconnect reasonid=8 reasonmsg=" + escapeTS3(reason)
|
|
|
|
|
log.Printf("Sending disconnect: %s", cmd)
|
|
|
|
|
if err := c.internal.SendCommandString(cmd); err != nil {
|
|
|
|
|
log.Printf("Error sending disconnect: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type disconnectCommand struct {
|
|
|
|
|
reason string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *disconnectCommand) encode() string {
|
|
|
|
|
return "clientdisconnect reasonid=8 reasonmsg=" + escapeTS3(d.reason)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func escapeTS3(s string) string {
|
|
|
|
|
// Basic escape for TS3 protocol
|
|
|
|
|
result := ""
|
|
|
|
|
for _, r := range s {
|
|
|
|
|
switch r {
|
|
|
|
|
case '\\':
|
|
|
|
|
result += "\\\\"
|
|
|
|
|
case '/':
|
|
|
|
|
result += "\\/"
|
|
|
|
|
case ' ':
|
|
|
|
|
result += "\\s"
|
|
|
|
|
case '|':
|
|
|
|
|
result += "\\p"
|
|
|
|
|
default:
|
|
|
|
|
result += string(r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IsConnected returns true if the client is connected
|
|
|
|
|
func (c *Client) IsConnected() bool {
|
|
|
|
|
return c.connected
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
// GetPing returns the current RTT in milliseconds
|
|
|
|
|
func (c *Client) GetPing() float64 {
|
|
|
|
|
if c.internal == nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return c.internal.PingRTT
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 22:06:35 +01:00
|
|
|
// handleInternalEvent processes events from the internal client
|
|
|
|
|
func (c *Client) handleInternalEvent(eventType string, data map[string]any) {
|
|
|
|
|
switch eventType {
|
|
|
|
|
case "connected":
|
|
|
|
|
c.connected = true
|
|
|
|
|
clientID := uint16(0)
|
|
|
|
|
serverName := ""
|
|
|
|
|
if v, ok := data["clientID"].(uint16); ok {
|
|
|
|
|
clientID = v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := data["serverName"].(string); ok {
|
|
|
|
|
serverName = v
|
|
|
|
|
}
|
|
|
|
|
c.selfInfo = &SelfInfo{ClientID: clientID, Nickname: c.config.Nickname}
|
2026-01-16 14:41:26 +01:00
|
|
|
c.serverInfo = &ServerInfo{Name: serverName}
|
2026-01-15 22:06:35 +01:00
|
|
|
c.emit(EventConnected, &ConnectedEvent{
|
|
|
|
|
ClientID: clientID,
|
|
|
|
|
ServerName: serverName,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case "message":
|
|
|
|
|
targetMode := MessageTarget(1)
|
|
|
|
|
if v, ok := data["targetMode"].(int); ok {
|
|
|
|
|
targetMode = MessageTarget(v)
|
|
|
|
|
}
|
|
|
|
|
c.emit(EventMessage, &MessageEvent{
|
|
|
|
|
SenderID: getUint16(data, "senderID"),
|
|
|
|
|
SenderName: getString(data, "senderName"),
|
|
|
|
|
Message: getString(data, "message"),
|
|
|
|
|
TargetMode: targetMode,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case "client_enter":
|
|
|
|
|
info := &ClientInfo{
|
|
|
|
|
ID: getUint16(data, "clientID"),
|
|
|
|
|
Nickname: getString(data, "nickname"),
|
|
|
|
|
ChannelID: getUint64(data, "channelID"),
|
|
|
|
|
}
|
|
|
|
|
c.clientsMu.Lock()
|
|
|
|
|
c.clients[info.ID] = info
|
|
|
|
|
c.clientsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
c.emit(EventClientEnter, &ClientEnterEvent{
|
|
|
|
|
ClientID: info.ID,
|
|
|
|
|
Nickname: info.Nickname,
|
|
|
|
|
ChannelID: info.ChannelID,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case "client_left":
|
|
|
|
|
clientID := getUint16(data, "clientID")
|
|
|
|
|
c.clientsMu.Lock()
|
|
|
|
|
delete(c.clients, clientID)
|
|
|
|
|
c.clientsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
c.emit(EventClientLeft, &ClientLeftEvent{
|
|
|
|
|
ClientID: clientID,
|
|
|
|
|
Reason: getString(data, "reason"),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case "client_moved":
|
2026-01-16 16:02:17 +01:00
|
|
|
clientID := getUint16(data, "clientID")
|
|
|
|
|
channelID := getUint64(data, "channelID")
|
|
|
|
|
|
|
|
|
|
c.clientsMu.Lock()
|
|
|
|
|
if client, ok := c.clients[clientID]; ok {
|
|
|
|
|
client.ChannelID = channelID
|
|
|
|
|
}
|
|
|
|
|
c.clientsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
// Update selfInfo if it's us
|
|
|
|
|
if c.selfInfo != nil && c.selfInfo.ClientID == clientID {
|
|
|
|
|
c.selfInfo.ChannelID = channelID
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 22:06:35 +01:00
|
|
|
c.emit(EventClientMoved, &ClientMovedEvent{
|
2026-01-16 16:02:17 +01:00
|
|
|
ClientID: clientID,
|
|
|
|
|
ChannelID: channelID,
|
2026-01-15 22:06:35 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case "channel_list":
|
|
|
|
|
if channels, ok := data["channels"].([]*client.Channel); ok {
|
|
|
|
|
c.channelsMu.Lock()
|
|
|
|
|
var chList []*Channel
|
|
|
|
|
for _, ch := range channels {
|
|
|
|
|
converted := &Channel{
|
|
|
|
|
ID: ch.ID,
|
|
|
|
|
ParentID: ch.ParentID,
|
|
|
|
|
Name: ch.Name,
|
|
|
|
|
Order: ch.Order,
|
|
|
|
|
}
|
|
|
|
|
c.channels[ch.ID] = converted
|
|
|
|
|
chList = append(chList, converted)
|
|
|
|
|
}
|
|
|
|
|
c.channelsMu.Unlock()
|
|
|
|
|
c.emit(EventChannelList, &ChannelListEvent{Channels: chList})
|
2026-01-16 23:00:03 +01:00
|
|
|
|
|
|
|
|
// Subscribe to all channels to see users
|
|
|
|
|
// This fixes the issue where users are invisible until joining the channel
|
|
|
|
|
if c.internal != nil {
|
|
|
|
|
log.Println("Received channel list. Subscribing to all channels...")
|
|
|
|
|
c.internal.SendCommandString("channelsubscribeall")
|
|
|
|
|
}
|
2026-01-15 22:06:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "audio":
|
2026-01-16 19:50:44 +01:00
|
|
|
senderID := getUint16(data, "senderID")
|
|
|
|
|
|
|
|
|
|
// Track talking status
|
|
|
|
|
c.talkingMu.Lock()
|
|
|
|
|
_, wasTalking := c.talkingClients[senderID]
|
|
|
|
|
c.talkingClients[senderID] = time.Now()
|
|
|
|
|
c.talkingMu.Unlock()
|
|
|
|
|
|
|
|
|
|
// Emit talking start event if this is a new speaker
|
|
|
|
|
if !wasTalking {
|
|
|
|
|
c.emit(EventTalkingStatus, &TalkingStatusEvent{
|
|
|
|
|
ClientID: senderID,
|
|
|
|
|
Talking: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 22:06:35 +01:00
|
|
|
c.emit(EventAudio, &AudioEvent{
|
2026-01-16 19:50:44 +01:00
|
|
|
SenderID: senderID,
|
2026-01-15 22:06:35 +01:00
|
|
|
Codec: AudioCodec(getInt(data, "codec")),
|
|
|
|
|
PCM: getPCM(data, "pcm"),
|
|
|
|
|
Channels: getInt(data, "channels"),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case "error":
|
|
|
|
|
c.emit(EventError, &ErrorEvent{
|
|
|
|
|
ID: getString(data, "id"),
|
|
|
|
|
Message: getString(data, "message"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper functions for type conversion
|
|
|
|
|
func getString(m map[string]any, key string) string {
|
|
|
|
|
if v, ok := m[key].(string); ok {
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getUint16(m map[string]any, key string) uint16 {
|
|
|
|
|
if v, ok := m[key].(uint16); ok {
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := m[key].(int); ok {
|
|
|
|
|
return uint16(v)
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getUint64(m map[string]any, key string) uint64 {
|
|
|
|
|
if v, ok := m[key].(uint64); ok {
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := m[key].(int); ok {
|
|
|
|
|
return uint64(v)
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getInt(m map[string]any, key string) int {
|
|
|
|
|
if v, ok := m[key].(int); ok {
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getPCM(m map[string]any, key string) []int16 {
|
|
|
|
|
if v, ok := m[key].([]int16); ok {
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WaitForConnection waits until the client is connected or timeout
|
|
|
|
|
func (c *Client) WaitForConnection(timeout time.Duration) error {
|
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
if c.connected {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("connection timeout")
|
|
|
|
|
}
|