diff --git a/CHANGELOG.md b/CHANGELOG.md index 26265bb3..29653516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Improved compatibility, more features, etc. ### Added * Added integrated help (with the `/HELP` command). * Added support for IRCv3.2 [capability negotiation](http://ircv3.net/specs/core/capability-negotiation-3.2.html) including CAP values. -* Added support for IRCv3 capability [`account-notify`](http://ircv3.net/specs/extensions/account-notify-3.1.html), [`invite-notify`](http://ircv3.net/specs/extensions/invite-notify-3.2.html), [`sasl`](http://ircv3.net/specs/extensions/sasl-3.2.html), and draft capability [`message-tags`](http://ircv3.net/specs/core/message-tags-3.3.html) as `draft/message-tags`. +* Added support for IRCv3 capability [`account-notify`](http://ircv3.net/specs/extensions/account-notify-3.1.html), [`invite-notify`](http://ircv3.net/specs/extensions/invite-notify-3.2.html), [`monitor`](http://ircv3.net/specs/core/monitor-3.2.html), [`sasl`](http://ircv3.net/specs/extensions/sasl-3.2.html), and draft capability [`message-tags`](http://ircv3.net/specs/core/message-tags-3.3.html) as `draft/message-tags`. ### Changed * Casemapping changed from custom unicode mapping to preliminary [rfc7700](https://github.com/ircv3/ircv3-specifications/pull/272) mapping. diff --git a/irc/client.go b/irc/client.go index a8a2657c..c9addf4e 100644 --- a/irc/client.go +++ b/irc/client.go @@ -44,6 +44,7 @@ type Client struct { hops uint hostname string idleTimer *time.Timer + monitoring map[string]bool nick string nickCasefolded string nickMaskString string // cache for nickmask string since it's used with lots of replies @@ -59,6 +60,7 @@ type Client struct { username string } +// NewClient returns a client with all the appropriate info setup. func NewClient(server *Server, conn net.Conn, isTLS bool) *Client { now := time.Now() socket := NewSocket(conn) @@ -71,6 +73,7 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) *Client { channels: make(ChannelSet), ctime: now, flags: make(map[UserMode]bool), + monitoring: make(map[string]bool), server: server, socket: &socket, account: &NoAccount, @@ -214,6 +217,8 @@ func (client *Client) Register() { } client.registered = true client.Touch() + + client.alertMonitors() } func (client *Client) IdleTime() time.Duration { @@ -306,7 +311,6 @@ func (client *Client) ChangeNickname(nickname string) { client.nick = nickname client.updateNickMask() client.server.clients.Add(client) - client.Send(nil, origNickMask, "NICK", nickname) for friend := range client.Friends() { friend.Send(nil, origNickMask, "NICK", nickname) } @@ -332,6 +336,14 @@ func (client *Client) destroy() { friends := client.Friends() friends.Remove(client) + // alert monitors + for _, mClient := range client.server.monitoring[client.nickCasefolded] { + mClient.Send(nil, client.server.name, RPL_MONOFFLINE, mClient.nick, client.nick) + } + + // remove my monitors + client.clearMonitorList() + // clean up channels for channel := range client.channels { channel.Quit(client) diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 75df5f6c..98408524 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -43,6 +43,15 @@ func NewClientLookupSet() *ClientLookupSet { } } +func (clients *ClientLookupSet) Has(nick string) bool { + casefoldedName, err := CasefoldName(nick) + if err == nil { + return false + } + _, exists := clients.byNick[casefoldedName] + return exists +} + func (clients *ClientLookupSet) Get(nick string) *Client { casefoldedName, err := CasefoldName(nick) if err == nil { diff --git a/irc/commands.go b/irc/commands.go index b15adb45..74def86e 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -100,6 +100,10 @@ var Commands = map[string]Command{ handler: modeHandler, minParams: 1, }, + "MONITOR": { + handler: monitorHandler, + minParams: 1, + }, "MOTD": { handler: motdHandler, minParams: 0, diff --git a/irc/config.go b/irc/config.go index ec87681d..5f894067 100644 --- a/irc/config.go +++ b/irc/config.go @@ -93,12 +93,13 @@ type Config struct { Operator map[string]*PassConfig Limits struct { - NickLen int `yaml:"nicklen"` - ChannelLen int `yaml:"channellen"` - AwayLen int `yaml:"awaylen"` - KickLen int `yaml:"kicklen"` - TopicLen int `yaml:"topiclen"` - WhowasEntries uint `yaml:"whowas-entries"` + NickLen uint `yaml:"nicklen"` + ChannelLen uint `yaml:"channellen"` + AwayLen uint `yaml:"awaylen"` + KickLen uint `yaml:"kicklen"` + TopicLen uint `yaml:"topiclen"` + WhowasEntries uint `yaml:"whowas-entries"` + MonitorEntries uint `yaml:"monitor-entries"` } } @@ -163,7 +164,7 @@ func LoadConfig(filename string) (config *Config, err error) { if len(config.Server.Listen) == 0 { return nil, errors.New("Server listening addresses missing") } - if config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.TopicLen < 1 || config.Limits.TopicLen < 1 { + if config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.KickLen < 1 || config.Limits.TopicLen < 1 { return nil, errors.New("Limits aren't setup properly, check them and make them sane") } return config, nil diff --git a/irc/monitor.go b/irc/monitor.go new file mode 100644 index 00000000..dfa72582 --- /dev/null +++ b/irc/monitor.go @@ -0,0 +1,205 @@ +// Copyright (c) 2016- Daniel Oaks +// released under the MIT license + +package irc + +import ( + "strconv" + "strings" + + "github.com/DanielOaks/girc-go/ircmsg" +) + +// alertMonitors alerts everyone monitoring us that we're online. +func (client *Client) alertMonitors() { + // alert monitors + for _, mClient := range client.server.monitoring[client.nickCasefolded] { + // don't have to notify ourselves + if &mClient != client { + mClient.Send(nil, client.server.name, RPL_MONONLINE, mClient.nick, client.nickMaskString) + } + } +} + +// clearMonitorList clears our MONITOR list. +func (client *Client) clearMonitorList() { + for name := range client.monitoring { + // just removes current client from the list + orig := client.server.monitoring[name] + var index int + for i, cli := range orig { + if &cli == client { + index = i + break + } + } + client.server.monitoring[name] = append(orig[:index], orig[index+1:]...) + } + + client.monitoring = make(map[string]bool) +} + +var ( + metadataSubcommands = map[string]func(server *Server, client *Client, msg ircmsg.IrcMessage) bool{ + "-": monitorRemoveHandler, + "+": monitorAddHandler, + "c": monitorClearHandler, + "l": monitorListHandler, + "s": monitorStatusHandler, + } +) + +func monitorHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + handler, exists := metadataSubcommands[strings.ToLower(msg.Params[0])] + + if !exists { + client.Send(nil, server.name, ERR_UNKNOWNERROR, client.nick, "MONITOR", msg.Params[0], "Unknown subcommand") + return false + } + + return handler(server, client, msg) +} + +func monitorRemoveHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + if len(msg.Params) < 2 { + client.Send(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, "Not enough parameters") + return false + } + + targets := strings.Split(msg.Params[1], ",") + for len(targets) > 0 { + // check name length + if len(targets[0]) < 1 { + targets = targets[1:] + continue + } + + // remove target + casefoldedTarget, err := CasefoldName(targets[0]) + if err != nil { + // skip silently I guess + targets = targets[1:] + continue + } + + if client.monitoring[casefoldedTarget] { + // just removes current client from the list + orig := server.monitoring[casefoldedTarget] + var index int + for i, cli := range orig { + if &cli == client { + index = i + break + } + } + server.monitoring[casefoldedTarget] = append(orig[:index], orig[index+1:]...) + + delete(client.monitoring, casefoldedTarget) + } + + // remove first element of targets list + targets = targets[1:] + } + + return false +} + +func monitorAddHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + if len(msg.Params) < 2 { + client.Send(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, "Not enough parameters") + return false + } + + var online []string + var offline []string + + targets := strings.Split(msg.Params[1], ",") + for len(targets) > 0 { + // check name length + if len(targets[0]) < 1 { + targets = targets[1:] + continue + } + + // check the monitor list length + if len(client.monitoring) >= server.limits.MonitorEntries { + client.Send(nil, server.name, ERR_MONLISTFULL, client.nick, strconv.Itoa(server.limits.MonitorEntries), strings.Join(targets, ",")) + break + } + + // add target + casefoldedTarget, err := CasefoldName(targets[0]) + if err != nil { + // skip silently I guess + targets = targets[1:] + continue + } + + if !client.monitoring[casefoldedTarget] { + client.monitoring[casefoldedTarget] = true + + orig := server.monitoring[casefoldedTarget] + server.monitoring[casefoldedTarget] = append(orig, *client) + } + + // add to online / offline lists + target := server.clients.Get(casefoldedTarget) + if target == nil { + offline = append(offline, targets[0]) + } else { + online = append(online, target.nickMaskString) + } + + // remove first element of targets list + targets = targets[1:] + } + + if len(online) > 0 { + client.Send(nil, server.name, RPL_MONONLINE, client.nick, strings.Join(online, ",")) + } + if len(offline) > 0 { + client.Send(nil, server.name, RPL_MONOFFLINE, client.nick, strings.Join(offline, ",")) + } + + return false +} + +func monitorClearHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + client.clearMonitorList() + + return false +} + +func monitorListHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + var monitorList []string + for name := range client.monitoring { + monitorList = append(monitorList, name) + } + + client.Send(nil, server.name, RPL_MONLIST, client.nick, strings.Join(monitorList, ",")) + + return false +} + +func monitorStatusHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + var online []string + var offline []string + + for name := range client.monitoring { + target := server.clients.Get(name) + if target == nil { + offline = append(offline, name) + } else { + online = append(online, target.nickMaskString) + } + } + + if len(online) > 0 { + client.Send(nil, server.name, RPL_MONONLINE, client.nick, strings.Join(online, ",")) + } + if len(offline) > 0 { + client.Send(nil, server.name, RPL_MONOFFLINE, client.nick, strings.Join(offline, ",")) + } + + return false +} diff --git a/irc/nickname.go b/irc/nickname.go index 6da5f120..a53d9545 100644 --- a/irc/nickname.go +++ b/irc/nickname.go @@ -43,6 +43,7 @@ func nickHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { if client.registered { client.ChangeNickname(nicknameRaw) + client.alertMonitors() } else { client.SetNickname(nicknameRaw) } diff --git a/irc/numerics.go b/irc/numerics.go index 0c1f5a46..237e8c54 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -153,6 +153,11 @@ const ( RPL_HELPSTART = "704" RPL_HELPTXT = "705" RPL_ENDOFHELP = "706" + RPL_MONONLINE = "730" + RPL_MONOFFLINE = "731" + RPL_MONLIST = "732" + RPL_ENDOFMONLIST = "733" + ERR_MONLISTFULL = "734" RPL_LOGGEDIN = "900" RPL_LOGGEDOUT = "901" ERR_NICKLOCKED = "902" diff --git a/irc/server.go b/irc/server.go index 93248935..47a0ec83 100644 --- a/irc/server.go +++ b/irc/server.go @@ -26,11 +26,12 @@ import ( // Limits holds the maximum limits for various things such as topic lengths type Limits struct { - AwayLen int - ChannelLen int - KickLen int - NickLen int - TopicLen int + AwayLen int + ChannelLen int + KickLen int + MonitorEntries int + NickLen int + TopicLen int } type Server struct { @@ -42,6 +43,7 @@ type Server struct { store buntdb.DB idle chan *Client limits Limits + monitoring map[string][]Client motdLines []string name string nameCasefolded string @@ -85,12 +87,14 @@ func NewServer(config *Config) *Server { ctime: time.Now(), idle: make(chan *Client), limits: Limits{ - AwayLen: config.Limits.AwayLen, - ChannelLen: config.Limits.ChannelLen, - KickLen: config.Limits.KickLen, - NickLen: config.Limits.NickLen, - TopicLen: config.Limits.TopicLen, + AwayLen: int(config.Limits.AwayLen), + ChannelLen: int(config.Limits.ChannelLen), + KickLen: int(config.Limits.KickLen), + MonitorEntries: int(config.Limits.MonitorEntries), + NickLen: int(config.Limits.NickLen), + TopicLen: int(config.Limits.TopicLen), }, + monitoring: make(map[string][]Client), name: config.Server.Name, nameCasefolded: casefoldedName, newConns: make(chan clientConn), @@ -172,15 +176,16 @@ func NewServer(config *Config) *Server { server.isupport.Add("AWAYLEN", strconv.Itoa(server.limits.AwayLen)) server.isupport.Add("CASEMAPPING", "rfc7700") server.isupport.Add("CHANMODES", strings.Join([]string{ChannelModes{BanMask, ExceptMask, InviteMask}.String(), "", ChannelModes{UserLimit, Key}.String(), ChannelModes{InviteOnly, Moderated, NoOutside, OpOnlyTopic, Secret}.String()}, ",")) - server.isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen)) + server.isupport.Add("CHANNELLEN", strconv.Itoa(server.limits.ChannelLen)) server.isupport.Add("CHANTYPES", "#") server.isupport.Add("EXCEPTS", "") server.isupport.Add("INVEX", "") server.isupport.Add("KICKLEN", strconv.Itoa(server.limits.KickLen)) // server.isupport.Add("MAXLIST", "") //TODO(dan): Support max list length? // server.isupport.Add("MODES", "") //TODO(dan): Support max modes? + server.isupport.Add("MONITOR", strconv.Itoa(server.limits.MonitorEntries)) server.isupport.Add("NETWORK", config.Network.Name) - server.isupport.Add("NICKLEN", strconv.Itoa(config.Limits.NickLen)) + server.isupport.Add("NICKLEN", strconv.Itoa(server.limits.NickLen)) server.isupport.Add("PREFIX", "(qaohv)~&@%+") // server.isupport.Add("STATUSMSG", "@+") //TODO(dan): Support STATUSMSG // server.isupport.Add("TARGMAX", "") //TODO(dan): Support this diff --git a/oragono.yaml b/oragono.yaml index ae0bc57a..dcf93425 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -86,5 +86,8 @@ limits: # topiclen is the maximum length of a channel topic topiclen: 390 + # maximum number of monitor entries a client can have + monitor-entries: 100 + # whowas entries to store whowas-entries: 100