diff --git a/channel.go b/channel.go new file mode 100644 index 0000000..85e6594 --- /dev/null +++ b/channel.go @@ -0,0 +1,66 @@ +package irc + +import "git.aiterp.net/gisle/irc/list" + +// A Channel is a target that manages the userlist +type Channel struct { + name string + userlist list.List +} + +// Kind returns "channel" +func (channel *Channel) Kind() string { + return "channel" +} + +// Name gets the channel name +func (channel *Channel) Name() string { + return channel.name +} + +// UserList gets the channel userlist +func (channel *Channel) UserList() list.Immutable { + return channel.userlist.Immutable() +} + +// Handle handles messages routed to this channel by the client's event loop +func (channel *Channel) Handle(event *Event, client *Client) { + switch event.Name() { + case "packet.join": + { + // Support extended-join + account := "" + if accountArg := event.Arg(1); accountArg != "" && accountArg != "*" { + account = accountArg + } + + channel.userlist.Insert(list.User{ + Nick: event.Nick, + User: event.User, + Host: event.Host, + Account: account, + }) + } + case "packet.part", "packet.quit": + { + channel.userlist.Remove(event.Nick) + } + case "packet.account": + { + newAccount := event.Arg(0) + + if newAccount != "*" && newAccount != "" { + channel.userlist.Patch(event.Nick, list.UserPatch{Account: newAccount}) + } else { + channel.userlist.Patch(event.Nick, list.UserPatch{ClearAccount: true}) + } + } + case "packet.chghost": + { + newUser := event.Arg(0) + newHost := event.Arg(1) + + channel.userlist.Patch(event.Nick, list.UserPatch{User: newUser, Host: newHost}) + } + } +} diff --git a/client.go b/client.go index 21b959e..b50c8d4 100644 --- a/client.go +++ b/client.go @@ -17,7 +17,6 @@ import ( "time" "git.aiterp.net/gisle/irc/ircutil" - "git.aiterp.net/gisle/irc/isupport" ) @@ -26,12 +25,31 @@ var supportedCaps = []string{ "cap-notify", "multi-prefix", "userhost-in-names", + "account-notify", + "extended-join", + "chghost", } // ErrNoConnection is returned if you try to do something requiring a connection, // but there is none. var ErrNoConnection = errors.New("irc: no connection") +// ErrTargetAlreadyAdded is returned by Client.AddTarget if that target has already been +// added to the client. +var ErrTargetAlreadyAdded = errors.New("irc: target already added") + +// ErrTargetConflict is returned by Clinet.AddTarget if there already exists a target +// matching the name and kind. +var ErrTargetConflict = errors.New("irc: target name and kind match existing target") + +// ErrTargetNotFound is returned by Clinet.RemoveTarget if the target is not part of +// the client's target list +var ErrTargetNotFound = errors.New("irc: target not found") + +// ErrTargetIsStatus is returned by Clinet.RemoveTarget if the target is the client's +// status target +var ErrTargetIsStatus = errors.New("irc: cannot remove status target") + // A Client is an IRC client. You need to use New to construct it type Client struct { id string @@ -57,6 +75,10 @@ type Client struct { quit bool isupport isupport.ISupport values map[string]interface{} + + status *Status + targets []Target + targteIds map[Target]string } // New creates a new client. The context can be context.Background if you want manually to @@ -70,8 +92,12 @@ func New(ctx context.Context, config Config) *Client { capEnabled: make(map[string]bool), capData: make(map[string]string), config: config.WithDefaults(), + targteIds: make(map[Target]string, 16), + status: &Status{}, } + client.AddTarget(client.status) + client.ctx, client.cancel = context.WithCancel(ctx) go client.handleEventLoop() @@ -366,6 +392,77 @@ func (client *Client) Join(channels ...string) error { return client.Sendf("JOIN %s", strings.Join(channels, ",")) } +// Target gets a target by kind and name +func (client *Client) Target(kind string, name string) Target { + client.mutex.RLock() + defer client.mutex.RUnlock() + + for _, target := range client.targets { + if target.Kind() == kind && target.Name() == name { + return target + } + } + + return nil +} + +// Channel is a shorthand for getting a channel target and type asserting it. +func (client *Client) Channel(name string) *Channel { + target := client.Target("channel", name) + if target == nil { + return nil + } + + return target.(*Channel) +} + +// AddTarget adds a target to the client, generating a unique ID for it. +func (client *Client) AddTarget(target Target) (id string, err error) { + client.mutex.Lock() + defer client.mutex.Unlock() + + for i := range client.targets { + if target == client.targets[i] { + err = ErrTargetAlreadyAdded + return + } else if target.Kind() == client.targets[i].Kind() && target.Name() == client.targets[i].Name() { + err = ErrTargetConflict + return + } + } + + id = generateClientID() + client.targets = append(client.targets, target) + client.targteIds[target] = id + + return +} + +// RemoveTarget removes a target to the client +func (client *Client) RemoveTarget(target Target) (id string, err error) { + if target == client.status { + return "", ErrTargetIsStatus + } + + client.mutex.Lock() + defer client.mutex.Unlock() + + for i := range client.targets { + if target == client.targets[i] { + id = client.targteIds[target] + + client.targets[i] = client.targets[len(client.targets)-1] + client.targets = client.targets[:len(client.targets)-1] + delete(client.targteIds, target) + + return + } + } + + err = ErrTargetNotFound + return +} + func (client *Client) handleEventLoop() { ticker := time.NewTicker(time.Second * 30) @@ -532,6 +629,15 @@ func (client *Client) handleEvent(event *Event) { } } + case "packer.nick": + { + client.handleInTargets(event.Nick, event) + + if event.Nick == client.nick { + client.SetValue("nick", event.Arg(0)) + } + } + // Handle ISupport case "packet.005": { @@ -678,8 +784,90 @@ func (client *Client) handleEvent(event *Event) { client.host = event.Args[2] client.mutex.Unlock() } + + // This may be relevant in channels where the client resides. + client.handleInTargets(event.Nick, event) + } + + // Join/part handling + case "packet.join": + { + var channel *Channel + + if event.Nick == client.nick { + channel = &Channel{name: event.Arg(0)} + client.AddTarget(channel) + } else { + channel = client.Channel(event.Arg(0)) + } + + event.targets = append(event.targets, channel) + + if channel != nil { + channel.Handle(event, client) + } + } + + case "packet.part": + { + channel := client.Channel(event.Arg(0)) + if channel == nil { + break + } + + channel.Handle(event, client) + + if event.Nick == client.nick { + client.RemoveTarget(channel) + } else { + event.targets = append(event.targets, channel) + } + } + + case "packet.quit": + { + client.handleInTargets(event.Nick, event) + } + + // Account handling + case "packet.account": + { + client.handleInTargets(event.Nick, event) + } + } +} + +func (client *Client) handleInTargets(nick string, event *Event) { + client.mutex.RLock() + for i := range client.targets { + switch target := client.targets[i].(type) { + case *Channel: + { + if nick != "" { + if _, ok := target.UserList().User(event.Nick); !ok { + continue + } + } + + event.targets = append(event.targets, target) + + target.Handle(event, client) + } + case *Query: + { + if target.user.Nick == nick { + target.Handle(event, client) + } + } + case *Status: + { + if client.nick == event.Nick { + target.Handle(event, client) + } + } } } + client.mutex.RUnlock() } func generateClientID() string { @@ -697,7 +885,7 @@ func generateClientID() string { return result[:24] } - binary.BigEndian.PutUint32(bytes, uint32(time.Now().Unix())) + binary.BigEndian.PutUint32(bytes[4:], uint32(time.Now().Unix())) return hex.EncodeToString(bytes) } diff --git a/event.go b/event.go index 9d88ba5..c826273 100644 --- a/event.go +++ b/event.go @@ -25,6 +25,8 @@ type Event struct { cancel context.CancelFunc killed bool hidden bool + + targets []Target } // NewEvent makes a new event with Kind, Verb, Time set and Args and Tags initialized. @@ -102,6 +104,20 @@ func (event *Event) Hidden() bool { return event.hidden } +// Arg gets the argument by index. The rationale behind it is that some +// servers may use it for the last argument in JOINs and such. +func (event *Event) Arg(index int) string { + if index < 0 || index > len(event.Args) { + return "" + } + + if index == len(event.Args) { + return event.Text + } + + return event.Args[index] +} + // MarshalJSON makes a JSON object from the event. func (event *Event) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]interface{}{ diff --git a/list/list.go b/list/list.go index 34ebd6b..4fea52f 100644 --- a/list/list.go +++ b/list/list.go @@ -242,6 +242,32 @@ func (list *List) Users() []User { return result } +// Patch allows editing a limited subset of the user's properties. +func (list *List) Patch(nick string, patch UserPatch) (ok bool) { + list.mutex.Lock() + defer list.mutex.Unlock() + + for _, user := range list.users { + if strings.EqualFold(nick, user.Nick) { + if patch.Account != "" || patch.ClearAccount { + user.Account = patch.Account + } + + if patch.User != "" { + user.User = patch.User + } + + if patch.Host != "" { + user.Host = patch.Host + } + + return true + } + } + + return false +} + // SetAutoSort enables or disables automatic sorting, which by default is enabled. // Dislabing it makes sense when doing a massive operation. Enabling it will trigger // a sort. @@ -264,6 +290,11 @@ func (list *List) Clear() { list.mutex.Unlock() } +// Immutable gets an immutable version of the list. +func (list *List) Immutable() Immutable { + return Immutable{list: list} +} + func (list *List) sort() { sort.Slice(list.users, func(i, j int) bool { a := list.users[i] diff --git a/list/user.go b/list/user.go index d26a2ff..a4d9618 100644 --- a/list/user.go +++ b/list/user.go @@ -11,6 +11,14 @@ type User struct { PrefixedNick string `json:"prefixedNick"` } +// UserPatch is used in List.Patch to apply changes to a user +type UserPatch struct { + User string + Host string + Account string + ClearAccount bool +} + // HighestMode returns the highest mode. func (user *User) HighestMode() rune { if len(user.Modes) == 0 { diff --git a/query.go b/query.go new file mode 100644 index 0000000..86144e1 --- /dev/null +++ b/query.go @@ -0,0 +1,44 @@ +package irc + +import ( + "git.aiterp.net/gisle/irc/list" +) + +// A Query is a target for direct messages to and from a specific nick. +type Query struct { + user list.User +} + +// Kind returns "channel" +func (query *Query) Kind() string { + return "query" +} + +// Name gets the query name +func (query *Query) Name() string { + return query.user.Nick +} + +// Handle handles messages routed to this channel by the client's event loop +func (query *Query) Handle(event *Event, client *Client) { + switch event.Name() { + case "packet.nick": + { + query.user.Nick = event.Arg(0) + } + case "packet.account": + { + account := "" + if accountArg := event.Arg(0); accountArg != "" && accountArg != "*" { + account = accountArg + } + + query.user.Account = account + } + case "packet.chghost": + { + query.user.User = event.Arg(0) + query.user.Host = event.Arg(1) + } + } +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..71f818d --- /dev/null +++ b/status.go @@ -0,0 +1,20 @@ +package irc + +// A Status contains +type Status struct { +} + +// Kind returns "status" +func (status *Status) Kind() string { + return "status" +} + +// Name returns "status" +func (status *Status) Name() string { + return "Status" +} + +// Handle handles messages routed to this status by the client's event loop +func (status *Status) Handle(event *Event, client *Client) { + +} diff --git a/target.go b/target.go new file mode 100644 index 0000000..c76dc75 --- /dev/null +++ b/target.go @@ -0,0 +1,9 @@ +package irc + +// A Target is a handler for a message meant for a limited part of the client, like a channel or +// query +type Target interface { + Kind() string + Name() string + Handle(event *Event, client *Client) +}