Compare commits

..

5 Commits

Author SHA1 Message Date
Liam Stanley
662a911d11 fix some of the rune/int -> string test errors 2020-10-28 01:08:24 -04:00
Liam Stanley
e2b3e11741 test actions, update readme 2020-10-28 01:01:59 -04:00
Liam Stanley
14813a795d initial event splitting implementation 2020-10-28 00:35:50 -04:00
Liam Stanley
b472e83947 add GetServerOptionInt Client method
allows returning ISUPPORT parameters in the form of integers
2020-10-28 00:35:41 -04:00
Liam Stanley
6f29ca92da bugfix: prevent stripping of colons in single-word PRIVMSG's that contain colons as a prefix 2020-10-24 12:38:42 -04:00
35 changed files with 1724 additions and 2119 deletions

@ -1,25 +0,0 @@
name: Go
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
- name: Build
run: go build -v ./...
- name: Test
run: go test -race -v ./...

24
.github/workflows/test.yml vendored Normal file

@ -0,0 +1,24 @@
name: test
on:
push: {}
pull_request: { branches: [master] }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v2
with: { go-version: '1.x' }
- uses: actions/checkout@v2
- name: setup
run: |
go get -v golang.org/x/lint/golint
- name: lint
run: golint -min_confidence 0.9 -set_exit_status
- name: test
run: |
GORACE="exitcode=1 halt_on_error=1" go test -v -coverprofile=coverage.txt -race -timeout 3m -count 3 -cpu 1,4
bash <(curl -s https://codecov.io/bash)
- name: vet
run: go vet -v .

4
.gitignore vendored

@ -1,4 +0,0 @@
.idea
*.save
*.swp
corpus/

@ -2,24 +2,31 @@
## Issue submission ## Issue submission
* When submitting an issue or bug report, please ensure to provide as much information as possible, please ensure that * When submitting an issue or bug report, please ensure to provide as much
you are running on the latest stable version (tagged), or when using master, provide the specific commit being used. information as possible, please ensure that you are running on the latest
* Provide the minimum needed viable source to replicate the problem. stable version (tagged), or when using master, provide the specific commit
being used.
* Provide the minimum needed viable source to replicate the problem.
## Pull requests ## Pull requests
To review what is currently being worked on, or looked into, feel free to head over to the [issues list](../../issues). To review what is currently being worked on, or looked into, feel free to head
over to the [issues list](../../issues).
Below are a few guidelines if you would like to contribute. Keep the code clean, standardized, and much of the quality Below are a few guidelines if you would like to contribute. Keep the code
should match Golang's standard library and common idioms. clean, standardized, and much of the quality should match Golang's standard
library and common idioms.
* Always test using the latest Go version. * Always test using the latest Go version.
* Always use `gofmt` before committing anything. * Always use `gofmt` before committing anything.
* Always have proper documentation before committing. * Always have proper documentation before committing.
* Keep the same whitespacing, documentation, and newline format as the rest of the project. * Keep the same whitespacing, documentation, and newline format as the
* Only use 3rd party libraries if necessary. If only a small portion of the library is needed, simply rewrite it within rest of the project.
the library to prevent useless imports. * Only use 3rd party libraries if necessary. If only a small portion of
* Also see [golang/go/wiki/CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments) the library is needed, simply rewrite it within the library to prevent
useless imports.
* Also see [golang/go/wiki/CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments)
If you would like to assist, and the pull request is quite large and/or it has the potential of being a breaking change, If you would like to assist, and the pull request is quite large and/or it has
please open an issue first so it can be discussed. the potential of being a breaking change, please open an issue first so it can
be discussed.

@ -1,7 +1,6 @@
MIT License MIT License
Copyright (c) 2016 Liam Stanley <me@liamstanley.io> Copyright (c) 2016 Liam Stanley <me@liamstanley.io>
Copyright (c) 2016 yung innanet <kayos@tcp.directs>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

@ -1,59 +1,67 @@
<p align="center"><a href="https://tcp.ac/i/G5OTn" target="_blank"><img width="270" src="https://tcp.ac/i/G5OTn"></a></p> <p align="center"><a href="https://godoc.org/github.com/lrstanley/girc"><img width="270" src="http://i.imgur.com/DEnyrdB.png"></a></p>
<p align="center">girc-atomic, a terrifying fork of an IRC library for Go</p> <p align="center">girc, a flexible IRC library for Go</p>
<p align="center"> <p align="center">
<a href="https://godoc.org/github.com/yunginnanet/girc-atomic"><img src="https://godoc.org/github.com/yunginnanet/girc-atomic?status.png" alt="GoDoc"></a> <a href="https://github.com/lrstanley/girc/actions"><img src="https://github.com/lrstanley/girc/workflows/test/badge.svg" alt="Test Status"></a>
<a href="https://goreportcard.com/report/github.com/yunginnanet/girc-atomic"><img src="https://goreportcard.com/badge/github.com/yunginnanet/girc-atomic" alt="Go Report Card"></a> <a href="https://codecov.io/gh/lrstanley/girc"><img src="https://codecov.io/gh/lrstanley/girc/branch/master/graph/badge.svg" alt="Coverage Status"></a>
<a href="ircs://ircd.chat:6697/#tcpdirect"><img src="https://img.shields.io/badge/ircd.chat-%23tcpdirect-blue.svg" alt="IRC Chat"></a> <a href="https://pkg.go.dev/github.com/lrstanley/girc"><img src="https://pkg.go.dev/badge/github.com/lrstanley/girc" alt="GoDoc"></a>
<a href="https://github.com/yunginnanet/girc-atomic/actions/workflows/go.yml"><img src="https://github.com/yunginnanet/girc-atomic/actions/workflows/go.yml/badge.svg?branch=master" alt="Build Status"></a> <a href="https://goreportcard.com/report/github.com/lrstanley/girc"><img src="https://goreportcard.com/badge/github.com/lrstanley/girc" alt="Go Report Card"></a>
<a href="https://liam.sh/chat"><img src="https://img.shields.io/badge/community-chat%20with%20us-green.svg" alt="Community Chat"></a>
</p> </p>
## Fork changes
[Click here to see the changes in girc-atomic vs girc](https://github.com/lrstanley/girc/compare/master...yunginnanet:master)
## Status ## Status
### ₜₕₑ ₛₖy ᵢₛ 𝆑ₐₗₗᵢₙg ʇɥǝ sʞʎ ᴉs ⅎɐʅʅᴉuƃ **girc is fairly close to marking the 1.0.0 endpoint, which will be tagged as
### 𝚝𝚑𝚎𝚢 𝚜𝚑𝚘𝚞𝚕𝚍 𝚑𝚊𝚟𝚎 𝚕𝚒𝚜𝚝𝚎𝚗𝚎𝚍 necessary, so you will be able to use this with care knowing the specific tag
### ʇɥǝ sʞʎ ᴉs ⅎɐʅʅᴉuƃ ₜₕₑ ₛₖy ᵢₛ 𝆑ₐₗₗᵢₙg you're using won't have breaking changes**
## Features ## Features
- Focuses on ~~simplicity~~ ʀᴀɪɴɪɴɢ ʜᴇʟʟғɪʀᴇ, yet tries to still be flexible. - Focuses on simplicity, yet tries to still be flexible.
- Only requires ~~[standard library packages](https://godoc.org/github.com/yunginnanet/girc-atomic?imports)~~ a total destruction of a 100 mile radius. - Only requires [standard library packages](https://godoc.org/github.com/lrstanley/girc?imports)
- Event based triggering/responses ([example](https://godoc.org/github.com/yunginnanet/girc-atomic#ex-package--Commands), and [CTCP too](https://godoc.org/github.com/yunginnanet/girc-atomic#Commands.SendCTCP)!) - Event based triggering/responses ([example](https://godoc.org/github.com/lrstanley/girc#ex-package--Commands), and [CTCP too](https://godoc.org/github.com/lrstanley/girc#Commands.SendCTCP)!)
- [Documentation](https://godoc.org/github.com/yunginnanet/girc-atomic) is _mostly_ complete. - [Documentation](https://godoc.org/github.com/lrstanley/girc) is _mostly_ complete.
- Support for almost all of the [IRCv3 spec](http://ircv3.net/software/libraries.html). - Support for almost all of the [IRCv3 spec](http://ircv3.net/software/libraries.html).
- SASL Auth (currently only `PLAIN` and `EXTERNAL` is support by default, - SASL Auth (currently only `PLAIN` and `EXTERNAL` is support by default,
however you can simply implement `SASLMech` yourself to support additional however you can simply implement `SASLMech` yourself to support additional
mechanisms.) mechanisms.)
- Message tags (things like `account-tag` on by default) - Message tags (things like `account-tag` on by default)
- `account-notify`, `away-notify`, `chghost`, `extended-join`, etc -- all handled seemlessly ([cap.go](https://github.com/yunginnanet/girc-atomic/blob/master/cap.go) for more info). - `account-notify`, `away-notify`, `chghost`, `extended-join`, etc -- all handled seemlessly ([cap.go](https://github.com/lrstanley/girc/blob/master/cap.go) for more info).
- Channel and user tracking. Easily find what users are in a channel, if a - Channel and user tracking. Easily find what users are in a channel, if a
user is away, or if they are authenticated (if the server supports it!) user is away, or if they are authenticated (if the server supports it!)
- Client state/capability tracking. Easy methods to access capability data ([LookupChannel](https://godoc.org/github.com/yunginnanet/girc-atomic#Client.LookupChannel), [LookupUser](https://godoc.org/github.com/yunginnanet/girc-atomic#Client.LookupUser), [GetServerOpt (ISUPPORT)](https://godoc.org/github.com/yunginnanet/girc-atomic#Client.GetServerOpt), etc.) - Client state/capability tracking. Easy methods to access capability data ([LookupChannel](https://godoc.org/github.com/lrstanley/girc#Client.LookupChannel), [LookupUser](https://godoc.org/github.com/lrstanley/girc#Client.LookupUser), [GetServerOption (ISUPPORT)](https://godoc.org/github.com/lrstanley/girc#Client.GetServerOption), etc.)
- Built-in support for things you would commonly have to implement yourself. - Built-in support for things you would commonly have to implement yourself.
- Nick collision detection and prevention (also see [Config.HandleNickCollide](https://godoc.org/github.com/yunginnanet/girc-atomic#Config).) - Nick collision detection and prevention (also see [Config.HandleNickCollide](https://godoc.org/github.com/lrstanley/girc#Config).)
- Event/message rate limiting. - Event/message rate limiting.
- Channel, nick, and user validation methods ([IsValidChannel](https://godoc.org/github.com/yunginnanet/girc-atomic#IsValidChannel), [IsValidNick](https://godoc.org/github.com/yunginnanet/girc-atomic#IsValidNick), etc.) - Channel, nick, and user validation methods ([IsValidChannel](https://godoc.org/github.com/lrstanley/girc#IsValidChannel), [IsValidNick](https://godoc.org/github.com/lrstanley/girc#IsValidNick), etc.)
- CTCP handling and auto-responses ([CTCP](https://godoc.org/github.com/yunginnanet/girc-atomic#CTCP)) - CTCP handling and auto-responses ([CTCP](https://godoc.org/github.com/lrstanley/girc#CTCP))
- Utilizes atomics and concurrent maps to reduce backpressure in multi-client usage. (fork) - And more!
- Additional CTCP handlers and customization. (fork)
- ?????? ## Installing
- PROFIT!!!1!
$ go get -u github.com/lrstanley/girc
## Examples
See [the examples](https://godoc.org/github.com/lrstanley/girc#example-package--Bare)
within the documentation for real-world usecases. Here are a few real-world
usecases/examples/projects which utilize girc:
| Project | Description |
| --- | --- |
| [nagios-check-ircd](https://github.com/lrstanley/nagios-check-ircd) | Nagios utility for monitoring the health of an ircd |
| [nagios-notify-irc](https://github.com/lrstanley/nagios-notify-irc) | Nagios utility for sending alerts to one or many channels/networks |
| [matterbridge](https://github.com/42wim/matterbridge) | bridge between mattermost, IRC, slack, discord (and many others) with REST API |
Working on a project and want to add it to the list? Submit a pull request!
## Contributing ## Contributing
~~Please review the [CONTRIBUTING](CONTRIBUTING.md) doc for submitting issues/a guide Please review the [CONTRIBUTING](CONTRIBUTING.md) doc for submitting issues/a guide
on submitting pull requests and helping out.~~ on submitting pull requests and helping out.
**OH GOD PLEASE MAKE IT STOP**
## License ## License
Copyright (c) 2016 Liam Stanley <me@liamstanley.io> Copyright (c) 2016 Liam Stanley <me@liamstanley.io>
Copyright (c) 2022 yung innanet <kayos@tcp.direct>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -73,9 +81,7 @@ on submitting pull requests and helping out.~~
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
girc artwork licensed under [CC 3.0](http://creativecommons.org/licenses/by/3.0/) based on Renee French under Creative Commons 3.0 Attributions girc artwork licensed under [CC 3.0](http://creativecommons.org/licenses/by/3.0/) based on Renee French under Creative Commons 3.0 Attributions.
...and then later defiled by [some idiot](https://github.com/yunginnanet).
## References ## References

@ -5,88 +5,75 @@
package girc package girc
import ( import (
"fmt"
"strconv"
"strings" "strings"
"time" "time"
"github.com/araddon/dateparse"
) )
// registerBuiltin sets up built-in handlers, based on client // registerBuiltin sets up built-in handlers, based on client
// configuration. // configuration.
func (c *Client) registerBuiltins() { func (c *Client) registerBuiltins() {
c.debug.Print("registering built-in handlers") c.debug.Print("registering built-in handlers")
c.Handlers.mu.Lock()
// Built-in things that should always be supported. // Built-in things that should always be supported.
c.Handlers.register(true, true, RPL_WELCOME, HandlerFunc(handleConnect)) c.Handlers.register(true, true, RPL_WELCOME, HandlerFunc(handleConnect))
c.Handlers.register(true, false, PING, HandlerFunc(handlePING)) c.Handlers.register(true, false, PING, HandlerFunc(handlePING))
c.Handlers.register(true, false, PONG, HandlerFunc(handlePONG)) c.Handlers.register(true, false, PONG, HandlerFunc(handlePONG))
if !c.Config.disableTracking {
// Joins/parts/anything that may add/remove/rename users.
c.Handlers.register(true, false, JOIN, HandlerFunc(handleJOIN))
c.Handlers.register(true, false, PART, HandlerFunc(handlePART))
c.Handlers.register(true, false, KICK, HandlerFunc(handleKICK))
c.Handlers.register(true, false, QUIT, HandlerFunc(handleQUIT))
c.Handlers.register(true, false, NICK, HandlerFunc(handleNICK))
c.Handlers.register(true, false, RPL_NAMREPLY, HandlerFunc(handleNAMES))
// Modes.
c.Handlers.register(true, false, MODE, HandlerFunc(handleMODE))
c.Handlers.register(true, false, RPL_CHANNELMODEIS, HandlerFunc(handleMODE))
// WHO/WHOX responses.
c.Handlers.register(true, false, RPL_WHOREPLY, HandlerFunc(handleWHO))
c.Handlers.register(true, false, RPL_WHOSPCRPL, HandlerFunc(handleWHO))
// Other misc. useful stuff.
c.Handlers.register(true, false, TOPIC, HandlerFunc(handleTOPIC))
c.Handlers.register(true, false, RPL_TOPIC, HandlerFunc(handleTOPIC))
c.Handlers.register(true, false, RPL_MYINFO, HandlerFunc(handleMYINFO))
c.Handlers.register(true, false, RPL_ISUPPORT, HandlerFunc(handleISUPPORT))
c.Handlers.register(true, false, RPL_MOTDSTART, HandlerFunc(handleMOTD))
c.Handlers.register(true, false, RPL_MOTD, HandlerFunc(handleMOTD))
// Keep users lastactive times up to date.
c.Handlers.register(true, false, PRIVMSG, HandlerFunc(updateLastActive))
c.Handlers.register(true, false, NOTICE, HandlerFunc(updateLastActive))
c.Handlers.register(true, false, TOPIC, HandlerFunc(updateLastActive))
c.Handlers.register(true, false, KICK, HandlerFunc(updateLastActive))
// CAP IRCv3-specific tracking and functionality.
c.Handlers.register(true, false, CAP, HandlerFunc(handleCAP))
c.Handlers.register(true, false, CAP_CHGHOST, HandlerFunc(handleCHGHOST))
c.Handlers.register(true, false, CAP_AWAY, HandlerFunc(handleAWAY))
c.Handlers.register(true, false, CAP_ACCOUNT, HandlerFunc(handleACCOUNT))
c.Handlers.register(true, false, ALL_EVENTS, HandlerFunc(handleTags))
// SASL IRCv3 support.
c.Handlers.register(true, false, AUTHENTICATE, HandlerFunc(handleSASL))
c.Handlers.register(true, false, RPL_SASLSUCCESS, HandlerFunc(handleSASL))
c.Handlers.register(true, false, RPL_NICKLOCKED, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, ERR_SASLFAIL, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, ERR_SASLTOOLONG, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, ERR_SASLABORTED, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, RPL_SASLMECHS, HandlerFunc(handleSASLError))
}
// Nickname collisions. // Nickname collisions.
c.Handlers.register(true, false, ERR_NICKNAMEINUSE, HandlerFunc(nickCollisionHandler)) c.Handlers.register(true, false, ERR_NICKNAMEINUSE, HandlerFunc(nickCollisionHandler))
c.Handlers.register(true, false, ERR_NICKCOLLISION, HandlerFunc(nickCollisionHandler)) c.Handlers.register(true, false, ERR_NICKCOLLISION, HandlerFunc(nickCollisionHandler))
c.Handlers.register(true, false, ERR_UNAVAILRESOURCE, HandlerFunc(nickCollisionHandler)) c.Handlers.register(true, false, ERR_UNAVAILRESOURCE, HandlerFunc(nickCollisionHandler))
if c.Config.disableTracking { c.Handlers.mu.Unlock()
return
}
// Joins/parts/anything that may add/remove/rename users.
c.Handlers.register(true, false, JOIN, HandlerFunc(handleJOIN))
c.Handlers.register(true, false, PART, HandlerFunc(handlePART))
c.Handlers.register(true, false, KICK, HandlerFunc(handleKICK))
c.Handlers.register(true, false, QUIT, HandlerFunc(handleQUIT))
c.Handlers.register(true, false, NICK, HandlerFunc(handleNICK))
c.Handlers.register(true, false, RPL_NAMREPLY, HandlerFunc(handleNAMES))
// Modes.
c.Handlers.register(true, false, MODE, HandlerFunc(handleMODE))
c.Handlers.register(true, false, RPL_CHANNELMODEIS, HandlerFunc(handleMODE))
// Channel creation time.
c.Handlers.register(true, false, RPL_CREATIONTIME, HandlerFunc(handleCREATIONTIME))
// WHO/WHOX responses.
c.Handlers.register(true, false, RPL_WHOREPLY, HandlerFunc(handleWHO))
c.Handlers.register(true, false, RPL_WHOSPCRPL, HandlerFunc(handleWHO))
// Other misc. useful stuff.
c.Handlers.register(true, false, TOPIC, HandlerFunc(handleTOPIC))
c.Handlers.register(true, false, RPL_TOPIC, HandlerFunc(handleTOPIC))
c.Handlers.register(true, false, RPL_YOURHOST, HandlerFunc(handleYOURHOST))
c.Handlers.register(true, false, RPL_CREATED, HandlerFunc(handleCREATED))
c.Handlers.register(true, false, RPL_ISUPPORT, HandlerFunc(handleISUPPORT))
c.Handlers.register(true, false, RPL_LUSERCHANNELS, HandlerFunc(handleLUSERCHANNELS)) // 254
c.Handlers.register(true, false, RPL_GLOBALUSERS, HandlerFunc(handleGLOBALUSERS)) // 266
c.Handlers.register(true, false, RPL_LOCALUSERS, HandlerFunc(handleLOCALUSERS)) // 265
c.Handlers.register(true, false, RPL_LUSEROP, HandlerFunc(handleLUSEROP)) // 252
c.Handlers.register(true, false, RPL_MOTDSTART, HandlerFunc(handleMOTD))
c.Handlers.register(true, false, RPL_MOTD, HandlerFunc(handleMOTD))
// c.Handlers.register(true, false, RPL_MYINFO, HandlerFunc(handleMYINFO))
// Keep users lastactive times up to date.
c.Handlers.register(true, false, PRIVMSG, HandlerFunc(updateLastActive))
c.Handlers.register(true, false, NOTICE, HandlerFunc(updateLastActive))
c.Handlers.register(true, false, TOPIC, HandlerFunc(updateLastActive))
c.Handlers.register(true, false, KICK, HandlerFunc(updateLastActive))
// CAP IRCv3-specific tracking and functionality.
c.Handlers.register(true, false, CAP, HandlerFunc(handleCAP))
c.Handlers.register(true, false, CAP_CHGHOST, HandlerFunc(handleCHGHOST))
c.Handlers.register(true, false, CAP_AWAY, HandlerFunc(handleAWAY))
c.Handlers.register(true, false, CAP_ACCOUNT, HandlerFunc(handleACCOUNT))
c.Handlers.register(true, false, ALL_EVENTS, HandlerFunc(handleTags))
// SASL IRCv3 support.
c.Handlers.register(true, false, AUTHENTICATE, HandlerFunc(handleSASL))
c.Handlers.register(true, false, RPL_SASLSUCCESS, HandlerFunc(handleSASL))
c.Handlers.register(true, false, RPL_NICKLOCKED, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, ERR_SASLFAIL, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, ERR_SASLTOOLONG, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, ERR_SASLABORTED, HandlerFunc(handleSASLError))
c.Handlers.register(true, false, RPL_SASLMECHS, HandlerFunc(handleSASLError))
return
} }
// handleConnect is a helper function which lets the client know that enough // handleConnect is a helper function which lets the client know that enough
@ -98,46 +85,30 @@ func handleConnect(c *Client, e Event) {
// the one we supplied during connection, but some networks will rename // the one we supplied during connection, but some networks will rename
// users on connect. // users on connect.
if len(e.Params) > 0 { if len(e.Params) > 0 {
c.state.nick.Store(e.Params[0]) c.state.Lock()
c.state.nick = e.Params[0]
c.state.Unlock()
c.state.notify(c, UPDATE_GENERAL) c.state.notify(c, UPDATE_GENERAL)
split := strings.Split(e.Params[1], " ")
search:
for i, artifact := range split {
switch strings.ToLower(artifact) {
case "welcome", "to":
continue
case "the":
if len(split) < i {
break search
}
c.IRCd.Network.Store(split[i+1])
break search
default:
break search
}
}
} }
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
c.mu.RLock()
server := c.server() server := c.server()
c.mu.RUnlock()
c.RunHandlers(&Event{Command: CONNECTED, Params: []string{server}}) c.RunHandlers(&Event{Command: CONNECTED, Params: []string{server}})
} }
// nickCollisionHandler helps prevent the client from having conflicting // nickCollisionHandler helps prevent the client from having conflicting
// nicknames with another bot, user, etc. // nicknames with another bot, user, etc.
//
//goland:noinspection GoUnusedParameter
func nickCollisionHandler(c *Client, e Event) { func nickCollisionHandler(c *Client, e Event) {
if c.Config.HandleNickCollide == nil { if c.Config.HandleNickCollide == nil {
c.Cmd.Nick(c.GetNick() + "_") c.Cmd.Nick(c.GetNick() + "_")
return return
} }
newNick := c.Config.HandleNickCollide(c.GetNick()) c.Cmd.Nick(c.Config.HandleNickCollide(c.GetNick()))
if newNick != "" {
c.Cmd.Nick(newNick)
}
} }
// handlePING helps respond to ping requests from the server. // handlePING helps respond to ping requests from the server.
@ -145,9 +116,10 @@ func handlePING(c *Client, e Event) {
c.Cmd.Pong(e.Last()) c.Cmd.Pong(e.Last())
} }
//goland:noinspection GoUnusedParameter
func handlePONG(c *Client, e Event) { func handlePONG(c *Client, e Event) {
c.conn.lastPong.Store(time.Now()) c.conn.mu.Lock()
c.conn.lastPong = time.Now()
c.conn.mu.Unlock()
} }
// handleJOIN ensures that the state has updated users and channels. // handleJOIN ensures that the state has updated users and channels.
@ -158,9 +130,12 @@ func handleJOIN(c *Client, e Event) {
channelName := e.Params[0] channelName := e.Params[0]
c.state.Lock()
channel := c.state.lookupChannel(channelName) channel := c.state.lookupChannel(channelName)
if channel == nil { if channel == nil {
if ok := c.state.createChannel(channelName); !ok { if ok := c.state.createChannel(channelName); !ok {
c.state.Unlock()
return return
} }
@ -169,7 +144,8 @@ func handleJOIN(c *Client, e Event) {
user := c.state.lookupUser(e.Source.Name) user := c.state.lookupUser(e.Source.Name)
if user == nil { if user == nil {
if _, ok := c.state.createUser(e.Source); !ok { if ok := c.state.createUser(e.Source); !ok {
c.state.Unlock()
return return
} }
user = c.state.lookupUser(e.Source.Name) user = c.state.lookupUser(e.Source.Name)
@ -177,8 +153,8 @@ func handleJOIN(c *Client, e Event) {
defer c.state.notify(c, UPDATE_STATE) defer c.state.notify(c, UPDATE_STATE)
channel.addUser(user.Nick.Load().(string), user) channel.addUser(user.Nick)
user.addChannel(channel.Name, channel) user.addChannel(channel.Name)
// Assume extended-join (ircv3). // Assume extended-join (ircv3).
if len(e.Params) >= 2 { if len(e.Params) >= 2 {
@ -190,6 +166,7 @@ func handleJOIN(c *Client, e Event) {
user.Extras.Name = e.Params[2] user.Extras.Name = e.Params[2]
} }
} }
c.state.Unlock()
if e.Source.ID() == c.GetID() { if e.Source.ID() == c.GetID() {
// If it's us, don't just add our user to the list. Run a WHO which // If it's us, don't just add our user to the list. Run a WHO which
@ -201,8 +178,10 @@ func handleJOIN(c *Client, e Event) {
// Update our ident and host too, in state -- since there is no // Update our ident and host too, in state -- since there is no
// cleaner method to do this. // cleaner method to do this.
c.state.ident.Store(e.Source.Ident) c.state.Lock()
c.state.host.Store(e.Source.Host) c.state.ident = e.Source.Ident
c.state.host = e.Source.Host
c.state.Unlock()
return return
} }
@ -216,11 +195,7 @@ func handlePART(c *Client, e Event) {
return return
} }
c.debug.Println("handlePart")
defer c.debug.Println("handlePart done for " + e.Params[0])
// TODO: does this work if it's not the bot? // TODO: does this work if it's not the bot?
// er yes, but needs a test case
channel := e.Params[0] channel := e.Params[0]
@ -230,42 +205,16 @@ func handlePART(c *Client, e Event) {
defer c.state.notify(c, UPDATE_STATE) defer c.state.notify(c, UPDATE_STATE)
if chn := c.LookupChannel(channel); chn != nil {
chn.UserList.Remove(e.Source.ID())
c.debug.Println(fmt.Sprintf("removed: %s, new count: %d", e.Source.ID(), chn.Len()))
} else {
c.debug.Println("failed to lookup channel: " + channel)
}
if e.Source.ID() == c.GetID() { if e.Source.ID() == c.GetID() {
c.state.Lock()
c.state.deleteChannel(channel) c.state.deleteChannel(channel)
c.state.Unlock()
return return
} }
c.state.Lock()
c.state.deleteUser(channel, e.Source.ID()) c.state.deleteUser(channel, e.Source.ID())
} c.state.Unlock()
// handleCREATIONTIME handles incoming TOPIC events and keeps channel tracking info
// updated with the latest channel topic.
func handleCREATIONTIME(c *Client, e Event) {
var created string
var name string
switch len(e.Params) {
case 0, 1, 2:
return
default:
name = e.Params[1]
created = e.Params[2]
break
}
channel := c.state.lookupChannel(name)
if channel == nil {
return
}
channel.Created = created
c.state.notify(c, UPDATE_STATE)
} }
// handleTOPIC handles incoming TOPIC events and keeps channel tracking info // handleTOPIC handles incoming TOPIC events and keeps channel tracking info
@ -281,14 +230,15 @@ func handleTOPIC(c *Client, e Event) {
name = e.Params[1] name = e.Params[1]
} }
c.state.Lock()
channel := c.state.lookupChannel(name) channel := c.state.lookupChannel(name)
if channel == nil { if channel == nil {
c.state.Unlock()
return return
} }
channel.Topic = e.Last() channel.Topic = e.Last()
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
@ -333,35 +283,22 @@ func handleWHO(c *Client, e Event) {
} }
} }
c.state.Lock()
user := c.state.lookupUser(nick) user := c.state.lookupUser(nick)
if user == nil { if user == nil {
usr, _ := c.state.createUser(&Source{nick, ident, host}) c.state.Unlock()
usr.Extras.Name = realname
if account != "0" {
usr.Extras.Account = account
}
c.state.notify(c, UPDATE_STATE)
return return
} }
user.Host = host user.Host = host
user.Ident.Store(ident) user.Ident = ident
str := strs.Get()
str.MustWriteString(user.Nick.Load().(string))
str.MustWriteString("!")
str.MustWriteString(user.Ident.Load().(string))
str.MustWriteString("@")
str.MustWriteString(user.Host)
user.Mask.Store(str.String())
strs.MustPut(str)
user.Extras.Name = realname user.Extras.Name = realname
if account != "0" { if account != "0" {
user.Extras.Account = account user.Extras.Account = account
} }
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
@ -376,16 +313,16 @@ func handleKICK(c *Client, e Event) {
defer c.state.notify(c, UPDATE_STATE) defer c.state.notify(c, UPDATE_STATE)
if e.Params[1] == c.GetNick() { if e.Params[1] == c.GetNick() {
c.state.Lock()
c.state.deleteChannel(e.Params[0]) c.state.deleteChannel(e.Params[0])
c.state.Unlock()
return return
} }
// Assume it's just another user. // Assume it's just another user.
c.state.Lock()
c.state.deleteUser(e.Params[0], e.Params[1]) c.state.deleteUser(e.Params[0], e.Params[1])
c.state.Unlock()
} }
// handleNICK ensures that users are renamed in state, or the client name is // handleNICK ensures that users are renamed in state, or the client name is
@ -395,10 +332,12 @@ func handleNICK(c *Client, e Event) {
return return
} }
c.state.Lock()
// renameUser updates the LastActive time automatically. // renameUser updates the LastActive time automatically.
if len(e.Params) >= 1 { if len(e.Params) >= 1 {
c.state.renameUser(e.Source.ID(), e.Last()) c.state.renameUser(e.Source.ID(), e.Last())
} }
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
@ -412,101 +351,32 @@ func handleQUIT(c *Client, e Event) {
return return
} }
c.state.Lock()
c.state.deleteUser("", e.Source.ID()) c.state.deleteUser("", e.Source.ID())
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
func handleGLOBALUSERS(c *Client, e Event) { // handleMYINFO handles incoming MYINFO events -- these are commonly used
cusers, err := strconv.Atoi(e.Params[0]) // to tell us what the server name is, what version of software is being used
if err != nil { // as well as what channel and user modes are being used on the server.
func handleMYINFO(c *Client, e Event) {
// Malformed or odd output. As this can differ strongly between networks,
// just skip it.
if len(e.Params) < 3 {
return return
} }
musers, err := strconv.Atoi(e.Params[1])
if err != nil {
return
}
c.IRCd.UserCount = cusers
c.IRCd.MaxUserCount = musers
}
func handleLOCALUSERS(c *Client, e Event) { c.state.Lock()
cusers, err := strconv.Atoi(e.Params[1]) c.state.serverOptions["SERVER"] = e.Params[1]
if err != nil { c.state.serverOptions["VERSION"] = e.Params[2]
return c.state.Unlock()
}
musers, err := strconv.Atoi(e.Params[2])
if err != nil {
return
}
c.IRCd.LocalUserCount = cusers
c.IRCd.LocalMaxUserCount = musers
}
func handleLUSERCHANNELS(c *Client, e Event) {
ccount, err := strconv.Atoi(e.Params[1])
if err != nil {
return
}
c.IRCd.ChannelCount = ccount
}
func handleLUSEROP(c *Client, e Event) {
ocount, err := strconv.Atoi(e.Params[1])
if err != nil {
return
}
c.IRCd.OperCount = ocount
}
// handleCREATED handles incoming CREATED events.
// This is commonly used to tell us when the IRC daemon was compiled.
func handleCREATED(c *Client, e Event) {
split := strings.Split(e.Params[1], " ")
days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
found := -1
for i, word := range split {
for _, day := range days {
if word == day+"," {
found = i
break
}
}
}
if found == -1 {
return
}
compiled, err := dateparse.ParseAny(strings.Join(split[found:], " "))
if err != nil {
return
}
c.IRCd.Compiled = compiled
c.state.notify(c, UPDATE_GENERAL)
}
// handleYOURHOST handles incoming YOURHOST events.
// This is commonly used to tell us details on the currently connected leaf.
func handleYOURHOST(c *Client, e Event) {
var host = ""
var ver = ""
const prefix = "Your host is "
const suffix = " running version "
if strings.Contains(e.Params[1], prefix) && strings.Contains(e.Params[1], ",") {
s := strings.TrimPrefix(e.Params[1], prefix)
split := strings.Split(s, ",")
host = split[0]
ver = strings.Replace(split[1], suffix, "", 1)
}
if len(host)+len(ver) == 0 {
return
}
c.IRCd.Host = host
c.IRCd.Version = ver
c.state.notify(c, UPDATE_GENERAL) c.state.notify(c, UPDATE_GENERAL)
} }
// handleISUPPORT handles incoming RPL_ISUPPORT (also known as RPL_PROTOCTL) // handleISUPPORT handles incoming RPL_ISUPPORT (also known as RPL_PROTOCTL)
// events. This commonly contains the date of the daemon's compilation. // events. These commonly contain the server capabilities and limitations.
// For example, things like max channel name length, or nickname length.
func handleISUPPORT(c *Client, e Event) { func handleISUPPORT(c *Client, e Event) {
// Must be a ISUPPORT-based message. // Must be a ISUPPORT-based message.
@ -520,26 +390,62 @@ func handleISUPPORT(c *Client, e Event) {
return return
} }
c.state.Lock()
// Skip the first parameter, as it's our nickname, and the last, as it's the doc. // Skip the first parameter, as it's our nickname, and the last, as it's the doc.
for i := range e.Params { for i := 1; i < len(e.Params)-1; i++ {
split := strings.Split(e.Params[i], "=") j := strings.IndexByte(e.Params[i], '=')
if len(split) != 2 { if j < 1 || (j+1) == len(e.Params[i]) {
c.state.serverOptions.Set(e.Params[i], "") c.state.serverOptions[e.Params[i]] = ""
continue continue
} }
if len(split[0]) < 1 || len(split[1]) < 1 { name := e.Params[i][0:j]
c.state.serverOptions.Set(e.Params[i], "") val := e.Params[i][j+1:]
continue c.state.serverOptions[name] = val
}
if split[0] == "NETWORK" {
c.state.network.Store(split[1])
}
c.state.serverOptions.Set(split[0], split[1])
} }
c.state.Unlock()
// Check for max line/nick/user/host lengths here.
c.state.RLock()
maxLineLength := c.state.maxLineLength
c.state.RUnlock()
maxNickLength := defaultNickLength
maxUserLength := defaultUserLength
maxHostLength := defaultHostLength
var ok bool
var tmp int
if tmp, ok = c.GetServerOptionInt("LINELEN"); ok {
maxLineLength = tmp
c.state.Lock()
c.state.maxLineLength = maxTagLength - 2 // -2 for CR-LF.
c.state.Unlock()
}
if tmp, ok = c.GetServerOptionInt("NICKLEN"); ok {
maxNickLength = tmp
}
if tmp, ok = c.GetServerOptionInt("MAXNICKLEN"); ok && tmp > maxNickLength {
maxNickLength = tmp
}
if tmp, ok = c.GetServerOptionInt("USERLEN"); ok && tmp > maxUserLength {
maxUserLength = tmp
}
if tmp, ok = c.GetServerOptionInt("HOSTLEN"); ok && tmp > maxHostLength {
maxHostLength = tmp
}
prefixLen := defaultPrefixPadding + maxNickLength + maxUserLength + maxHostLength
if prefixLen >= maxLineLength {
// Give up and go with defaults.
c.state.notify(c, UPDATE_GENERAL)
return
}
c.state.Lock()
c.state.maxPrefixLength = prefixLen
c.state.Unlock()
c.state.notify(c, UPDATE_GENERAL) c.state.notify(c, UPDATE_GENERAL)
} }
@ -547,19 +453,24 @@ func handleISUPPORT(c *Client, e Event) {
// handleMOTD handles incoming MOTD messages and buffers them up for use with // handleMOTD handles incoming MOTD messages and buffers them up for use with
// Client.ServerMOTD(). // Client.ServerMOTD().
func handleMOTD(c *Client, e Event) { func handleMOTD(c *Client, e Event) {
c.state.Lock()
defer c.state.notify(c, UPDATE_GENERAL) defer c.state.notify(c, UPDATE_GENERAL)
// Beginning of the MOTD. // Beginning of the MOTD.
if e.Command == RPL_MOTDSTART { if e.Command == RPL_MOTDSTART {
c.state.motd = "" c.state.motd = ""
c.state.Unlock()
return return
} }
// Otherwise, assume we're getting sent the MOTD line-by-line. // Otherwise, assume we're getting sent the MOTD line-by-line.
if c.state.motd != "" { if len(c.state.motd) != 0 {
c.state.motd += "\n" c.state.motd += "\n"
} }
c.state.motd += e.Last() c.state.motd += e.Last()
c.state.Unlock()
} }
// handleNAMES handles incoming NAMES queries, of which lists all users in // handleNAMES handles incoming NAMES queries, of which lists all users in
@ -579,8 +490,9 @@ func handleNAMES(c *Client, e Event) {
var modes, nick string var modes, nick string
var ok bool var ok bool
var s *Source s := &Source{}
c.state.Lock()
for i := 0; i < len(parts); i++ { for i := 0; i < len(parts); i++ {
modes, nick, ok = parseUserPrefix(parts[i]) modes, nick, ok = parseUserPrefix(parts[i])
if !ok { if !ok {
@ -610,14 +522,15 @@ func handleNAMES(c *Client, e Event) {
continue continue
} }
user.addChannel(channel.Name, channel) user.addChannel(channel.Name)
channel.addUser(s.ID(), user) channel.addUser(s.ID())
// Don't append modes, overwrite them. // Don't append modes, overwrite them.
perms, _ := user.Perms.Lookup(channel.Name) perms, _ := user.Perms.Lookup(channel.Name)
perms.set(modes, false) perms.set(modes, false)
user.Perms.set(channel.Name, perms) user.Perms.set(channel.Name, perms)
} }
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
@ -630,11 +543,15 @@ func updateLastActive(c *Client, e Event) {
return return
} }
c.state.Lock()
// Update the users last active time, if they exist. // Update the users last active time, if they exist.
user := c.state.lookupUser(e.Source.Name) user := c.state.lookupUser(e.Source.Name)
if user == nil { if user == nil {
c.state.Unlock()
return return
} }
user.LastActive = time.Now() user.LastActive = time.Now()
c.state.Unlock()
} }

39
cap.go

@ -49,7 +49,9 @@ var possibleCap = map[string][]string{
const capServerTimeFormat = "2006-01-02T15:04:05.999Z" const capServerTimeFormat = "2006-01-02T15:04:05.999Z"
func (c *Client) listCAP() { func (c *Client) listCAP() {
c.write(&Event{Command: CAP, Params: []string{CAP_LS, "302"}}) if !c.Config.disableTracking {
c.write(&Event{Command: CAP, Params: []string{CAP_LS, "302"}})
}
} }
func possibleCapList(c *Client) map[string][]string { func possibleCapList(c *Client) map[string][]string {
@ -62,7 +64,7 @@ func possibleCapList(c *Client) map[string][]string {
if !c.Config.DisableSTS && !c.Config.SSL { if !c.Config.DisableSTS && !c.Config.SSL {
// If fallback supported, and we failed recently, don't try negotiating STS. // If fallback supported, and we failed recently, don't try negotiating STS.
// ONLY do this fallback if we're expired (primarily useful during the first // ONLY do this fallback if we're expired (primarily useful during the first
// sts negotiation). // sts negotation).
if time.Since(c.state.sts.lastFailed) < 5*time.Minute && !c.Config.DisableSTSFallback { if time.Since(c.state.sts.lastFailed) < 5*time.Minute && !c.Config.DisableSTSFallback {
c.debug.Println("skipping strict transport policy negotiation; failed within the last 5 minutes") c.debug.Println("skipping strict transport policy negotiation; failed within the last 5 minutes")
} else { } else {
@ -104,7 +106,7 @@ func parseCap(raw string) map[string]map[string]string {
if j < 0 { if j < 0 {
out[parts[i][:val]][option] = "" out[parts[i][:val]][option] = ""
} else { } else {
out[parts[i][:val]][option[:j]] = option[j+1:] out[parts[i][:val]][option[:j]] = option[j+1 : len(option)]
} }
} }
} }
@ -121,8 +123,9 @@ func handleCAP(c *Client, e Event) {
if len(e.Params) >= 2 && e.Params[1] == CAP_DEL { if len(e.Params) >= 2 && e.Params[1] == CAP_DEL {
caps := parseCap(e.Last()) caps := parseCap(e.Last())
for capab := range caps { for cap := range caps {
c.state.enabledCap.Remove(capab) // TODO: test the deletion.
delete(c.state.enabledCap, cap)
} }
return return
} }
@ -191,12 +194,12 @@ func handleCAP(c *Client, e Event) {
if len(e.Params) == 3 && e.Params[1] == CAP_ACK { if len(e.Params) == 3 && e.Params[1] == CAP_ACK {
enabled := strings.Split(e.Last(), " ") enabled := strings.Split(e.Last(), " ")
for _, capab := range enabled { for _, cap := range enabled {
if val, ok := c.state.tmpCap[capab]; ok { if val, ok := c.state.tmpCap[cap]; ok {
c.state.enabledCap.Set(capab, val) c.state.enabledCap[cap] = val
continue } else {
c.state.enabledCap[cap] = nil
} }
c.state.enabledCap.Remove(capab)
} }
// Anything client side that needs to be setup post-capability-acknowledgement, // Anything client side that needs to be setup post-capability-acknowledgement,
@ -204,8 +207,9 @@ func handleCAP(c *Client, e Event) {
// Handle STS, and only if it's something specifically we enabled (client // Handle STS, and only if it's something specifically we enabled (client
// may choose to disable girc automatic STS, and do it themselves). // may choose to disable girc automatic STS, and do it themselves).
if sts, sok := c.state.enabledCap.Get("sts"); sok && !c.Config.DisableSTS { if sts, sok := c.state.enabledCap["sts"]; sok && !c.Config.DisableSTS {
var isError bool var isError bool
// Some things are updated in the policy depending on if the current // Some things are updated in the policy depending on if the current
// connection is over tls or not. // connection is over tls or not.
var hasTLSConnection bool var hasTLSConnection bool
@ -284,7 +288,7 @@ func handleCAP(c *Client, e Event) {
// due to cap-notify, we can re-evaluate what we can support. // due to cap-notify, we can re-evaluate what we can support.
c.state.tmpCap = make(map[string]map[string]string) c.state.tmpCap = make(map[string]map[string]string)
if _, ok := c.state.enabledCap.Get("sasl"); ok && c.Config.SASL != nil { if _, ok := c.state.enabledCap["sasl"]; ok && c.Config.SASL != nil {
c.write(&Event{Command: AUTHENTICATE, Params: []string{c.Config.SASL.Method()}}) c.write(&Event{Command: AUTHENTICATE, Params: []string{c.Config.SASL.Method()}})
// Don't "CAP END", since we want to authenticate. // Don't "CAP END", since we want to authenticate.
return return
@ -305,24 +309,25 @@ func handleCHGHOST(c *Client, e Event) {
return return
} }
c.state.Lock()
user := c.state.lookupUser(e.Source.Name) user := c.state.lookupUser(e.Source.Name)
if user != nil { if user != nil {
user.Ident.Store(e.Params[0]) user.Ident = e.Params[0]
user.Host = e.Params[1] user.Host = e.Params[1]
} }
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
// handleAWAY handles incoming IRCv3 AWAY events, for which are sent both // handleAWAY handles incoming IRCv3 AWAY events, for which are sent both
// when users are no longer away, or when they are away. // when users are no longer away, or when they are away.
func handleAWAY(c *Client, e Event) { func handleAWAY(c *Client, e Event) {
c.state.Lock()
user := c.state.lookupUser(e.Source.Name) user := c.state.lookupUser(e.Source.Name)
if user != nil { if user != nil {
user.Extras.Away = e.Last() user.Extras.Away = e.Last()
} }
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
@ -340,9 +345,11 @@ func handleACCOUNT(c *Client, e Event) {
account = "" account = ""
} }
c.state.Lock()
user := c.state.lookupUser(e.Source.Name) user := c.state.lookupUser(e.Source.Name)
if user != nil { if user != nil {
user.Extras.Account = account user.Extras.Account = account
} }
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }

@ -120,6 +120,7 @@ func handleSASL(c *Client, e Event) {
break break
} }
} }
return
} }
func handleSASLError(c *Client, e Event) { func handleSASLError(c *Client, e Event) {

@ -24,11 +24,12 @@ func handleTags(c *Client, e Event) {
return return
} }
c.state.Lock()
user := c.state.lookupUser(e.Source.ID()) user := c.state.lookupUser(e.Source.ID())
if user != nil { if user != nil {
user.Extras.Account = account user.Extras.Account = account
} }
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
@ -51,12 +52,9 @@ type Tags map[string]string
// ParseTags parses out the key-value map of tags. raw should only be the tag // ParseTags parses out the key-value map of tags. raw should only be the tag
// data, not a full message. For example: // data, not a full message. For example:
// // @aaa=bbb;ccc;example.com/ddd=eee
// @aaa=bbb;ccc;example.com/ddd=eee
//
// NOT: // NOT:
// // @aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello
// @aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello
// //
// Technically, there is a length limit of 4096, but the server should reject // Technically, there is a length limit of 4096, but the server should reject
// tag messages longer than this. // tag messages longer than this.
@ -252,6 +250,10 @@ func (t Tags) Get(key string) (tag string, success bool) {
// Set escapes given value and saves it as the value for given key. Note that // Set escapes given value and saves it as the value for given key. Note that
// this is not concurrent safe. // this is not concurrent safe.
func (t Tags) Set(key, value string) error { func (t Tags) Set(key, value string) error {
if t == nil {
t = make(Tags)
}
if !validTag(key) { if !validTag(key) {
return fmt.Errorf("tag key %q is invalid", key) return fmt.Errorf("tag key %q is invalid", key)
} }

@ -16,7 +16,6 @@ func TestCapSupported(t *testing.T) {
User: "user", User: "user",
SASL: &SASLPlain{User: "test", Pass: "example"}, SASL: &SASLPlain{User: "test", Pass: "example"},
SupportedCaps: map[string][]string{"example": nil}, SupportedCaps: map[string][]string{"example": nil},
// Debug: os.Stdout,
}) })
var ok bool var ok bool
@ -34,36 +33,26 @@ func TestCapSupported(t *testing.T) {
} }
} }
var testsParseCap = []struct { func TestParseCap(t *testing.T) {
in string tests := []struct {
want map[string]map[string]string in string
}{ want map[string]map[string]string
{in: "sts=port=6697,duration=1234567890,preload", want: map[string]map[string]string{"sts": {"duration": "1234567890", "preload": "", "port": "6697"}}}, }{
{in: "userhost-in-names", want: map[string]map[string]string{"userhost-in-names": nil}}, {in: "sts=port=6697,duration=1234567890,preload", want: map[string]map[string]string{"sts": {"duration": "1234567890", "preload": "", "port": "6697"}}},
{in: "userhost-in-names test2", want: map[string]map[string]string{"userhost-in-names": nil, "test2": nil}}, {in: "userhost-in-names", want: map[string]map[string]string{"userhost-in-names": nil}},
{in: "example/name=test", want: map[string]map[string]string{"example/name": {"test": ""}}}, {in: "userhost-in-names test2", want: map[string]map[string]string{"userhost-in-names": nil, "test2": nil}},
{ {in: "example/name=test", want: map[string]map[string]string{"example/name": {"test": ""}}},
in: "userhost-in-names example/name example/name2=test=1,test2=true", {
want: map[string]map[string]string{ in: "userhost-in-names example/name example/name2=test=1,test2=true",
"userhost-in-names": nil, want: map[string]map[string]string{
"example/name": nil, "userhost-in-names": nil,
"example/name2": {"test": "1", "test2": "true"}, "example/name": nil,
"example/name2": {"test": "1", "test2": "true"},
},
}, },
},
}
func FuzzParseCap(f *testing.F) {
for _, tc := range testsParseCap {
f.Add(tc.in)
} }
f.Fuzz(func(t *testing.T, orig string) { for _, tt := range tests {
_ = parseCap(orig)
})
}
func TestParseCap(t *testing.T) {
for _, tt := range testsParseCap {
got := parseCap(tt.in) got := parseCap(tt.in)
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
@ -115,7 +104,7 @@ func TestTagGetSetCount(t *testing.T) {
} }
// Add a hidden ascii value at the end to make it invalid. // Add a hidden ascii value at the end to make it invalid.
if err := e.Tags.Set("key", "invalid-value\b"); err == nil { if err := e.Tags.Set("key", "invalid-value"+string(rune(0x08))); err == nil {
t.Fatal("tag set of invalid value should have returned error") t.Fatal("tag set of invalid value should have returned error")
} }
} }

329
client.go

@ -7,10 +7,10 @@ package girc
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"net" "net"
"os" "os"
@ -19,10 +19,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
cmap "github.com/orcaman/concurrent-map/v2"
) )
// Client contains all of the information necessary to run a single IRC // Client contains all of the information necessary to run a single IRC
@ -50,10 +47,6 @@ type Client struct {
// so multiple threads aren't trying to connect at the same time, and // so multiple threads aren't trying to connect at the same time, and
// vice versa. // vice versa.
mu sync.RWMutex mu sync.RWMutex
// IRCd encapsulates IRC Server details.
IRCd Server
// stop is used to communicate with Connect(), letting it know that the // stop is used to communicate with Connect(), letting it know that the
// client wishes to cancel/close. // client wishes to cancel/close.
stop context.CancelFunc stop context.CancelFunc
@ -64,32 +57,6 @@ type Client struct {
conn *ircConn conn *ircConn
// debug is used if a writer is supplied for Client.Config.Debugger. // debug is used if a writer is supplied for Client.Config.Debugger.
debug *log.Logger debug *log.Logger
atom uint32
}
// Server contains information about the IRC server that the client is connected to.
type Server struct {
// Network is the name of the IRC network we are connected to as acquired by 001.
Network atomic.Value
// Version is the software version of the IRC daemon as acquired by 004.
Version string
// Host is the hostname/id/IP of the leaf, as acquired by 002.
Host string
// compiled is the reported date the server was compiled on as acquired by 003.
Compiled time.Time
// UserCount is the amount of online users currently on this network as acquired by 251.
UserCount int
// MaxUserCount is the amount of online users currently on this network as acquired by 251.
MaxUserCount int
// LocalUserCount is the amount of online users currently on this leaf as acquired by 265.
LocalUserCount int
// LocalMaxUserCount is the maximum amount of users that have been on this leaf as acquired by 265.
LocalMaxUserCount int
// OperCount is the amount of opers currently online as acquired by 252.
OperCount int
// ChannelCount is the amount of channels formed as acquired by 254.
ChannelCount int
} }
// Config contains configuration options for an IRC client // Config contains configuration options for an IRC client
@ -179,20 +146,9 @@ type Config struct {
// to the server if supported. // to the server if supported.
SupportedCaps map[string][]string SupportedCaps map[string][]string
// Version is the application version information that will be used in // Version is the application version information that will be used in
// response to a CTCP VERSION. A default message will be sent otherwise. // response to a CTCP VERSION, if default CTCP replies have not been
// overwritten or a VERSION handler was already supplied.
Version string Version string
// ClientInfo is the application ClientInfo code information that will be used in
// response to a CTCP CLIENTINFO. No response will be sent if this is not set.
ClientInfo string
// UserInfo is the user information that will be used in
// response to a CTCP USERINFO. No response will be sent if this is not set.
UserInfo string
// Finger is the client information that will be used in
// response to a CTCP FINGER. A default message will be sent otherwise.
Finger string
// Source is the application source code information that will be used in
// response to a CTCP SOURCE. A default message will be sent otherwise.
Source string
// PingDelay is the frequency between when the client sends a keep-alive // PingDelay is the frequency between when the client sends a keep-alive
// PING to the server, and awaits a response (and times out if the server // PING to the server, and awaits a response (and times out if the server
// doesn't respond in time). This should be between 20-600 seconds. See // doesn't respond in time). This should be between 20-600 seconds. See
@ -212,9 +168,6 @@ type Config struct {
// an invalid nickname. For example, if "test" is already in use, or is // an invalid nickname. For example, if "test" is already in use, or is
// blocked by the network/a service, the client will try and use "test_", // blocked by the network/a service, the client will try and use "test_",
// then it will attempt "test__", "test___", and so on. // then it will attempt "test__", "test___", and so on.
//
// If HandleNickCollide returns an empty string, the client will not
// attempt to fix nickname collisions, and you must handle this yourself.
HandleNickCollide func(oldNick string) (newNick string) HandleNickCollide func(oldNick string) (newNick string)
} }
@ -224,13 +177,13 @@ type Config struct {
// server. // server.
// //
// Client expectations: // Client expectations:
// - Perform any proxy resolution. // - Perform any proxy resolution.
// - Check the reverse DNS and forward DNS match. // - Check the reverse DNS and forward DNS match.
// - Check the IP against suitable access controls (ipaccess, dnsbl, etc). // - Check the IP against suitable access controls (ipaccess, dnsbl, etc).
// //
// More information: // More information:
// - https://ircv3.net/specs/extensions/webirc.html // - https://ircv3.net/specs/extensions/webirc.html
// - https://kiwiirc.com/docs/webirc // - https://kiwiirc.com/docs/webirc
type WebIRC struct { type WebIRC struct {
// Password that authenticates the WEBIRC command from this client. // Password that authenticates the WEBIRC command from this client.
Password string Password string
@ -276,10 +229,10 @@ func (conf *Config) isValid() error {
} }
if !IsValidNick(conf.Nick) { if !IsValidNick(conf.Nick) {
return &ErrInvalidConfig{Conf: *conf, err: fmt.Errorf("bad nickname specified: %s", conf.Nick)} return &ErrInvalidConfig{Conf: *conf, err: errors.New("bad nickname specified")}
} }
if !IsValidUser(conf.User) { if !IsValidUser(conf.User) {
return &ErrInvalidConfig{Conf: *conf, err: fmt.Errorf("bad user/ident specified: %s", conf.Nick)} return &ErrInvalidConfig{Conf: *conf, err: errors.New("bad user/ident specified")}
} }
return nil return nil
@ -299,15 +252,6 @@ func New(config Config) *Client {
initTime: time.Now(), initTime: time.Now(),
} }
c.IRCd = Server{
Network: atomic.Value{},
Version: "",
UserCount: 0,
MaxUserCount: 0,
}
c.IRCd.Network.Store("")
c.Cmd = &Commands{c: c} c.Cmd = &Commands{c: c}
if c.Config.PingDelay >= 0 && c.Config.PingDelay < (20*time.Second) { if c.Config.PingDelay >= 0 && c.Config.PingDelay < (20*time.Second) {
@ -321,7 +265,7 @@ func New(config Config) *Client {
if envDebug { if envDebug {
c.debug = log.New(os.Stderr, "debug:", log.Ltime|log.Lshortfile) c.debug = log.New(os.Stderr, "debug:", log.Ltime|log.Lshortfile)
} else { } else {
c.debug = log.New(io.Discard, "", 0) c.debug = log.New(ioutil.Discard, "", 0)
} }
} else { } else {
if envDebug { if envDebug {
@ -333,26 +277,18 @@ func New(config Config) *Client {
c.debug.Print("initializing debugging") c.debug.Print("initializing debugging")
} }
envDisableSTS, _ := strconv.ParseBool(os.Getenv("GIRC_DISABLE_STS")) envDisableSTS, _ := strconv.ParseBool((os.Getenv("GIRC_DISABLE_STS")))
if envDisableSTS { if envDisableSTS {
c.Config.DisableSTS = envDisableSTS c.Config.DisableSTS = envDisableSTS
} }
// Setup the caller. // Setup the caller.
c.Handlers = newCaller(c, c.debug) c.Handlers = newCaller(c.debug)
// Give ourselves a new state. // Give ourselves a new state.
c.state = &state{ c.state = &state{}
channels: cmap.New[*Channel](),
users: cmap.New[*User](),
enabledCap: cmap.New[map[string]string](),
serverOptions: cmap.New[string](),
}
c.state.RWMutex = &sync.RWMutex{}
c.state.reset(true) c.state.reset(true)
c.state.client = c
// Register builtin handlers. // Register builtin handlers.
c.registerBuiltins() c.registerBuiltins()
@ -377,11 +313,16 @@ func (c *Client) String() string {
// connection wasn't established using TLS (see ErrConnNotTLS), or if the // connection wasn't established using TLS (see ErrConnNotTLS), or if the
// client isn't connected. // client isn't connected.
func (c *Client) TLSConnectionState() (*tls.ConnectionState, error) { func (c *Client) TLSConnectionState() (*tls.ConnectionState, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.conn == nil { if c.conn == nil {
return nil, ErrNotConnected return nil, ErrNotConnected
} }
if !c.conn.connected.Load().(bool) { c.conn.mu.RLock()
defer c.conn.mu.RUnlock()
if !c.conn.connected {
return nil, ErrNotConnected return nil, ErrNotConnected
} }
@ -402,10 +343,12 @@ var ErrConnNotTLS = errors.New("underlying connection is not tls")
// safe to call multiple times. See Connect()'s documentation on how // safe to call multiple times. See Connect()'s documentation on how
// handlers and goroutines are handled when disconnected from the server. // handlers and goroutines are handled when disconnected from the server.
func (c *Client) Close() { func (c *Client) Close() {
c.mu.RLock()
if c.stop != nil { if c.stop != nil {
c.debug.Print("requesting client to stop") c.debug.Print("requesting client to stop")
c.stop() c.stop()
} }
c.mu.RUnlock()
} }
// Quit sends a QUIT message to the server with a given reason to close the // Quit sends a QUIT message to the server with a given reason to close the
@ -435,12 +378,10 @@ func (e *ErrEvent) Error() string {
return e.Event.Last() return e.Event.Last()
} }
func (c *Client) execLoop(ctx context.Context, errs chan error, working *int32) { func (c *Client) execLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
c.debug.Print("starting execLoop") c.debug.Print("starting execLoop")
defer c.debug.Print("closing execLoop") defer c.debug.Print("closing execLoop")
defer atomic.AddInt32(working, -1)
var event *Event var event *Event
for { for {
@ -460,6 +401,7 @@ func (c *Client) execLoop(ctx context.Context, errs chan error, working *int32)
} }
done: done:
wg.Done()
return return
case event = <-c.rx: case event = <-c.rx:
if event != nil && event.Command == ERROR { if event != nil && event.Command == ERROR {
@ -490,7 +432,9 @@ func (c *Client) DisableTracking() {
c.Config.disableTracking = true c.Config.disableTracking = true
c.Handlers.clearInternal() c.Handlers.clearInternal()
c.state.channels.Clear() c.state.Lock()
c.state.channels = nil
c.state.Unlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
c.registerBuiltins() c.registerBuiltins()
@ -498,6 +442,9 @@ func (c *Client) DisableTracking() {
// Server returns the string representation of host+port pair for the connection. // Server returns the string representation of host+port pair for the connection.
func (c *Client) Server() string { func (c *Client) Server() string {
c.state.Lock()
defer c.state.Lock()
return c.server() return c.server()
} }
@ -518,12 +465,16 @@ func (c *Client) Lifetime() time.Duration {
// Uptime is the time at which the client successfully connected to the // Uptime is the time at which the client successfully connected to the
// server. // server.
func (c *Client) Uptime() (up time.Time, err error) { func (c *Client) Uptime() (up *time.Time, err error) {
if !c.IsConnected() { if !c.IsConnected() {
return time.Now(), ErrNotConnected return nil, ErrNotConnected
} }
up = c.conn.connTime.Load().(time.Time) c.mu.RLock()
c.conn.mu.RLock()
up = c.conn.connTime
c.conn.mu.RUnlock()
c.mu.RUnlock()
return up, nil return up, nil
} }
@ -535,40 +486,43 @@ func (c *Client) ConnSince() (since *time.Duration, err error) {
return nil, ErrNotConnected return nil, ErrNotConnected
} }
timeSince := time.Since(c.conn.connTime.Load().(time.Time)) c.mu.RLock()
c.conn.mu.RLock()
timeSince := time.Since(*c.conn.connTime)
c.conn.mu.RUnlock()
c.mu.RUnlock()
return &timeSince, nil return &timeSince, nil
} }
// IsConnected returns true if the client is connected to the server. // IsConnected returns true if the client is connected to the server.
func (c *Client) IsConnected() bool { func (c *Client) IsConnected() bool {
if c == nil { c.mu.RLock()
return false
}
if c.conn == nil { if c.conn == nil {
c.mu.RUnlock()
return false return false
} }
if c.conn.connected.Load() == nil {
c.conn.connected.Store(false)
}
return c.conn.connected.Load().(bool) c.conn.mu.RLock()
connected := c.conn.connected
c.conn.mu.RUnlock()
c.mu.RUnlock()
return connected
} }
// GetNick returns the current nickname of the active connection. Panics if // GetNick returns the current nickname of the active connection. Panics if
// tracking is disabled. // tracking is disabled.
func (c *Client) GetNick() string { func (c *Client) GetNick() string {
if c == nil {
return ""
}
c.panicIfNotTracking() c.panicIfNotTracking()
n := c.state.nick.Load().(string)
if len(n) < 1 { c.state.RLock()
defer c.state.RUnlock()
if c.state.nick == "" {
return c.Config.Nick return c.Config.Nick
} }
return c.state.nick
return n
} }
// GetID returns an RFC1459 compliant version of the current nickname. Panics // GetID returns an RFC1459 compliant version of the current nickname. Panics
@ -583,10 +537,13 @@ func (c *Client) GetID() string {
func (c *Client) GetIdent() string { func (c *Client) GetIdent() string {
c.panicIfNotTracking() c.panicIfNotTracking()
if c.state.ident.Load().(string) == "" { c.state.RLock()
defer c.state.RUnlock()
if c.state.ident == "" {
return c.Config.User return c.Config.User
} }
return c.state.ident.Load().(string) return c.state.ident
} }
// GetHost returns the current host of the active connection. Panics if // GetHost returns the current host of the active connection. Panics if
@ -595,8 +552,9 @@ func (c *Client) GetIdent() string {
func (c *Client) GetHost() (host string) { func (c *Client) GetHost() (host string) {
c.panicIfNotTracking() c.panicIfNotTracking()
host = c.state.host.Load().(string) c.state.RLock()
host = c.state.host
c.state.RUnlock()
return host return host
} }
@ -605,15 +563,12 @@ func (c *Client) GetHost() (host string) {
func (c *Client) ChannelList() []string { func (c *Client) ChannelList() []string {
c.panicIfNotTracking() c.panicIfNotTracking()
channels := make([]string, 0, len(c.state.channels.Keys())) c.state.RLock()
for channel := range c.state.channels.IterBuffered() { channels := make([]string, 0, len(c.state.channels))
chn := channel.Val for channel := range c.state.channels {
if !chn.UserIn(c.GetNick()) { channels = append(channels, c.state.channels[channel].Name)
continue
}
channels = append(channels, chn.Name)
} }
c.state.RUnlock()
sort.Strings(channels) sort.Strings(channels)
return channels return channels
} }
@ -623,11 +578,12 @@ func (c *Client) ChannelList() []string {
func (c *Client) Channels() []*Channel { func (c *Client) Channels() []*Channel {
c.panicIfNotTracking() c.panicIfNotTracking()
channels := make([]*Channel, 0, c.state.channels.Count()) c.state.RLock()
for channel := range c.state.channels.IterBuffered() { channels := make([]*Channel, 0, len(c.state.channels))
chn := channel.Val for channel := range c.state.channels {
channels = append(channels, chn.Copy()) channels = append(channels, c.state.channels[channel].Copy())
} }
c.state.RUnlock()
sort.Slice(channels, func(i, j int) bool { sort.Slice(channels, func(i, j int) bool {
return channels[i].Name < channels[j].Name return channels[i].Name < channels[j].Name
@ -640,15 +596,12 @@ func (c *Client) Channels() []*Channel {
func (c *Client) UserList() []string { func (c *Client) UserList() []string {
c.panicIfNotTracking() c.panicIfNotTracking()
users := make([]string, 0, c.state.users.Count()) c.state.RLock()
for user := range c.state.users.IterBuffered() { users := make([]string, 0, len(c.state.users))
usr := user.Val for user := range c.state.users {
if usr.Stale { users = append(users, c.state.users[user].Nick)
continue
}
users = append(users, usr.Nick.Load().(string))
} }
c.state.RUnlock()
sort.Strings(users) sort.Strings(users)
return users return users
} }
@ -658,14 +611,15 @@ func (c *Client) UserList() []string {
func (c *Client) Users() []*User { func (c *Client) Users() []*User {
c.panicIfNotTracking() c.panicIfNotTracking()
users := make([]*User, 0, c.state.users.Count()) c.state.RLock()
for user := range c.state.users.IterBuffered() { users := make([]*User, 0, len(c.state.users))
usr := user.Val for user := range c.state.users {
users = append(users, usr.Copy()) users = append(users, c.state.users[user].Copy())
} }
c.state.RUnlock()
sort.Slice(users, func(i, j int) bool { sort.Slice(users, func(i, j int) bool {
return users[i].Nick.Load().(string) < users[j].Nick.Load().(string) return users[i].Nick < users[j].Nick
}) })
return users return users
} }
@ -678,8 +632,9 @@ func (c *Client) LookupChannel(name string) (channel *Channel) {
return nil return nil
} }
c.state.RLock()
channel = c.state.lookupChannel(name).Copy() channel = c.state.lookupChannel(name).Copy()
c.state.RUnlock()
return channel return channel
} }
@ -691,49 +646,59 @@ func (c *Client) LookupUser(nick string) (user *User) {
return nil return nil
} }
c.state.RLock()
user = c.state.lookupUser(nick).Copy() user = c.state.lookupUser(nick).Copy()
c.state.RUnlock()
return user return user
} }
// IsInChannel returns true if the client is in channel. Panics if tracking // IsInChannel returns true if the client is in channel. Panics if tracking
// is disabled. // is disabled.
// TODO: make sure this still works.
func (c *Client) IsInChannel(channel string) (in bool) { func (c *Client) IsInChannel(channel string) (in bool) {
c.panicIfNotTracking() c.panicIfNotTracking()
_, in = c.state.channels.Get(ToRFC1459(channel))
c.state.RLock()
_, in = c.state.channels[ToRFC1459(channel)]
c.state.RUnlock()
return in return in
} }
// GetServerOpt retrieves a server capability setting that was retrieved // GetServerOption retrieves a server capability setting that was retrieved
// during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL). // during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL).
// Will panic if used when tracking has been disabled. Examples of usage: // Will panic if used when tracking has been disabled. Examples of usage:
// //
// nickLen, success := GetServerOpt("MAXNICKLEN") // nickLen, success := GetServerOption("MAXNICKLEN")
func (c *Client) GetServerOpt(key string) (result string, ok bool) { //
func (c *Client) GetServerOption(key string) (result string, ok bool) {
c.panicIfNotTracking() c.panicIfNotTracking()
result, ok = c.state.serverOptions.Get(key) c.state.RLock()
if !ok { result, ok = c.state.serverOptions[key]
return "", ok c.state.RUnlock()
}
if len(result) > 0 {
ok = true
}
return result, ok return result, ok
} }
// GetServerOptions retrieves all of a server's capability settings that were retrieved // GetServerOptionInt retrieves a server capability setting (as an integer) that was
// during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL). // retrieved during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL).
func (c *Client) GetServerOptions() []byte { // Will panic if used when tracking has been disabled. Examples of usage:
o := make(map[string]string) //
for opt := range c.state.serverOptions.IterBuffered() { // nickLen, success := GetServerOption("MAXNICKLEN")
o[opt.Key] = opt.Val //
func (c *Client) GetServerOptionInt(key string) (result int, ok bool) {
var data string
var err error
data, ok = c.GetServerOption(key)
if !ok {
return result, ok
} }
jcytes, _ := json.Marshal(o)
return jcytes result, err = strconv.Atoi(data)
if err != nil {
ok = false
}
return result, ok
} }
// NetworkName returns the network identifier. E.g. "EsperNet", "ByteIRC". // NetworkName returns the network identifier. E.g. "EsperNet", "ByteIRC".
@ -741,21 +706,8 @@ func (c *Client) GetServerOptions() []byte {
// Will panic if used when tracking has been disabled. // Will panic if used when tracking has been disabled.
func (c *Client) NetworkName() (name string) { func (c *Client) NetworkName() (name string) {
c.panicIfNotTracking() c.panicIfNotTracking()
var ok bool
if len(c.state.network.Load().(string)) > 0 {
return c.state.network.Load().(string)
}
name, ok = c.GetServerOpt("NETWORK")
if !ok {
return c.IRCd.Network.Load().(string)
}
if len(name) < 1 && len(c.IRCd.Network.Load().(string)) > 1 {
name = c.IRCd.Network.Load().(string)
}
name, _ = c.GetServerOption("NETWORK")
return name return name
} }
@ -766,7 +718,7 @@ func (c *Client) NetworkName() (name string) {
func (c *Client) ServerVersion() (version string) { func (c *Client) ServerVersion() (version string) {
c.panicIfNotTracking() c.panicIfNotTracking()
version, _ = c.GetServerOpt("VERSION") version, _ = c.GetServerOption("VERSION")
return version return version
} }
@ -775,14 +727,21 @@ func (c *Client) ServerVersion() (version string) {
func (c *Client) ServerMOTD() (motd string) { func (c *Client) ServerMOTD() (motd string) {
c.panicIfNotTracking() c.panicIfNotTracking()
return c.state.motd c.state.RLock()
motd = c.state.motd
c.state.RUnlock()
return motd
} }
// Latency is the latency between the server and the client. This is measured // Latency is the latency between the server and the client. This is measured
// by determining the difference in time between when we ping the server, and // by determining the difference in time between when we ping the server, and
// when we receive a pong. // when we receive a pong.
func (c *Client) Latency() (delta time.Duration) { func (c *Client) Latency() (delta time.Duration) {
delta = c.conn.lastPong.Load().(time.Time).Sub(c.conn.lastPing.Load().(time.Time)) c.mu.RLock()
c.conn.mu.RLock()
delta = c.conn.lastPong.Sub(c.conn.lastPing)
c.conn.mu.RUnlock()
c.mu.RUnlock()
if delta < 0 { if delta < 0 {
return 0 return 0
@ -803,24 +762,38 @@ func (c *Client) HasCapability(name string) (has bool) {
name = strings.ToLower(name) name = strings.ToLower(name)
for capab := range c.state.enabledCap.IterBuffered() { c.state.RLock()
key := strings.ToLower(capab.Key) for key := range c.state.enabledCap {
key = strings.ToLower(key)
if key == name { if key == name {
has = true has = true
break break
} }
} }
c.state.RUnlock()
return has return has
} }
// MaxEventLength return the maximum supported server length of an event. This is the
// maximum length of the command and arguments, excluding the source/prefix supported
// by the protocol. If state tracking is enabled, this will utilize ISUPPORT/IRCv3
// information to more accurately calculate the maximum supported length (i.e. extended
// length events).
func (c *Client) MaxEventLength() (max int) {
if !c.Config.disableTracking {
c.state.RLock()
max = c.state.maxLineLength - c.state.maxPrefixLength
c.state.RUnlock()
return max
}
return DefaultMaxLineLength - DefaultMaxPrefixLength
}
// panicIfNotTracking will throw a panic when it's called, and tracking is // panicIfNotTracking will throw a panic when it's called, and tracking is
// disabled. Adds useful info like what function specifically, and where it // disabled. Adds useful info like what function specifically, and where it
// was called from. // was called from.
func (c *Client) panicIfNotTracking() { func (c *Client) panicIfNotTracking() {
if c == nil {
return
}
if !c.Config.disableTracking { if !c.Config.disableTracking {
return return
} }
@ -848,10 +821,8 @@ func (c *Client) debugLogEvent(e *Event, dropped bool) {
} }
if c.Config.Out != nil { if c.Config.Out != nil {
if pretty, ok := e.Pretty(); ok { if pretty, ok := e.Pretty(); ok {
fmt.Fprintln(c.Config.Out, StripRaw(pretty))
_, _ = fmt.Fprintln(c.Config.Out, StripRaw(pretty))
} }
} }
} }

@ -19,16 +19,19 @@ func TestDisableTracking(t *testing.T) {
Name: "Testing123", Name: "Testing123",
}) })
if client.Handlers.internal.len() < 1 { if len(client.Handlers.internal) < 1 {
t.Fatal("Client.Handlers empty, though just initialized") t.Fatal("Client.Handlers empty, though just initialized")
} }
client.DisableTracking() client.DisableTracking()
if _, ok := client.Handlers.internal.cm.Get(CAP); ok { if _, ok := client.Handlers.internal[CAP]; ok {
t.Fatal("Client.Handlers contains capability tracking handlers, though disabled") t.Fatal("Client.Handlers contains capability tracking handlers, though disabled")
} }
if len(client.state.channels.Keys()) > 0 { client.state.Lock()
defer client.state.Unlock()
if client.state.channels != nil {
t.Fatal("Client.DisableTracking() called but channel state still exists") t.Fatal("Client.DisableTracking() called but channel state still exists")
} }
} }
@ -93,24 +96,14 @@ func TestClientLifetime(t *testing.T) {
func TestClientUptime(t *testing.T) { func TestClientUptime(t *testing.T) {
c, conn, server := genMockConn() c, conn, server := genMockConn()
defer func() { defer conn.Close()
if err := conn.Close(); err != nil { defer server.Close()
t.Errorf("failed to close connection: %s", err)
}
if err := server.Close(); err != nil {
t.Errorf("failed to close server: %s", err)
}
}()
go mockReadBuffer(conn) go mockReadBuffer(conn)
done := make(chan struct{}, 1) done := make(chan struct{}, 1)
c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) }) c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) })
go func() { go c.MockConnect(server)
if err := c.MockConnect(server); err != nil {
t.Errorf("failed to connect: %s", err)
}
}()
defer c.Close() defer c.Close()
select { select {
@ -124,7 +117,7 @@ func TestClientUptime(t *testing.T) {
t.Fatalf("Client.Uptime() = %s, wanted time", err) t.Fatalf("Client.Uptime() = %s, wanted time", err)
} }
since := time.Since(uptime) since := time.Since(*uptime)
connsince, err := c.ConnSince() connsince, err := c.ConnSince()
if err != nil { if err != nil {
t.Fatalf("Client.ConnSince() = %s, wanted time", err) t.Fatalf("Client.ConnSince() = %s, wanted time", err)
@ -148,24 +141,14 @@ func TestClientUptime(t *testing.T) {
func TestClientGet(t *testing.T) { func TestClientGet(t *testing.T) {
c, conn, server := genMockConn() c, conn, server := genMockConn()
defer func() { defer conn.Close()
if err := conn.Close(); err != nil { defer server.Close()
t.Errorf("failed to close connection: %s", err)
}
if err := server.Close(); err != nil {
t.Errorf("failed to close server: %s", err)
}
}()
go mockReadBuffer(conn) go mockReadBuffer(conn)
done := make(chan struct{}, 1) done := make(chan struct{}, 1)
c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) }) c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) })
go func() { go c.MockConnect(server)
if err := c.MockConnect(server); err != nil {
t.Errorf("failed to connect: %s", err)
}
}()
defer c.Close() defer c.Close()
select { select {
@ -189,14 +172,8 @@ func TestClientGet(t *testing.T) {
func TestClientClose(t *testing.T) { func TestClientClose(t *testing.T) {
c, conn, server := genMockConn() c, conn, server := genMockConn()
defer func() { defer server.Close()
if err := conn.Close(); err != nil { defer conn.Close()
t.Errorf("failed to close connection: %s", err)
}
if err := server.Close(); err != nil {
t.Errorf("failed to close server: %s", err)
}
}()
go mockReadBuffer(conn) go mockReadBuffer(conn)
errchan := make(chan error, 1) errchan := make(chan error, 1)

199
cmdhandler/cmd.go Normal file

@ -0,0 +1,199 @@
package cmdhandler
import (
"errors"
"fmt"
"regexp"
"strings"
"sync"
"github.com/lrstanley/girc"
)
// Input is a wrapper for events, based around private messages.
type Input struct {
Origin *girc.Event
Args []string
RawArgs string
}
// Command is an IRC command, supporting aliases, help documentation and easy
// wrapping for message inputs.
type Command struct {
// Name of command, e.g. "search" or "ping".
Name string
// Aliases for the above command, e.g. "s" for search, or "p" for "ping".
Aliases []string
// Help documentation. Should be in the format "<arg> <arg> [arg] --
// something useful here"
Help string
// MinArgs is the minimum required arguments for the command. Defaults to
// 0, which means multiple, or no arguments can be supplied. If set
// above 0, this means that the command handler will throw an error asking
// the person to check "<prefix>help <command>" for more info.
MinArgs int
// Fn is the function which is executed when the command is ran from a
// private message, or channel.
Fn func(*girc.Client, *Input)
}
func (c *Command) genHelp(prefix string) string {
out := "{b}" + prefix + c.Name + "{b}"
if c.Aliases != nil && len(c.Aliases) > 0 {
out += " ({b}" + prefix + strings.Join(c.Aliases, "{b}, {b}"+prefix) + "{b})"
}
out += " :: " + c.Help
return out
}
// CmdHandler is an irc command parser and execution format which you could
// use as an example for building your own version/bot.
//
// An example of how you would register this with girc:
//
// ch, err := cmdhandler.New("!")
// if err != nil {
// panic(err)
// }
//
// ch.Add(&cmdhandler.Command{
// Name: "ping",
// Help: "Sends a pong reply back to the original user.",
// Fn: func(c *girc.Client, input *cmdhandler.Input) {
// c.Commands.ReplyTo(*input.Origin, "pong!")
// },
// })
//
// client.Handlers.AddHandler(girc.PRIVMSG, ch)
type CmdHandler struct {
prefix string
re *regexp.Regexp
mu sync.Mutex
cmds map[string]*Command
}
var cmdMatch = `^%s([a-z0-9-_]{1,20})(?: (.*))?$`
// New returns a new CmdHandler based on the specified command prefix. A good
// prefix is a single character, and easy to remember/use. E.g. "!", or ".".
func New(prefix string) (*CmdHandler, error) {
re, err := regexp.Compile(fmt.Sprintf(cmdMatch, regexp.QuoteMeta(prefix)))
if err != nil {
return nil, err
}
return &CmdHandler{prefix: prefix, re: re, cmds: make(map[string]*Command)}, nil
}
var validName = regexp.MustCompile(`^[a-z0-9-_]{1,20}$`)
// Add registers a new command to the handler. Note that you cannot remove
// commands once added, unless you add another CmdHandler to the client.
func (ch *CmdHandler) Add(cmd *Command) error {
if cmd == nil {
return errors.New("nil command provided to CmdHandler")
}
cmd.Name = strings.ToLower(cmd.Name)
if !validName.MatchString(cmd.Name) {
return fmt.Errorf("invalid command name: %q (req: %q)", cmd.Name, validName.String())
}
if cmd.Aliases != nil {
for i := 0; i < len(cmd.Aliases); i++ {
cmd.Aliases[i] = strings.ToLower(cmd.Aliases[i])
if !validName.MatchString(cmd.Aliases[i]) {
return fmt.Errorf("invalid command name: %q (req: %q)", cmd.Aliases[i], validName.String())
}
}
}
if cmd.MinArgs < 0 {
cmd.MinArgs = 0
}
ch.mu.Lock()
defer ch.mu.Unlock()
if _, ok := ch.cmds[cmd.Name]; ok {
return fmt.Errorf("command already registered: %s", cmd.Name)
}
ch.cmds[cmd.Name] = cmd
// Since we'd be storing pointers, duplicates do not matter.
for i := 0; i < len(cmd.Aliases); i++ {
if _, ok := ch.cmds[cmd.Aliases[i]]; ok {
return fmt.Errorf("alias already registered: %s", cmd.Aliases[i])
}
ch.cmds[cmd.Aliases[i]] = cmd
}
return nil
}
// Execute satisfies the girc.Handler interface.
func (ch *CmdHandler) Execute(client *girc.Client, event girc.Event) {
if event.Source == nil || event.Command != girc.PRIVMSG {
return
}
parsed := ch.re.FindStringSubmatch(event.Last())
if len(parsed) != 3 {
return
}
invCmd := strings.ToLower(parsed[1])
args := strings.Split(parsed[2], " ")
if len(args) == 1 && args[0] == "" {
args = []string{}
}
ch.mu.Lock()
defer ch.mu.Unlock()
if invCmd == "help" {
if len(args) == 0 {
client.Cmd.ReplyTo(event, girc.Fmt("type '{b}!help {blue}<command>{c}{b}' to optionally get more info about a specific command."))
return
}
args[0] = strings.ToLower(args[0])
if _, ok := ch.cmds[args[0]]; !ok {
client.Cmd.ReplyTof(event, girc.Fmt("unknown command {b}%q{b}."), args[0])
return
}
if ch.cmds[args[0]].Help == "" {
client.Cmd.ReplyTof(event, girc.Fmt("there is no help documentation for {b}%q{b}"), args[0])
return
}
client.Cmd.ReplyTo(event, girc.Fmt(ch.cmds[args[0]].genHelp(ch.prefix)))
return
}
cmd, ok := ch.cmds[invCmd]
if !ok {
return
}
if len(args) < cmd.MinArgs {
client.Cmd.ReplyTof(event, girc.Fmt("not enough arguments supplied for {b}%q{b}. try '{b}%shelp %s{b}'?"), invCmd, ch.prefix, invCmd)
return
}
in := &Input{
Origin: &event,
Args: args,
RawArgs: parsed[2],
}
go cmd.Fn(client, in)
}

@ -1,230 +0,0 @@
package girc
/*
IRCNumToStr takes in a numeric IRC code and returns the relevant girc name.
IRCNumToStr accepts a string because that's how we tend to receive the codes.
*/
//goland:noinspection GoUnusedExportedFunction
func IRCNumToStr(code string) string {
if _, ok := noTranslate[code]; ok {
return code
}
if result, ok := IRCCodes[code]; ok {
return result
}
return ""
}
var noTranslate = map[string]uint8{"JOIN": 1, "INVITE": 1, "NOTICE": 1, "CAP": 1, "MODE": 1, "QUIT": 1, "PRIVMSG": 1, "NICK": 1, "FAIL": 1}
// IRCCodes is just a map form of our constants for quick lookup purposes.
var IRCCodes = map[string]string{
"001": "RPL_WELCOME",
"002": "RPL_YOURHOST",
"003": "RPL_CREATED",
"004": "RPL_MYINFO",
"005": "RPL_ISUPPORT",
"302": "RPL_USERHOST",
"303": "RPL_ISON",
"301": "RPL_AWAY",
"305": "RPL_UNAWAY",
"306": "RPL_NOWAWAY",
"311": "RPL_WHOISUSER",
"312": "RPL_WHOISSERVER",
"313": "RPL_WHOISOPERATOR",
"317": "RPL_WHOISIDLE",
"318": "RPL_ENDOFWHOIS",
"319": "RPL_WHOISCHANNELS",
"314": "RPL_WHOWASUSER",
"369": "RPL_ENDOFWHOWAS",
"321": "RPL_LISTSTART",
"322": "RPL_LIST",
"323": "RPL_LISTEND",
"325": "RPL_UNIQOPIS",
"324": "RPL_CHANNELMODEIS",
"329": "RPL_CREATIONTIME",
"330": "RPL_WHOISAUTHNAME",
"331": "RPL_NOTOPIC",
"332": "RPL_TOPIC",
"341": "RPL_INVITING",
"342": "RPL_SUMMONING",
"346": "RPL_INVITELIST",
"347": "RPL_ENDOFINVITELIST",
"348": "RPL_EXCEPTLIST",
"349": "RPL_ENDOFEXCEPTLIST",
"351": "RPL_VERSION",
"352": "RPL_WHOREPLY",
"315": "RPL_ENDOFWHO",
"353": "RPL_NAMREPLY",
"366": "RPL_ENDOFNAMES",
"364": "RPL_LINKS",
"365": "RPL_ENDOFLINKS",
"367": "RPL_BANLIST",
"368": "RPL_ENDOFBANLIST",
"371": "RPL_INFO",
"374": "RPL_ENDOFINFO",
"375": "RPL_MOTDSTART",
"372": "RPL_MOTD",
"376": "RPL_ENDOFMOTD",
"381": "RPL_YOUREOPER",
"382": "RPL_REHASHING",
"383": "RPL_YOURESERVICE",
"391": "RPL_TIME",
"392": "RPL_USERSSTART",
"393": "RPL_USERS",
"394": "RPL_ENDOFUSERS",
"395": "RPL_NOUSERS",
"200": "RPL_TRACELINK",
"201": "RPL_TRACECONNECTING",
"202": "RPL_TRACEHANDSHAKE",
"203": "RPL_TRACEUNKNOWN",
"204": "RPL_TRACEOPERATOR",
"205": "RPL_TRACEUSER",
"206": "RPL_TRACESERVER",
"207": "RPL_TRACESERVICE",
"208": "RPL_TRACENEWTYPE",
"209": "RPL_TRACECLASS",
"210": "RPL_TRACERECONNECT",
"261": "RPL_TRACELOG",
"262": "RPL_TRACEEND",
"211": "RPL_STATSLINKINFO",
"212": "RPL_STATSCOMMANDS",
"219": "RPL_ENDOFSTATS",
"242": "RPL_STATSUPTIME",
"243": "RPL_STATSOLINE",
"221": "RPL_UMODEIS",
"234": "RPL_SERVLIST",
"235": "RPL_SERVLISTEND",
"251": "RPL_LUSERCLIENT",
"252": "RPL_LUSEROP",
"253": "RPL_LUSERUNKNOWN",
"254": "RPL_LUSERCHANNELS",
"255": "RPL_LUSERME",
"256": "RPL_ADMINME",
"257": "RPL_ADMINLOC1",
"258": "RPL_ADMINLOC2",
"259": "RPL_ADMINEMAIL",
"263": "RPL_TRYAGAIN",
"400": "ERR_ALREADYOPER",
"401": "ERR_NOSUCHNICK",
"402": "ERR_NOSUCHSERVER",
"403": "ERR_NOSUCHCHANNEL",
"404": "ERR_CANNOTSENDTOCHAN",
"405": "ERR_TOOMANYCHANNELS",
"406": "ERR_WASNOSUCHNICK",
"407": "ERR_TOOMANYTARGETS",
"408": "ERR_NOSUCHSERVICE",
"409": "ERR_NOORIGIN",
"411": "ERR_NORECIPIENT",
"412": "ERR_NOTEXTTOSEND",
"413": "ERR_NOTOPLEVEL",
"414": "ERR_WILDTOPLEVEL",
"415": "ERR_BADMASK",
"417": "ERR_INPUTTOOLONG",
"421": "ERR_UNKNOWNCOMMAND",
"422": "ERR_NOMOTD",
"423": "ERR_NOADMININFO",
"424": "ERR_FILEERROR",
"431": "ERR_NONICKNAMEGIVEN",
"432": "ERR_ERRONEUSNICKNAME",
"433": "ERR_NICKNAMEINUSE",
"436": "ERR_NICKCOLLISION",
"437": "ERR_UNAVAILRESOURCE",
"441": "ERR_USERNOTINCHANNEL",
"442": "ERR_NOTONCHANNEL",
"443": "ERR_USERONCHANNEL",
"444": "ERR_NOLOGIN",
"445": "ERR_SUMMONDISABLED",
"446": "ERR_USERSDISABLED",
"451": "ERR_NOTREGISTERED",
"461": "ERR_NEEDMOREPARAMS",
"462": "ERR_ALREADYREGISTRED",
"463": "ERR_NOPERMFORHOST",
"464": "ERR_PASSWDMISMATCH",
"465": "ERR_YOUREBANNEDCREEP",
"466": "ERR_YOUWILLBEBANNED",
"467": "ERR_KEYSET",
"471": "ERR_CHANNELISFULL",
"472": "ERR_UNKNOWNMODE",
"473": "ERR_INVITEONLYCHAN",
"474": "ERR_BANNEDFROMCHAN",
"475": "ERR_BADCHANNELKEY",
"476": "ERR_BADCHANMASK",
"477": "ERR_NOCHANMODES",
"478": "ERR_BANLISTFULL",
"481": "ERR_NOPRIVILEGES",
"482": "ERR_CHANOPRIVSNEEDED",
"483": "ERR_CANTKILLSERVER",
"484": "ERR_RESTRICTED",
"485": "ERR_UNIQOPPRIVSNEEDED",
"491": "ERR_NOOPERHOST",
"501": "ERR_UMODEUNKNOWNFLAG",
"502": "ERR_USERSDONTMATCH",
"900": "RPL_LOGGEDIN",
"901": "RPL_LOGGEDOUT",
"902": "RPL_NICKLOCKED",
"903": "RPL_SASLSUCCESS",
"904": "ERR_SASLFAIL",
"905": "ERR_SASLTOOLONG",
"906": "ERR_SASLABORTED",
"907": "ERR_SASLALREADY",
"908": "RPL_SASLMECHS",
"670": "RPL_STARTTLS",
"691": "ERR_STARTTLS",
"730": "RPL_MONONLINE",
"731": "RPL_MONOFFLINE",
"732": "RPL_MONLIST",
"733": "RPL_ENDOFMONLIST",
"734": "ERR_MONLISTFULL",
"213": "RPL_STATSCLINE",
"214": "RPL_STATSNLINE",
"215": "RPL_STATSILINE",
"216": "RPL_STATSKLINE",
"217": "RPL_STATSQLINE",
"218": "RPL_STATSYLINE",
"231": "RPL_SERVICEINFO",
"232": "RPL_ENDOFSERVICES",
"233": "RPL_SERVICE",
"240": "RPL_STATSVLINE",
"241": "RPL_STATSLLINE",
"244": "RPL_STATSHLINE",
"245": "RPL_STATSSLINE",
"246": "RPL_STATSPING",
"247": "RPL_STATSBLINE",
"250": "RPL_STATSDLINE",
"300": "RPL_NONE",
"316": "RPL_WHOISCHANOP",
"361": "RPL_KILLDONE",
"362": "RPL_CLOSING",
"363": "RPL_CLOSEEND",
"373": "RPL_INFOSTART",
"384": "RPL_MYPORTIS",
"492": "ERR_NOSERVICEHOST",
"416": "ERR_TOOMANYMATCHES",
"266": "RPL_GLOBALUSERS",
"265": "RPL_LOCALUSERS",
"333": "RPL_TOPICWHOTIME",
"354": "RPL_WHOSPCRPL",
"671": "RPL_WHOISTLS",
"CLIENT_STATE_UPDATED": "UPDATE_STATE",
"CLIENT_GENERAL_UPDATED": "UPDATE_GENERAL",
"*": "ALL_EVENTS",
"CLIENT_CONNECTED": "CONNECTED",
"CLIENT_INIT": "INITIALIZED",
"CLIENT_DISCONNECTED": "DISCONNECTED",
"CLIENT_CLOSED": "CLOSED",
"STS_UPGRADE_INIT": "STS_UPGRADE_INIT",
"STS_ERR_FALLBACK": "STS_ERR_FALLBACK",
"ACTION": "CTCP_ACTION",
"PING": "CTCP_PING",
"PONG": "CTCP_PONG",
"VERSION": "CTCP_VERSION",
"USERINFO": "CTCP_USERINFO",
"CLIENTINFO": "CTCP_CLIENTINFO",
"SOURCE": "CTCP_SOURCE",
"TIME": "CTCP_TIME",
"FINGER": "CTCP_FINGER",
"ERRMSG": "CTCP_ERRMSG",
}

@ -25,7 +25,7 @@ func (cmd *Commands) Nick(name string) {
func (cmd *Commands) Join(channels ...string) { func (cmd *Commands) Join(channels ...string) {
// We can join multiple channels at once, however we need to ensure that // We can join multiple channels at once, however we need to ensure that
// we are not exceeding the line length. (see maxLength) // we are not exceeding the line length. (see maxLength)
max := maxLength - len(JOIN) - 1 max := cmd.c.MaxEventLength() - len(JOIN) - 1
var buffer string var buffer string
@ -36,7 +36,7 @@ func (cmd *Commands) Join(channels ...string) {
continue continue
} }
if buffer == "" { if len(buffer) == 0 {
buffer = channels[i] buffer = channels[i]
} else { } else {
buffer += "," + channels[i] buffer += "," + channels[i]
@ -111,8 +111,7 @@ func (cmd *Commands) Message(target, message string) {
// Messagef sends a formated PRIVMSG to target (either channel, service, or // Messagef sends a formated PRIVMSG to target (either channel, service, or
// user). // user).
func (cmd *Commands) Messagef(target, format string, a ...interface{}) { func (cmd *Commands) Messagef(target, format string, a ...interface{}) {
message := fmt.Sprintf(format, a...) cmd.Message(target, fmt.Sprintf(format, a...))
cmd.Message(target, Fmt(message))
} }
// ErrInvalidSource is returned when a method needs to know the origin of an // ErrInvalidSource is returned when a method needs to know the origin of an
@ -120,83 +119,52 @@ func (cmd *Commands) Messagef(target, format string, a ...interface{}) {
// server.) // server.)
var ErrInvalidSource = errors.New("event has nil or invalid source address") var ErrInvalidSource = errors.New("event has nil or invalid source address")
// ErrDontKnowUser is returned when a method needs to know the origin of an event,
var ErrDontKnowUser = errors.New("failed to lookup target user")
// Reply sends a reply to channel or user, based on where the supplied event // Reply sends a reply to channel or user, based on where the supplied event
// originated from. See also ReplyTo(). Panics if the incoming event has no // originated from. See also ReplyTo(). Panics if the incoming event has no
// source. // source.
func (cmd *Commands) Reply(event Event, message string) error { func (cmd *Commands) Reply(event Event, message string) {
if event.Source == nil { if event.Source == nil {
return ErrInvalidSource panic(ErrInvalidSource)
} }
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) { if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Message(event.Params[0], message) cmd.Message(event.Params[0], message)
return nil return
} }
cmd.Message(event.Source.Name, message) cmd.Message(event.Source.Name, message)
return nil
}
// ReplyKick kicks the source of the event from the channel where the event originated
func (cmd *Commands) ReplyKick(event Event, reason string) error {
if event.Source == nil {
return ErrInvalidSource
}
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Kick(event.Params[0], event.Source.Name, reason)
}
return nil
}
// ReplyBan kicks the source of the event from the channel where the event originated.
// Additionally, if a reason is provided, it will send a message to the channel.
func (cmd *Commands) ReplyBan(event Event, reason string) (err error) {
if event.Source == nil {
return ErrInvalidSource
}
if reason != "" {
err = cmd.Replyf(event, "{red}{b}[BAN] {r}%s", reason)
}
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Ban(event.Params[0], fmt.Sprintf("*!%s@%s", event.Source.Ident, event.Source.Host))
}
return
} }
// Replyf sends a reply to channel or user with a format string, based on // Replyf sends a reply to channel or user with a format string, based on
// where the supplied event originated from. See also ReplyTof(). Panics if // where the supplied event originated from. See also ReplyTof(). Panics if
// the incoming event has no source. // the incoming event has no source.
// Formatted means both in the sense of Sprintf as well as girc style macros. func (cmd *Commands) Replyf(event Event, format string, a ...interface{}) {
func (cmd *Commands) Replyf(event Event, format string, a ...interface{}) error { cmd.Reply(event, fmt.Sprintf(format, a...))
message := fmt.Sprintf(format, a...)
return cmd.Reply(event, Fmt(message))
} }
// ReplyTo sends a reply to a channel or user, based on where the supplied // ReplyTo sends a reply to a channel or user, based on where the supplied
// event originated from. ReplyTo(), when originating from a channel will // event originated from. ReplyTo(), when originating from a channel will
// default to replying with "<user>, <message>". See also Reply(). Panics if // default to replying with "<user>, <message>". See also Reply(). Panics if
// the incoming event has no source. // the incoming event has no source.
func (cmd *Commands) ReplyTo(event Event, message string) error { func (cmd *Commands) ReplyTo(event Event, message string) {
if event.Source == nil { if event.Source == nil {
return ErrInvalidSource panic(ErrInvalidSource)
} }
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) { if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Message(event.Params[0], event.Source.Name+", "+message) cmd.Message(event.Params[0], event.Source.Name+", "+message)
} else { return
cmd.Message(event.Source.Name, message)
} }
return nil
cmd.Message(event.Source.Name, message)
} }
// ReplyTof sends a reply to a channel or user with a format string, based // ReplyTof sends a reply to a channel or user with a format string, based
// on where the supplied event originated from. ReplyTo(), when originating // on where the supplied event originated from. ReplyTo(), when originating
// from a channel will default to replying with "<user>, <message>". See // from a channel will default to replying with "<user>, <message>". See
// also Replyf(). Panics if the incoming event has no source. // also Replyf(). Panics if the incoming event has no source.
// Formatted means both in the sense of Sprintf as well as girc style macros. func (cmd *Commands) ReplyTof(event Event, format string, a ...interface{}) {
func (cmd *Commands) ReplyTof(event Event, format string, a ...interface{}) error { cmd.ReplyTo(event, fmt.Sprintf(format, a...))
message := fmt.Sprintf(format, a...)
return cmd.ReplyTo(event, Fmt(message))
} }
// Action sends a PRIVMSG ACTION (/me) to target (either channel, service, // Action sends a PRIVMSG ACTION (/me) to target (either channel, service,
@ -220,9 +188,9 @@ func (cmd *Commands) Notice(target, message string) {
} }
// Noticef sends a formated NOTICE to target (either channel, service, or // Noticef sends a formated NOTICE to target (either channel, service, or
// user). Formatted means both in the sense of Sprintf as well as girc styling codes. // user).
func (cmd *Commands) Noticef(target, format string, a ...interface{}) { func (cmd *Commands) Noticef(target, format string, a ...interface{}) {
cmd.Notice(target, Fmt(fmt.Sprintf(format, a...))) cmd.Notice(target, fmt.Sprintf(format, a...))
} }
// SendRaw sends a raw string (or multiple) to the server, without carriage // SendRaw sends a raw string (or multiple) to the server, without carriage
@ -244,9 +212,9 @@ func (cmd *Commands) SendRaw(raw ...string) error {
} }
// SendRawf sends a formated string back to the server, without carriage // SendRawf sends a formated string back to the server, without carriage
// returns or newlines. Formatted means both in the sense of Sprintf as well as girc style macros. // returns or newlines.
func (cmd *Commands) SendRawf(format string, a ...interface{}) error { func (cmd *Commands) SendRawf(format string, a ...interface{}) error {
return cmd.SendRaw(Fmt(fmt.Sprintf(format, a...))) return cmd.SendRaw(fmt.Sprintf(format, a...))
} }
// Topic sets the topic of channel to message. Does not verify the length // Topic sets the topic of channel to message. Does not verify the length
@ -255,19 +223,13 @@ func (cmd *Commands) Topic(channel, message string) {
cmd.c.Send(&Event{Command: TOPIC, Params: []string{channel, message}}) cmd.c.Send(&Event{Command: TOPIC, Params: []string{channel, message}})
} }
// Topicf sets a formatted topic command to the channel. Does not verify the length
// of the topic. Formatted means both in the sense of Sprintf as well as girc style macros.
func (cmd *Commands) Topicf(channel, format string, a ...interface{}) {
message := fmt.Sprintf(format, a...)
cmd.c.Send(&Event{Command: TOPIC, Params: []string{channel, Fmt(message)}})
}
// Who sends a WHO query to the server, which will attempt WHOX by default. // Who sends a WHO query to the server, which will attempt WHOX by default.
// See http://faerion.sourceforge.net/doc/irc/whox.var for more details. This // See http://faerion.sourceforge.net/doc/irc/whox.var for more details. This
// sends "%tcuhnr,1" per default. // sends "%tcuhnr,2" per default. Do not use "1" as this will conflict with
// girc's builtin tracking functionality.
func (cmd *Commands) Who(users ...string) { func (cmd *Commands) Who(users ...string) {
for i := 0; i < len(users); i++ { for i := 0; i < len(users); i++ {
cmd.c.Send(&Event{Command: WHO, Params: []string{users[i], "%tacuhnr,1"}}) cmd.c.Send(&Event{Command: WHO, Params: []string{users[i], "%tcuhnr,2"}})
} }
} }
@ -298,22 +260,6 @@ func (cmd *Commands) Oper(user, pass string) {
cmd.c.Send(&Event{Command: OPER, Params: []string{user, pass}, Sensitive: true}) cmd.c.Send(&Event{Command: OPER, Params: []string{user, pass}, Sensitive: true})
} }
// KickBan sends a KICK query to the server, attempting to kick nick from
// channel, with reason. If reason is blank, one will not be sent to the
// server. Afterwards it immediately sets +b on the mask given.
// If no mask is given, it will set +b on *!~ident@host.
//
// Note: this command will return an error if it cannot track the user in order to determine ban mask.
func (cmd *Commands) KickBan(channel, user, reason string) error {
u := cmd.c.LookupUser(user)
if u == nil {
return ErrDontKnowUser
}
cmd.Kick(channel, user, reason)
cmd.Ban(channel, fmt.Sprintf("*!%s@%s", u.Ident, u.Host))
return nil
}
// Kick sends a KICK query to the server, attempting to kick nick from // Kick sends a KICK query to the server, attempting to kick nick from
// channel, with reason. If reason is blank, one will not be sent to the // channel, with reason. If reason is blank, one will not be sent to the
// server. // server.
@ -321,6 +267,7 @@ func (cmd *Commands) Kick(channel, user, reason string) {
if reason != "" { if reason != "" {
cmd.c.Send(&Event{Command: KICK, Params: []string{channel, user, reason}}) cmd.c.Send(&Event{Command: KICK, Params: []string{channel, user, reason}})
} }
cmd.c.Send(&Event{Command: KICK, Params: []string{channel, user}}) cmd.c.Send(&Event{Command: KICK, Params: []string{channel, user}})
} }
@ -382,7 +329,7 @@ func (cmd *Commands) List(channels ...string) {
// We can LIST multiple channels at once, however we need to ensure that // We can LIST multiple channels at once, however we need to ensure that
// we are not exceeding the line length. (see maxLength) // we are not exceeding the line length. (see maxLength)
max := maxLength - len(JOIN) - 1 max := cmd.c.MaxEventLength() - len(JOIN) - 1
var buffer string var buffer string
@ -393,7 +340,7 @@ func (cmd *Commands) List(channels ...string) {
continue continue
} }
if buffer == "" { if len(buffer) == 0 {
buffer = channels[i] buffer = channels[i]
} else { } else {
buffer += "," + channels[i] buffer += "," + channels[i]
@ -409,7 +356,7 @@ func (cmd *Commands) List(channels ...string) {
// Whowas sends a WHOWAS query to the server. amount is the amount of results // Whowas sends a WHOWAS query to the server. amount is the amount of results
// you want back. // you want back.
func (cmd *Commands) Whowas(user string, amount int) { func (cmd *Commands) Whowas(user string, amount int) {
cmd.c.Send(&Event{Command: WHOWAS, Params: []string{user, fmt.Sprintf("%d", amount)}}) cmd.c.Send(&Event{Command: WHOWAS, Params: []string{user, fmt.Sprint(amount)}})
} }
// Monitor sends a MONITOR query to the server. The results of the query // Monitor sends a MONITOR query to the server. The results of the query

230
conn.go

@ -10,10 +10,8 @@ import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net" "net"
"sync/atomic" "sync"
"time" "time"
"git.tcp.direct/kayos/common/pool"
) )
// Messages are delimited with CR and LF line endings, we're using the last // Messages are delimited with CR and LF line endings, we're using the last
@ -28,23 +26,25 @@ type ircConn struct {
io *bufio.ReadWriter io *bufio.ReadWriter
sock net.Conn sock net.Conn
mu sync.RWMutex
// lastWrite is used to keep track of when we last wrote to the server. // lastWrite is used to keep track of when we last wrote to the server.
lastWrite atomic.Value lastWrite time.Time
// lastActive is the last time the client was interacting with the server, // lastActive is the last time the client was interacting with the server,
// excluding a few background commands (PING, PONG, WHO, etc). // excluding a few background commands (PING, PONG, WHO, etc).
lastActive atomic.Value lastActive time.Time
// writeDelay is used to keep track of rate limiting of events sent to // writeDelay is used to keep track of rate limiting of events sent to
// the server. // the server.
writeDelay atomic.Value writeDelay time.Duration
// connected is true if we're actively connected to a server. // connected is true if we're actively connected to a server.
connected atomic.Value connected bool
// connTime is the time at which the client has connected to a server. // connTime is the time at which the client has connected to a server.
connTime atomic.Value connTime *time.Time
// lastPing is the last time that we pinged the server. // lastPing is the last time that we pinged the server.
lastPing atomic.Value lastPing time.Time
// lastPong is the last successful time that we pinged the server and // lastPong is the last successful time that we pinged the server and
// received a successful pong back. // received a successful pong back.
lastPong atomic.Value lastPong time.Time
pingDelay time.Duration
} }
// Dialer is an interface implementation of net.Dialer. Use this if you would // Dialer is an interface implementation of net.Dialer. Use this if you would
@ -57,8 +57,6 @@ type Dialer interface {
Dial(network, address string) (net.Conn, error) Dial(network, address string) (net.Conn, error)
} }
var strs = pool.NewStringFactory()
// newConn sets up and returns a new connection to the server. // newConn sets up and returns a new connection to the server.
func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ircConn, error) { func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ircConn, error) {
if err := conf.isValid(); err != nil { if err := conf.isValid(); err != nil {
@ -73,11 +71,7 @@ func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ir
if conf.Bind != "" { if conf.Bind != "" {
var local *net.TCPAddr var local *net.TCPAddr
s := strs.Get() local, err = net.ResolveTCPAddr("tcp", conf.Bind+":0")
s.MustWriteString(conf.Bind)
s.MustWriteString(":0")
local, err = net.ResolveTCPAddr("tcp", s.String())
strs.MustPut(s)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -118,27 +112,25 @@ func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ir
conn = tlsConn conn = tlsConn
} }
ctime := time.Now()
c := &ircConn{ c := &ircConn{
sock: conn, sock: conn,
connTime: atomic.Value{}, connTime: &ctime,
connected: atomic.Value{}, connected: true,
} }
c.connTime.Store(time.Now())
c.connected.Store(true)
c.newReadWriter() c.newReadWriter()
return c, nil return c, nil
} }
func newMockConn(conn net.Conn) *ircConn { func newMockConn(conn net.Conn) *ircConn {
ctime := time.Now()
c := &ircConn{ c := &ircConn{
sock: conn, sock: conn,
connTime: atomic.Value{}, connTime: &ctime,
connected: atomic.Value{}, connected: true,
} }
c.connTime.Store(time.Now())
c.connected.Store(true)
c.newReadWriter() c.newReadWriter()
return c return c
@ -151,17 +143,6 @@ type ErrParseEvent struct {
func (e ErrParseEvent) Error() string { return "unable to parse event: " + e.Line } func (e ErrParseEvent) Error() string { return "unable to parse event: " + e.Line }
func (c *ircConn) encode(event *Event) error {
if _, err := c.io.Write(event.Bytes()); err != nil {
return err
}
if _, err := c.io.Write(endline); err != nil {
return err
}
return c.io.Flush()
}
func (c *ircConn) decode() (event *Event, err error) { func (c *ircConn) decode() (event *Event, err error) {
line, err := c.io.ReadString(delim) line, err := c.io.ReadString(delim)
if err != nil { if err != nil {
@ -175,6 +156,17 @@ func (c *ircConn) decode() (event *Event, err error) {
return event, nil return event, nil
} }
func (c *ircConn) encode(event *Event) error {
if _, err := c.io.Write(event.Bytes()); err != nil {
return err
}
if _, err := c.io.Write(endline); err != nil {
return err
}
return c.io.Flush()
}
func (c *ircConn) newReadWriter() { func (c *ircConn) newReadWriter() {
c.io = bufio.NewReadWriter(bufio.NewReader(c.sock), bufio.NewWriter(c.sock)) c.io = bufio.NewReadWriter(bufio.NewReader(c.sock), bufio.NewWriter(c.sock))
} }
@ -270,6 +262,9 @@ func (c *Client) MockConnect(conn net.Conn) error {
func (c *Client) internalConnect(mock net.Conn, dialer Dialer) error { func (c *Client) internalConnect(mock net.Conn, dialer Dialer) error {
startConn: startConn:
// We want to be the only one handling connects/disconnects right now.
c.mu.Lock()
if c.conn != nil { if c.conn != nil {
panic("use of connect more than once") panic("use of connect more than once")
} }
@ -281,7 +276,7 @@ startConn:
if mock == nil { if mock == nil {
// Validate info, and actually make the connection. // Validate info, and actually make the connection.
c.debug.Printf("(%s) connecting to %s... (sts: %v, config-ssl: %v)", c.Config.Nick, addr, c.state.sts.enabled(), c.Config.SSL) c.debug.Printf("connecting to %s... (sts: %v, config-ssl: %v)", addr, c.state.sts.enabled(), c.Config.SSL)
conn, err := newConn(c.Config, dialer, addr, &c.state.sts) conn, err := newConn(c.Config, dialer, addr, &c.state.sts)
if err != nil { if err != nil {
if _, ok := err.(*ErrSTSUpgradeFailed); ok { if _, ok := err.(*ErrSTSUpgradeFailed); ok {
@ -289,6 +284,7 @@ startConn:
c.RunHandlers(&Event{Command: STS_ERR_FALLBACK}) c.RunHandlers(&Event{Command: STS_ERR_FALLBACK})
} }
} }
c.mu.Unlock()
return err return err
} }
@ -299,16 +295,17 @@ startConn:
var ctx context.Context var ctx context.Context
ctx, c.stop = context.WithCancel(context.Background()) ctx, c.stop = context.WithCancel(context.Background())
c.mu.Unlock()
errs := make(chan error, 4) errs := make(chan error, 4)
var working int32 var wg sync.WaitGroup
// 4 being the number of goroutines we need to finish when this function // 4 being the number of goroutines we need to finish when this function
// returns. // returns.
atomic.AddInt32(&working, 4) wg.Add(4)
go c.execLoop(ctx, errs, &working) go c.execLoop(ctx, errs, &wg)
go c.readLoop(ctx, errs, &working) go c.readLoop(ctx, errs, &wg)
go c.sendLoop(ctx, errs, &working) go c.sendLoop(ctx, errs, &wg)
go c.pingLoop(ctx, errs, &working) go c.pingLoop(ctx, errs, &wg)
// Passwords first. // Passwords first.
@ -329,9 +326,7 @@ startConn:
c.listCAP() c.listCAP()
// Then nickname. // Then nickname.
c.state.RLock()
c.write(&Event{Command: NICK, Params: []string{c.Config.Nick}}) c.write(&Event{Command: NICK, Params: []string{c.Config.Nick}})
c.state.RUnlock()
// Then username and realname. // Then username and realname.
if c.Config.Name == "" { if c.Config.Name == "" {
@ -352,41 +347,40 @@ startConn:
} }
c.RunHandlers(&Event{Command: CLOSED, Params: []string{addr}}) c.RunHandlers(&Event{Command: CLOSED, Params: []string{addr}})
case err := <-errs: case err := <-errs:
c.debug.Printf("(%s) received error, beginning cleanup: %v", c.Config.Nick, err) c.debug.Printf("received error, beginning cleanup: %v", err)
result = err result = err
} }
// Make sure that the connection is closed if not already. // Make sure that the connection is closed if not already.
c.mu.RLock()
if c.stop != nil { if c.stop != nil {
c.stop() c.stop()
} }
c.conn.mu.Lock()
c.conn.connected.Store(false) c.conn.connected = false
_ = c.conn.Close() _ = c.conn.Close()
c.conn.mu.Unlock()
c.mu.RUnlock()
c.RunHandlers(&Event{Command: DISCONNECTED, Params: []string{addr}}) c.RunHandlers(&Event{Command: DISCONNECTED, Params: []string{addr}})
// Once we have our error/result, let all other functions know we're done. // Once we have our error/result, let all other functions know we're done.
c.debug.Print("waiting for all routines to finish") c.debug.Print("waiting for all routines to finish")
for {
if atomic.LoadInt32(&working) <= 0 {
break
}
}
// Wait for all goroutines to finish. // Wait for all goroutines to finish.
wg.Wait()
close(errs) close(errs)
// This helps ensure that the end user isn't improperly using the client // This helps ensure that the end user isn't improperly using the client
// more than once. If they want to do this, they should be using multiple // more than once. If they want to do this, they should be using multiple
// clients, not multiple instances of Connect(). // clients, not multiple instances of Connect().
c.mu.Lock()
c.conn = nil c.conn = nil
if result == nil { if result == nil {
if c.state.sts.beginUpgrade { if c.state.sts.beginUpgrade {
c.state.sts.beginUpgrade = false c.state.sts.beginUpgrade = false
c.mu.Unlock()
goto startConn goto startConn
} }
@ -394,13 +388,14 @@ startConn:
c.state.sts.persistenceReceived = time.Now() c.state.sts.persistenceReceived = time.Now()
} }
} }
c.mu.Unlock()
return result return result
} }
// readLoop sets a timeout of 300 seconds, and then attempts to read from the // readLoop sets a timeout of 300 seconds, and then attempts to read from the
// IRC server. If there is an error, it calls Reconnect. // IRC server. If there is an error, it calls Reconnect.
func (c *Client) readLoop(ctx context.Context, errs chan error, working *int32) { func (c *Client) readLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
c.debug.Print("starting readLoop") c.debug.Print("starting readLoop")
defer c.debug.Print("closing readLoop") defer c.debug.Print("closing readLoop")
@ -410,19 +405,17 @@ func (c *Client) readLoop(ctx context.Context, errs chan error, working *int32)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
atomic.AddInt32(working, -1) wg.Done()
return return
default: default:
_ = c.conn.sock.SetReadDeadline(time.Now().Add(300 * time.Second)) _ = c.conn.sock.SetReadDeadline(time.Now().Add(300 * time.Second))
event, err = c.conn.decode() event, err = c.conn.decode()
if err != nil { if err != nil {
errs <- err errs <- err
atomic.AddInt32(working, -1) wg.Done()
return return
} }
event.Network = c.NetworkName()
// Check if it's an echo-message. // Check if it's an echo-message.
if !c.Config.disableTracking { if !c.Config.disableTracking {
event.Echo = (event.Command == PRIVMSG || event.Command == NOTICE) && event.Echo = (event.Command == PRIVMSG || event.Command == NOTICE) &&
@ -434,36 +427,49 @@ func (c *Client) readLoop(ctx context.Context, errs chan error, working *int32)
} }
} }
// Send sends an event to the server. Use Client.RunHandlers() if you are // Send sends an event to the server. Send will split events if the event is longer than
// simply looking to trigger handlers with an event. // what the server supports, and is an event that supports splitting. Use
// Client.RunHandlers() if you are simply looking to trigger handlers with an event.
func (c *Client) Send(event *Event) { func (c *Client) Send(event *Event) {
var delay time.Duration var delay time.Duration
event.Network = c.NetworkName()
if !c.Config.AllowFlood {
// Drop the event early as we're disconnected, this way we don't have to wait
// the (potentially long) rate limit delay before dropping.
if c.conn == nil {
c.debugLogEvent(event, true)
return
}
delay = c.conn.rate(event.Len())
}
if c.Config.GlobalFormat && len(event.Params) > 0 && event.Params[len(event.Params)-1] != "" && if c.Config.GlobalFormat && len(event.Params) > 0 && event.Params[len(event.Params)-1] != "" &&
(event.Command == PRIVMSG || event.Command == TOPIC || event.Command == NOTICE) { (event.Command == PRIVMSG || event.Command == TOPIC || event.Command == NOTICE) {
event.Params[len(event.Params)-1] = Fmt(event.Params[len(event.Params)-1]) event.Params[len(event.Params)-1] = Fmt(event.Params[len(event.Params)-1])
} }
<-time.After(delay) var events []*Event
c.write(event) events = event.split(c.MaxEventLength())
for _, e := range events {
if !c.Config.AllowFlood {
c.mu.RLock()
// Drop the event early as we're disconnected, this way we don't have to wait
// the (potentially long) rate limit delay before dropping.
if c.conn == nil {
c.debugLogEvent(e, true)
c.mu.RUnlock()
return
}
c.conn.mu.Lock()
delay = c.conn.rate(e.Len())
c.conn.mu.Unlock()
c.mu.RUnlock()
}
<-time.After(delay)
c.write(e)
}
} }
// write is the lower level function to write an event. It does not have a // write is the lower level function to write an event. It does not have a
// write-delay when sending events. // write-delay when sending events.
func (c *Client) write(event *Event) { func (c *Client) write(event *Event) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.conn == nil { if c.conn == nil {
// Drop the event if disconnected. // Drop the event if disconnected.
c.debugLogEvent(event, true) c.debugLogEvent(event, true)
@ -477,30 +483,21 @@ func (c *Client) write(event *Event) {
func (c *ircConn) rate(chars int) time.Duration { func (c *ircConn) rate(chars int) time.Duration {
_time := time.Second + ((time.Duration(chars) * time.Second) / 100) _time := time.Second + ((time.Duration(chars) * time.Second) / 100)
if c.writeDelay.Load() == nil { if c.writeDelay += _time - time.Now().Sub(c.lastWrite); c.writeDelay < 0 {
c.writeDelay.Store(time.Duration(0)) c.writeDelay = 0
}
wdelay := c.writeDelay.Load().(time.Duration)
lwrite := c.lastWrite.Load().(time.Time)
if wdelay += _time - time.Since(lwrite); wdelay < 0 {
c.writeDelay.Store(time.Duration(0))
} }
if c.writeDelay.Load().(time.Duration) > (8 * time.Second) { if c.writeDelay > (8 * time.Second) {
return _time return _time
} }
return 0 return 0
} }
func (c *Client) sendLoop(ctx context.Context, errs chan error, working *int32) { func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
c.debug.Print("starting sendLoop") c.debug.Print("starting sendLoop")
defer c.debug.Print("closing sendLoop") defer c.debug.Print("closing sendLoop")
defer atomic.AddInt32(working, -1)
var err error var err error
for { for {
@ -509,13 +506,15 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, working *int32)
// Check if tags exist on the event. If they do, and message-tags // Check if tags exist on the event. If they do, and message-tags
// isn't a supported capability, remove them from the event. // isn't a supported capability, remove them from the event.
if event.Tags != nil { if event.Tags != nil {
c.state.RLock()
var in bool var in bool
for i := 0; i < c.state.enabledCap.Count(); i++ { for i := 0; i < len(c.state.enabledCap); i++ {
if _, ok := c.state.enabledCap.Get("message-tags"); ok { if _, ok := c.state.enabledCap["message-tags"]; ok {
in = true in = true
break break
} }
} }
c.state.RUnlock()
if !in { if !in {
event.Tags = Tags{} event.Tags = Tags{}
@ -524,11 +523,13 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, working *int32)
c.debugLogEvent(event, false) c.debugLogEvent(event, false)
c.conn.lastWrite.Store(time.Now()) c.conn.mu.Lock()
c.conn.lastWrite = time.Now()
if event.Command != PING && event.Command != PONG && event.Command != WHO { if event.Command != PING && event.Command != PONG && event.Command != WHO {
c.conn.lastActive = c.conn.lastWrite c.conn.lastActive = c.conn.lastWrite
} }
c.conn.mu.Unlock()
// Write the raw line. // Write the raw line.
_, err = c.conn.io.Write(event.Bytes()) _, err = c.conn.io.Write(event.Bytes())
@ -543,14 +544,17 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, working *int32)
if event.Command == QUIT { if event.Command == QUIT {
c.Close() c.Close()
wg.Done()
return return
} }
if err != nil { if err != nil {
errs <- err errs <- err
wg.Done()
return return
} }
case <-ctx.Done(): case <-ctx.Done():
wg.Done()
return return
} }
} }
@ -571,25 +575,26 @@ type ErrTimedOut struct {
func (ErrTimedOut) Error() string { return "timed out waiting for a requested PING response" } func (ErrTimedOut) Error() string { return "timed out waiting for a requested PING response" }
func (c *Client) pingLoop(ctx context.Context, errs chan error, working *int32) { func (c *Client) pingLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
defer atomic.AddInt32(working, -1)
// Don't run the pingLoop if they want to disable it. // Don't run the pingLoop if they want to disable it.
if c.Config.PingDelay <= 0 { if c.Config.PingDelay <= 0 {
wg.Done()
return return
} }
c.debug.Print("starting pingLoop") c.debug.Print("starting pingLoop")
defer c.debug.Print("closing pingLoop") defer c.debug.Print("closing pingLoop")
c.conn.lastPing.Store(time.Now()) c.conn.mu.Lock()
c.conn.lastPong.Store(time.Now()) c.conn.lastPing = time.Now()
c.conn.lastPong = time.Now()
c.conn.mu.Unlock()
tick := time.NewTicker(c.Config.PingDelay) tick := time.NewTicker(c.Config.PingDelay)
defer tick.Stop() defer tick.Stop()
started := time.Now() started := time.Now()
past := false past := false
pingSent := false
for { for {
select { select {
@ -604,27 +609,30 @@ func (c *Client) pingLoop(ctx context.Context, errs chan error, working *int32)
past = true past = true
} }
if pingSent && time.Since(c.conn.lastPong.Load().(time.Time)) > c.Config.PingDelay+(180*time.Second) { c.conn.mu.RLock()
// It's 180 seconds over what out ping delay is, connection has probably dropped. if time.Since(c.conn.lastPong) > c.Config.PingDelay+(60*time.Second) {
// It's 60 seconds over what out ping delay is, connection
err := ErrTimedOut{ // has probably dropped.
TimeSinceSuccess: time.Since(c.conn.lastPong.Load().(time.Time)), errs <- ErrTimedOut{
LastPong: c.conn.lastPong.Load().(time.Time), TimeSinceSuccess: time.Since(c.conn.lastPong),
LastPing: c.conn.lastPing.Load().(time.Time), LastPong: c.conn.lastPong,
LastPing: c.conn.lastPing,
Delay: c.Config.PingDelay, Delay: c.Config.PingDelay,
} }
go func() { wg.Done()
errs <- err c.conn.mu.RUnlock()
}()
return return
} }
c.conn.mu.RUnlock()
c.conn.lastPing.Store(time.Now()) c.conn.mu.Lock()
c.conn.lastPing = time.Now()
c.conn.mu.Unlock()
c.Cmd.Ping(fmt.Sprintf("%d", time.Now().UnixNano())) c.Cmd.Ping(fmt.Sprintf("%d", time.Now().UnixNano()))
pingSent = true
case <-ctx.Done(): case <-ctx.Done():
wg.Done()
return return
} }
} }

@ -8,18 +8,16 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"net" "net"
"os"
"sync/atomic"
"testing" "testing"
"time" "time"
) )
func mockBuffers() (in, out *bytes.Buffer, irc *ircConn) { func mockBuffers() (in *bytes.Buffer, out *bytes.Buffer, irc *ircConn) {
in = &bytes.Buffer{} in = &bytes.Buffer{}
out = &bytes.Buffer{} out = &bytes.Buffer{}
irc = &ircConn{ irc = &ircConn{
io: bufio.NewReadWriter(bufio.NewReader(in), bufio.NewWriter(out)), io: bufio.NewReadWriter(bufio.NewReader(in), bufio.NewWriter(out)),
connected: atomic.Value{}, connected: true,
} }
return in, out, irc return in, out, irc
@ -48,6 +46,8 @@ func TestDecode(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("should have failed to parse decoded event. got: %#v", event) t.Fatalf("should have failed to parse decoded event. got: %#v", event)
} }
return
} }
func TestEncode(t *testing.T) { func TestEncode(t *testing.T) {
@ -70,11 +70,13 @@ func TestEncode(t *testing.T) {
if want != line { if want != line {
t.Fatalf("encoded line wanted: %q, got: %q", want, line) t.Fatalf("encoded line wanted: %q, got: %q", want, line)
} }
return
} }
func TestRate(t *testing.T) { func TestRate(t *testing.T) {
_, _, c := mockBuffers() _, _, c := mockBuffers()
c.lastWrite.Store(time.Now()) c.lastWrite = time.Now()
if delay := c.rate(100); delay > time.Second { if delay := c.rate(100); delay > time.Second {
t.Fatal("first instance of rate is > second") t.Fatal("first instance of rate is > second")
} }
@ -86,16 +88,17 @@ func TestRate(t *testing.T) {
if delay := c.rate(200); delay > (3 * time.Second) { if delay := c.rate(200); delay > (3 * time.Second) {
t.Fatal("rate delay too high") t.Fatal("rate delay too high")
} }
return
} }
func genMockConn() (client *Client, clientConn, serverConn net.Conn) { func genMockConn() (client *Client, clientConn net.Conn, serverConn net.Conn) {
client = New(Config{ client = New(Config{
Server: "dummy.int", Server: "dummy.int",
Port: 6667, Port: 6667,
Nick: "test", Nick: "test",
User: "test", User: "test",
Name: "Testing123", Name: "Testing123",
Debug: os.Stdout,
}) })
conn1, conn2 := net.Pipe() conn1, conn2 := net.Pipe()
@ -107,7 +110,7 @@ func mockReadBuffer(conn net.Conn) {
// Accept all outgoing writes from the client. // Accept all outgoing writes from the client.
b := bufio.NewReader(conn) b := bufio.NewReader(conn)
for { for {
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) conn.SetReadDeadline(time.Now().Add(10 * time.Second))
_, err := b.ReadString(byte('\n')) _, err := b.ReadString(byte('\n'))
if err != nil { if err != nil {
return return

@ -5,8 +5,6 @@
package girc package girc
// Standard CTCP based constants. // Standard CTCP based constants.
//
//goland:noinspection ALL
const ( const (
CTCP_ACTION = "ACTION" CTCP_ACTION = "ACTION"
CTCP_PING = "PING" CTCP_PING = "PING"
@ -22,8 +20,6 @@ const (
// Emulated event commands used to allow easier hooks into the changing // Emulated event commands used to allow easier hooks into the changing
// state of the client. // state of the client.
//
//goland:noinspection ALL
const ( const (
UPDATE_STATE = "CLIENT_STATE_UPDATED" // when channel/user state is updated. UPDATE_STATE = "CLIENT_STATE_UPDATED" // when channel/user state is updated.
UPDATE_GENERAL = "CLIENT_GENERAL_UPDATED" // when general state (client nick, server name, etc) is updated. UPDATE_GENERAL = "CLIENT_GENERAL_UPDATED" // when general state (client nick, server name, etc) is updated.
@ -37,8 +33,6 @@ const (
) )
// User/channel prefixes :: RFC1459. // User/channel prefixes :: RFC1459.
//
//goland:noinspection ALL
const ( const (
DefaultPrefixes = "(ov)@+" // the most common default prefixes DefaultPrefixes = "(ov)@+" // the most common default prefixes
ModeAddPrefix = "+" // modes are being added ModeAddPrefix = "+" // modes are being added
@ -54,8 +48,6 @@ const (
) )
// User modes :: RFC1459; section 4.2.3.2. // User modes :: RFC1459; section 4.2.3.2.
//
//goland:noinspection ALL
const ( const (
UserModeInvisible = "i" // invisible UserModeInvisible = "i" // invisible
UserModeOperator = "o" // server operator UserModeOperator = "o" // server operator
@ -64,8 +56,6 @@ const (
) )
// Channel modes :: RFC1459; section 4.2.3.1. // Channel modes :: RFC1459; section 4.2.3.1.
//
//goland:noinspection ALL
const ( const (
ModeDefaults = "beI,k,l,imnpst" // the most common default modes ModeDefaults = "beI,k,l,imnpst" // the most common default modes
@ -85,8 +75,6 @@ const (
) )
// IRC commands :: RFC2812; section 3 :: RFC2813; section 4. // IRC commands :: RFC2812; section 3 :: RFC2813; section 4.
//
//goland:noinspection ALL
const ( const (
ADMIN = "ADMIN" ADMIN = "ADMIN"
AWAY = "AWAY" AWAY = "AWAY"
@ -139,8 +127,6 @@ const (
) )
// Numeric IRC reply mapping :: RFC2812; section 5. // Numeric IRC reply mapping :: RFC2812; section 5.
//
//goland:noinspection ALL
const ( const (
RPL_WELCOME = "001" RPL_WELCOME = "001"
RPL_YOURHOST = "002" RPL_YOURHOST = "002"
@ -284,8 +270,6 @@ const (
) )
// IRCv3 commands and extensions :: http://ircv3.net/irc/. // IRCv3 commands and extensions :: http://ircv3.net/irc/.
//
//goland:noinspection ALL
const ( const (
AUTHENTICATE = "AUTHENTICATE" AUTHENTICATE = "AUTHENTICATE"
MONITOR = "MONITOR" MONITOR = "MONITOR"
@ -309,8 +293,6 @@ const (
) )
// Numeric IRC reply mapping for ircv3 :: http://ircv3.net/irc/. // Numeric IRC reply mapping for ircv3 :: http://ircv3.net/irc/.
//
//goland:noinspection ALL
const ( const (
RPL_LOGGEDIN = "900" RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901" RPL_LOGGEDOUT = "901"
@ -331,8 +313,6 @@ const (
) )
// Numeric IRC event mapping :: RFC2812; section 5.3. // Numeric IRC event mapping :: RFC2812; section 5.3.
//
//goland:noinspection ALL
const ( const (
RPL_STATSCLINE = "213" RPL_STATSCLINE = "213"
RPL_STATSNLINE = "214" RPL_STATSNLINE = "214"
@ -361,22 +341,10 @@ const (
) )
// Misc. // Misc.
//
//goland:noinspection ALL
const ( const (
ERR_TOOMANYMATCHES = "416" // IRCNet. ERR_TOOMANYMATCHES = "416" // IRCNet.
RPL_GLOBALUSERS = "266" // aircd/hybrid/bahamut, used on freenode. RPL_GLOBALUSERS = "266" // aircd/hybrid/bahamut, used on freenode.
RPL_LOCALUSERS = "265" // aircd/hybrid/bahamut, used on freenode. RPL_LOCALUSERS = "265" // aircd/hybrid/bahamut, used on freenode.
RPL_TOPICWHOTIME = "333" // ircu, used on freenode. RPL_TOPICWHOTIME = "333" // ircu, used on freenode.
RPL_WHOSPCRPL = "354" // ircu, used on networks with WHOX support. RPL_WHOSPCRPL = "354" // ircu, used on networks with WHOX support.
RPL_CREATIONTIME = "329"
)
// As seen in the wild.
//
//goland:noinspection ALL
const (
RPL_WHOISAUTHNAME = "330"
RPL_WHOISTLS = "671"
ERR_ALREADYOPER = "400"
) )

95
ctcp.go

@ -5,12 +5,11 @@
package girc package girc
import ( import (
"fmt"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
"time" "time"
cmap "github.com/orcaman/concurrent-map/v2"
) )
// ctcpDelim if the delimiter used for CTCP formatted events/messages. // ctcpDelim if the delimiter used for CTCP formatted events/messages.
@ -105,8 +104,8 @@ func EncodeCTCP(ctcp *CTCPEvent) (out string) {
// EncodeCTCPRaw is much like EncodeCTCP, however accepts a raw command and // EncodeCTCPRaw is much like EncodeCTCP, however accepts a raw command and
// string as input. // string as input.
func EncodeCTCPRaw(cmd, text string) (out string) { func EncodeCTCPRaw(cmd, text string) (out string) {
if cmd == "" { if len(cmd) <= 0 {
return cmd return ""
} }
out = string(ctcpDelim) + cmd out = string(ctcpDelim) + cmd
@ -124,31 +123,45 @@ type CTCP struct {
// mu is the mutex that should be used when accessing any ctcp handlers. // mu is the mutex that should be used when accessing any ctcp handlers.
mu sync.RWMutex mu sync.RWMutex
// handlers is a map of CTCP message -> functions. // handlers is a map of CTCP message -> functions.
handlers cmap.ConcurrentMap[string, CTCPHandler] handlers map[string]CTCPHandler
} }
// newCTCP returns a new clean CTCP handler. // newCTCP returns a new clean CTCP handler.
func newCTCP() *CTCP { func newCTCP() *CTCP {
return &CTCP{handlers: cmap.New[CTCPHandler]()} return &CTCP{handlers: map[string]CTCPHandler{}}
} }
// call executes the necessary CTCP handler for the incoming event/CTCP // call executes the necessary CTCP handler for the incoming event/CTCP
// command. // command.
func (c *CTCP) call(client *Client, event *CTCPEvent) { func (c *CTCP) call(client *Client, event *CTCPEvent) {
c.mu.RLock()
defer c.mu.RUnlock()
// If they want to catch any panics, add to defer stack. // If they want to catch any panics, add to defer stack.
if client.Config.RecoverFunc != nil && event.Origin != nil { if client.Config.RecoverFunc != nil && event.Origin != nil {
defer recoverHandlerPanic(client, event.Origin, "ctcp-"+strings.ToLower(event.Command), 3) defer recoverHandlerPanic(client, event.Origin, "ctcp-"+strings.ToLower(event.Command), 3)
} }
// Support wildcard CTCP event handling. Gets executed first before // Support wildcard CTCP event handling. Gets executed first before
// regular event handlers. // regular event handlers.
if val, ok := c.handlers.Get("*"); ok && val != nil { if _, ok := c.handlers["*"]; ok {
val(client, *event) c.handlers["*"](client, *event)
} }
val, ok := c.handlers.Get(event.Command)
if !ok || val == nil || event.Command == CTCP_ACTION { if _, ok := c.handlers[event.Command]; !ok {
// If ACTION, don't do anything.
if event.Command == CTCP_ACTION {
return
}
// Send a ERRMSG reply, if we know who sent it.
if event.Source != nil && IsValidNick(event.Source.ID()) {
client.Cmd.SendCTCPReply(event.Source.ID(), CTCP_ERRMSG, "that is an unknown CTCP query")
}
return return
} }
val(client, *event)
c.handlers[event.Command](client, *event)
} }
// parseCMD parses a CTCP command/tag, ensuring it's valid. If not, an empty // parseCMD parses a CTCP command/tag, ensuring it's valid. If not, an empty
@ -180,7 +193,10 @@ func (c *CTCP) Set(cmd string, handler func(client *Client, ctcp CTCPEvent)) {
if cmd = c.parseCMD(cmd); cmd == "" { if cmd = c.parseCMD(cmd); cmd == "" {
return return
} }
c.handlers.Set(cmd, handler)
c.mu.Lock()
c.handlers[cmd] = CTCPHandler(handler)
c.mu.Unlock()
} }
// SetBg is much like Set, however the handler is executed in the background, // SetBg is much like Set, however the handler is executed in the background,
@ -197,12 +213,18 @@ func (c *CTCP) Clear(cmd string) {
if cmd = c.parseCMD(cmd); cmd == "" { if cmd = c.parseCMD(cmd); cmd == "" {
return return
} }
c.handlers.Remove(cmd)
c.mu.Lock()
delete(c.handlers, cmd)
c.mu.Unlock()
} }
// ClearAll removes all currently setup and re-sets the default handlers. // ClearAll removes all currently setup and re-sets the default handlers.
func (c *CTCP) ClearAll() { func (c *CTCP) ClearAll() {
c.handlers = cmap.New[CTCPHandler]() c.mu.Lock()
c.handlers = map[string]CTCPHandler{}
c.mu.Unlock()
// Register necessary handlers. // Register necessary handlers.
c.addDefaultHandlers() c.addDefaultHandlers()
} }
@ -217,8 +239,6 @@ func (c *CTCP) addDefaultHandlers() {
c.SetBg(CTCP_PONG, handleCTCPPong) c.SetBg(CTCP_PONG, handleCTCPPong)
c.SetBg(CTCP_VERSION, handleCTCPVersion) c.SetBg(CTCP_VERSION, handleCTCPVersion)
c.SetBg(CTCP_SOURCE, handleCTCPSource) c.SetBg(CTCP_SOURCE, handleCTCPSource)
c.SetBg(CTCP_USERINFO, handleCTCPUserInfo)
c.SetBg(CTCP_CLIENTINFO, handleCTCPClientInfo)
c.SetBg(CTCP_TIME, handleCTCPTime) c.SetBg(CTCP_TIME, handleCTCPTime)
c.SetBg(CTCP_FINGER, handleCTCPFinger) c.SetBg(CTCP_FINGER, handleCTCPFinger)
} }
@ -240,7 +260,8 @@ func handleCTCPPong(client *Client, ctcp CTCPEvent) {
} }
// handleCTCPVersion replies with the name of the client, Go version, as well // handleCTCPVersion replies with the name of the client, Go version, as well
// as the os type if not overridden by client configuration. // as the os type (darwin, linux, windows, etc) and architecture type (x86,
// arm, etc).
func handleCTCPVersion(client *Client, ctcp CTCPEvent) { func handleCTCPVersion(client *Client, ctcp CTCPEvent) {
if client.Config.Version != "" { if client.Config.Version != "" {
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_VERSION, client.Config.Version) client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_VERSION, client.Config.Version)
@ -249,34 +270,14 @@ func handleCTCPVersion(client *Client, ctcp CTCPEvent) {
client.Cmd.SendCTCPReplyf( client.Cmd.SendCTCPReplyf(
ctcp.Source.ID(), CTCP_VERSION, ctcp.Source.ID(), CTCP_VERSION,
"girc-atomic %s (%s, %s)", "girc (github.com/lrstanley/girc) using %s (%s, %s)",
Version, runtime.GOOS, runtime.GOARCH, runtime.Version(), runtime.GOOS, runtime.GOARCH,
) )
} }
// handleCTCPUserInfo replies with the configured user information if available, otherwise it does not reply. // handleCTCPSource replies with the public git location of this library.
func handleCTCPUserInfo(client *Client, ctcp CTCPEvent) {
if client.Config.UserInfo == "" {
return
}
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_USERINFO, client.Config.UserInfo)
}
// handleCTCPClientInfo replies with the configured client information if available, otherwise it does not reply.
func handleCTCPClientInfo(client *Client, ctcp CTCPEvent) {
if client.Config.UserInfo == "" {
return
}
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_USERINFO, client.Config.UserInfo)
}
// handleCTCPUserInfo replies with the public git location of this library.
func handleCTCPSource(client *Client, ctcp CTCPEvent) { func handleCTCPSource(client *Client, ctcp CTCPEvent) {
if client.Config.Source != "" { client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_SOURCE, "https://github.com/lrstanley/girc")
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_SOURCE, client.Config.Source)
return
}
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_SOURCE, "https://github.com/yunginnanet/girc-atomic")
} }
// handleCTCPTime replies with a RFC 1123 (Z) formatted version of Go's // handleCTCPTime replies with a RFC 1123 (Z) formatted version of Go's
@ -288,11 +289,9 @@ func handleCTCPTime(client *Client, ctcp CTCPEvent) {
// handleCTCPFinger replies with the realname and idle time of the user. This // handleCTCPFinger replies with the realname and idle time of the user. This
// is obsoleted by improvements to the IRC protocol, however still supported. // is obsoleted by improvements to the IRC protocol, however still supported.
func handleCTCPFinger(client *Client, ctcp CTCPEvent) { func handleCTCPFinger(client *Client, ctcp CTCPEvent) {
if client.Config.Finger != "" { client.conn.mu.RLock()
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_FINGER, client.Config.Finger) active := client.conn.lastActive
return client.conn.mu.RUnlock()
}
// irssi doesn't appear to do this on a stock install so gonna just go ahead and nix it. client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_FINGER, fmt.Sprintf("%s -- idle %s", client.Config.Name, time.Since(active)))
// active := client.conn.lastActive.Load().(time.Time)
// client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_FINGER, fmt.Sprintf("%s -- idle %s", client.Config.Name, time.Since(active)))
} }

@ -9,40 +9,26 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"unicode/utf8"
) )
var testsEncodeCTCP = []struct { func TestEncodeCTCP(t *testing.T) {
name string type args struct {
test *CTCPEvent ctcp *CTCPEvent
want string
}{
{name: "command only", test: &CTCPEvent{Command: "TEST", Text: ""}, want: "\001TEST\001"},
{name: "command with args", test: &CTCPEvent{Command: "TEST", Text: "TEST"}, want: "\001TEST TEST\001"},
{name: "nil command", test: &CTCPEvent{Command: "", Text: "TEST"}, want: ""},
{name: "nil event", test: nil, want: ""},
}
func FuzzEncodeCTCP(f *testing.F) {
for _, tc := range testsEncodeCTCP {
if tc.test == nil {
continue
}
f.Add(tc.test.Command, tc.test.Text)
} }
f.Fuzz(func(t *testing.T, cmd, text string) { tests := []struct {
got := EncodeCTCP(&CTCPEvent{Command: cmd, Text: text}) name string
args args
want string
}{
{name: "command only", args: args{ctcp: &CTCPEvent{Command: "TEST", Text: ""}}, want: "\001TEST\001"},
{name: "command with args", args: args{ctcp: &CTCPEvent{Command: "TEST", Text: "TEST"}}, want: "\001TEST TEST\001"},
{name: "nil command", args: args{ctcp: &CTCPEvent{Command: "", Text: "TEST"}}, want: ""},
{name: "nil event", args: args{ctcp: nil}, want: ""},
}
if utf8.ValidString(cmd) && utf8.ValidString(text) && !utf8.ValidString(got) { for _, tt := range tests {
t.Errorf("produced invalid UTF-8 string %q", got) if got := EncodeCTCP(tt.args.ctcp); got != tt.want {
}
})
}
func TestEncodeCTCP(t *testing.T) {
for _, tt := range testsEncodeCTCP {
if got := EncodeCTCP(tt.test); got != tt.want {
t.Errorf("%s: encodeCTCP() = %q, want %q", tt.name, got, tt.want) t.Errorf("%s: encodeCTCP() = %q, want %q", tt.name, got, tt.want)
} }
} }
@ -124,8 +110,7 @@ func TestCall(t *testing.T) {
atomic.AddUint64(&counter, 1) atomic.AddUint64(&counter, 1)
}) })
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}) if ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}); atomic.LoadUint64(&counter) != 1 {
if atomic.LoadUint64(&counter) != 1 {
t.Fatal("regular execution: call() didn't increase counter") t.Fatal("regular execution: call() didn't increase counter")
} }
ctcp.Clear("TEST") ctcp.Clear("TEST")
@ -135,8 +120,7 @@ func TestCall(t *testing.T) {
}) })
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}) ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"})
time.Sleep(250 * time.Millisecond) if time.Sleep(250 * time.Millisecond); atomic.LoadUint64(&counter) != 2 {
if atomic.LoadUint64(&counter) != 2 {
t.Fatal("goroutine execution: call() in goroutine didn't increase counter") t.Fatal("goroutine execution: call() in goroutine didn't increase counter")
} }
ctcp.Clear("TEST") ctcp.Clear("TEST")
@ -145,15 +129,14 @@ func TestCall(t *testing.T) {
atomic.AddUint64(&counter, 1) atomic.AddUint64(&counter, 1)
}) })
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}) if ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}); atomic.LoadUint64(&counter) != 3 {
if atomic.LoadUint64(&counter) != 3 {
t.Fatal("wildcard execution: call() didn't increase counter") t.Fatal("wildcard execution: call() didn't increase counter")
} }
ctcp.Clear("*") ctcp.Clear("*")
ctcp.Clear("TEST") ctcp.Clear("TEST")
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}) if ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}); atomic.LoadUint64(&counter) != 3 {
if atomic.LoadUint64(&counter) != 3 {
t.Fatal("empty execution: call() with no handler incremented the counter") t.Fatal("empty execution: call() with no handler incremented the counter")
} }
} }
@ -162,13 +145,13 @@ func TestSet(t *testing.T) {
ctcp := newCTCP() ctcp := newCTCP()
ctcp.Set("TEST-1", func(client *Client, event CTCPEvent) {}) ctcp.Set("TEST-1", func(client *Client, event CTCPEvent) {})
if _, ok := ctcp.handlers.Get("TEST"); ok { if _, ok := ctcp.handlers["TEST"]; ok {
t.Fatal("Set('TEST') allowed invalid command") t.Fatal("Set('TEST') allowed invalid command")
} }
ctcp.Set("TEST", func(client *Client, event CTCPEvent) {}) ctcp.Set("TEST", func(client *Client, event CTCPEvent) {})
// Make sure it's there. // Make sure it's there.
if _, ok := ctcp.handlers.Get("TEST"); !ok { if _, ok := ctcp.handlers["TEST"]; !ok {
t.Fatal("store: Set('TEST') didn't set") t.Fatal("store: Set('TEST') didn't set")
} }
} }
@ -179,7 +162,7 @@ func TestClear(t *testing.T) {
ctcp.Set("TEST", func(client *Client, event CTCPEvent) {}) ctcp.Set("TEST", func(client *Client, event CTCPEvent) {})
ctcp.Clear("TEST") ctcp.Clear("TEST")
if _, ok := ctcp.handlers.Get("TEST"); ok { if _, ok := ctcp.handlers["TEST"]; ok {
t.Fatal("ctcp.Clear('TEST') didn't remove handler") t.Fatal("ctcp.Clear('TEST') didn't remove handler")
} }
} }
@ -191,8 +174,8 @@ func TestClearAll(t *testing.T) {
ctcp.Set("TEST2", func(client *Client, event CTCPEvent) {}) ctcp.Set("TEST2", func(client *Client, event CTCPEvent) {})
ctcp.ClearAll() ctcp.ClearAll()
_, first := ctcp.handlers.Get("TEST1") _, first := ctcp.handlers["TEST1"]
_, second := ctcp.handlers.Get("TEST2") _, second := ctcp.handlers["TEST2"]
if first || second { if first || second {
t.Fatalf("ctcp.ClearAll() didn't remove all handlers: 1: %v 2: %v", first, second) t.Fatalf("ctcp.ClearAll() didn't remove all handlers: 1: %v 2: %v", first, second)

174
event.go

@ -13,7 +13,41 @@ import (
const ( const (
eventSpace byte = ' ' // Separator. eventSpace byte = ' ' // Separator.
maxLength int = 510 // Maximum length is 510 (2 for line endings).
// TODO: if state tracking is enabled, we SHOULD be able to use it's known length.
// Can be overridden by the NICKLEN (or MAXNICKLEN) ISUPPORT parameter. 30 or 31
// are typical values for this parameter advertised by servers today.
defaultNickLength = 30
// The maximum length of <username> may be specified by the USERLEN RPL_ISUPPORT
// parameter. If this length is advertised, the username MUST be silently truncated
// to the given length before being used.
defaultUserLength = 18
// If a looked-up domain name is longer than this length (or overridden by the
// HOSTLEN ISUPPORT parameter), the server SHOULD opt to use the IP address instead,
// so that the hostname is underneath this length.
defaultHostLength = 63
// defaultPrefixPadding defaults the estimated prefix padding length of a given
// event. See also:
// [ ":" ( servername / ( nickname [ [ "!" user ] "@" host ] ) ) SPACE ]
defaultPrefixPadding = 4
)
var (
// DefaultMaxLineLength is the default maximum length for an event. 510 (+2 for line endings)
// is used as a default as this is used by many older implementations.
//
// See also: RFC 2812
// IRC messages are always lines of characters terminated with a CR-LF
// (Carriage Return - Line Feed) pair, and these messages SHALL NOT
// exceed 512 characters in length, counting all characters including
// the trailing CR-LF.
DefaultMaxLineLength = 510
// DefaultMaxPrefixLength defines the default max ":nickname!user@host " length
// that's used to calculate line splitting.
DefaultMaxPrefixLength = defaultPrefixPadding + defaultNickLength + defaultUserLength + defaultHostLength
) )
// cutCRFunc is used to trim CR characters from prefixes/messages. // cutCRFunc is used to trim CR characters from prefixes/messages.
@ -52,7 +86,7 @@ func ParseEvent(raw string) (e *Event) {
i = 0 i = 0
} }
if raw != "" && raw[0] == messagePrefix { if raw[0] == messagePrefix {
// Prefix ends with a space. // Prefix ends with a space.
i = strings.IndexByte(raw, eventSpace) i = strings.IndexByte(raw, eventSpace)
@ -155,10 +189,8 @@ type Event struct {
// Sensitive should be true if the message is sensitive (e.g. and should // Sensitive should be true if the message is sensitive (e.g. and should
// not be logged/shown in debugging output). // not be logged/shown in debugging output).
Sensitive bool `json:"sensitive"` Sensitive bool `json:"sensitive"`
// Echo is if the event is an echo-message response. // If the event is an echo-message response.
Echo bool `json:"echo"` Echo bool `json:"echo"`
// Network represents the originating IRC network the event came from.
Network string
} }
// Last returns the last parameter in Event.Params if it exists. // Last returns the last parameter in Event.Params if it exists.
@ -169,16 +201,6 @@ func (e *Event) Last() string {
return "" return ""
} }
// IsError does it's best to determine if the incoming event is tied to a known error event/command.
// Note that if we are not aware of the given numeric IRC command, then this function will return false.
// See: codebook.go
func (e *Event) IsError() bool {
if cmdstr, ok := IRCCodes[e.Command]; ok {
return strings.Contains(cmdstr, "ERR_")
}
return false
}
// Copy makes a deep copy of a given event, for use with allowing untrusted // Copy makes a deep copy of a given event, for use with allowing untrusted
// functions/handlers edit the event without causing potential issues with // functions/handlers edit the event without causing potential issues with
// other handlers. // other handlers.
@ -235,11 +257,82 @@ func (e *Event) Equals(ev *Event) bool {
return true return true
} }
// Len calculates the length of the string representation of event. Note that // split will split a potentially large event that is larger than what the server
// this will return the true length (even if longer than what IRC supports), // supports, into multiple events. split will ignore events that cannot be split, and
// which may be useful if you are trying to check and see if a message is // if the event isn't longer than what the server supports, it will just return an array
// too long, to trim it down yourself. // with 1 entry, the original event.
func (e *Event) split(maxLength int) []*Event {
if len(e.Params) < 1 || (e.Command != PRIVMSG && e.Command != NOTICE) {
return []*Event{e}
}
// Exclude source, even if it does exist, because the server will likely ignore the
// sent source anyway.
event := e.Copy()
event.Source = nil
if event.LenOpts(false) < maxLength {
return []*Event{e}
}
results := []*Event{}
// Will force the length check to include " :". This will allow us to get the length
// of the commands and necessary prefixes.
text := event.Last()
event.Params[len(event.Params)-1] = ""
cmdLen := event.LenOpts(false)
var ok bool
var ctcp *CTCPEvent
if ok, ctcp = e.IsCTCP(); ok {
if len(text) == 0 {
return []*Event{e}
}
text = ctcp.Text
// ctcpDelim's at start and end, and space between command and trailing text.
maxLength -= len(ctcp.Command) + 4
}
// TODO: colors? use last color at start of split? make sure it's POST-color gen?
// If the command itself is longer than the limit, there is a problem. PRIVMSG should
// be 1->1 per RFC. Just return the original message and let it be the user of the
// libraries problem.
if cmdLen > maxLength {
return []*Event{e}
}
// Split the text into correctly size segments, and make the necessary number of
// events that duplicate the original event.
for _, split := range splitAtWord(text, maxLength-cmdLen) {
if ctcp != nil {
split = string(ctcpDelim) + ctcp.Command + string(eventSpace) + split + string(ctcpDelim)
}
clonedEvent := event.Copy()
clonedEvent.Source = e.Source
clonedEvent.Params[len(e.Params)-1] = split
results = append(results, clonedEvent)
}
return results
}
// Len calculates the length of the string representation of event (including tags).
// Note that this will return the true length (even if longer than what IRC supports),
// which may be useful if you are trying to check and see if a message is too long, to
// trim it down yourself.
func (e *Event) Len() (length int) { func (e *Event) Len() (length int) {
return e.LenOpts(true)
}
// LenOpts calculates the length of the string representation of event (with a toggle
// for tags). Note that this will return the true length (even if longer than what IRC
// supports), which may be useful if you are trying to check and see if a message is
// too long, to trim it down yourself.
func (e *Event) LenOpts(includeTags bool) (length int) {
if e.Tags != nil { if e.Tags != nil {
// Include tags and trailing space. // Include tags and trailing space.
length = e.Tags.Len() + 1 length = e.Tags.Len() + 1
@ -260,8 +353,7 @@ func (e *Event) Len() (length int) {
// If param contains a space or it's empty, it's trailing, so it should be // If param contains a space or it's empty, it's trailing, so it should be
// prefixed with a colon (:). // prefixed with a colon (:).
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "" || strings.HasPrefix(e.Params[i], ":")) {
strings.HasPrefix(e.Params[i], ":") || e.Params[i] == "") {
length++ length++
} }
} }
@ -272,18 +364,12 @@ func (e *Event) Len() (length int) {
// Bytes returns a []byte representation of event. Strips all newlines and // Bytes returns a []byte representation of event. Strips all newlines and
// carriage returns. // carriage returns.
//
// Per RFC2812 section 2.3, messages should not exceed 512 characters in
// length. This method forces that limit by discarding any characters
// exceeding the length limit.
func (e *Event) Bytes() []byte { func (e *Event) Bytes() []byte {
buffer := new(bytes.Buffer) buffer := new(bytes.Buffer)
// Tags. // Tags.
if e.Tags != nil { if e.Tags != nil {
if _, err := e.Tags.writeTo(buffer); err != nil { e.Tags.writeTo(buffer)
return nil
}
} }
// Event prefix. // Event prefix.
@ -298,9 +384,8 @@ func (e *Event) Bytes() []byte {
// Space separated list of arguments. // Space separated list of arguments.
if len(e.Params) > 0 { if len(e.Params) > 0 {
// buffer.WriteByte(eventSpace)
for i := 0; i < len(e.Params); i++ { for i := 0; i < len(e.Params); i++ {
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || strings.HasPrefix(e.Params[i], ":") || e.Params[i] == "") { if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "" || strings.HasPrefix(e.Params[i], ":")) {
buffer.WriteString(string(eventSpace) + string(messagePrefix) + e.Params[i]) buffer.WriteString(string(eventSpace) + string(messagePrefix) + e.Params[i])
continue continue
} }
@ -308,14 +393,7 @@ func (e *Event) Bytes() []byte {
} }
} }
// We need the limit the buffer length. out := buffer.Bytes()
if buffer.Len() > (maxLength) {
buffer.Truncate(maxLength)
}
// If we truncated in the middle of a utf8 character, we need to remove
// the other (now invalid) bytes.
out := bytes.ToValidUTF8(buffer.Bytes(), nil)
// Strip newlines and carriage returns. // Strip newlines and carriage returns.
for i := 0; i < len(out); i++ { for i := 0; i < len(out); i++ {
@ -379,15 +457,7 @@ func (e *Event) Pretty() (out string, ok bool) {
return fmt.Sprintf("[*] CTCP query from %s: %s%s", ctcp.Source.Name, ctcp.Command, " "+ctcp.Text), true return fmt.Sprintf("[*] CTCP query from %s: %s%s", ctcp.Source.Name, ctcp.Command, " "+ctcp.Text), true
} }
return fmt.Sprintf("[%s] (%s) %s", strings.Join(e.Params[0:len(e.Params)-1], ","), e.Source.Name, e.Last()), true
var source string
if e.Command == PRIVMSG {
source = fmt.Sprintf("(%s)", e.Source.Name)
} else { // NOTICE
source = fmt.Sprintf("--%s--", e.Source.Name)
}
return fmt.Sprintf("[%s] %s %s", strings.Join(e.Params[0:len(e.Params)-1], ","), source, e.Last()), true
} }
if e.Command == RPL_MOTD || e.Command == RPL_MOTDSTART || if e.Command == RPL_MOTD || e.Command == RPL_MOTDSTART ||
@ -620,16 +690,14 @@ func (s *Source) Bytes() []byte {
// String returns a string representation of source. // String returns a string representation of source.
func (s *Source) String() (out string) { func (s *Source) String() (out string) {
out = "" out = s.Name
if len(s.Name) > 0 {
out = s.Name
}
if len(s.Ident) > 0 { if len(s.Ident) > 0 {
out = out + string(prefixIdent) + s.Ident out = out + string(prefixIdent) + s.Ident
} }
if len(s.Host) > 0 { if len(s.Host) > 0 {
out = out + string(prefixHost) + s.Host out = out + string(prefixHost) + s.Host
} }
return return
} }
@ -640,7 +708,7 @@ func (s *Source) IsHostmask() bool {
// IsServer returns true if this source looks like a server name. // IsServer returns true if this source looks like a server name.
func (s *Source) IsServer() bool { func (s *Source) IsServer() bool {
return s.Ident == "" && s.Host == "" return len(s.Ident) <= 0 && len(s.Host) <= 0
} }
// writeTo is an utility function to write the source to the bytes.Buffer // writeTo is an utility function to write the source to the bytes.Buffer
@ -655,4 +723,6 @@ func (s *Source) writeTo(buffer *bytes.Buffer) {
buffer.WriteByte(prefixHost) buffer.WriteByte(prefixHost)
buffer.WriteString(s.Host) buffer.WriteString(s.Host)
} }
return
} }

@ -84,10 +84,7 @@ func TestParseEvent(t *testing.T) {
{in: ":host.domain.com TEST\r\n", want: ":host.domain.com TEST"}, {in: ":host.domain.com TEST\r\n", want: ":host.domain.com TEST"},
{in: ":host.domain.com TEST arg1 arg2", want: ":host.domain.com TEST arg1 arg2"}, {in: ":host.domain.com TEST arg1 arg2", want: ":host.domain.com TEST arg1 arg2"},
{in: ":host.domain.com TEST :", want: ":host.domain.com TEST :"}, {in: ":host.domain.com TEST :", want: ":host.domain.com TEST :"},
{in: ":host.domain.com TEST ::", want: ":host.domain.com TEST ::"},
{in: ":host.domain.com TEST :test1", want: ":host.domain.com TEST test1"}, {in: ":host.domain.com TEST :test1", want: ":host.domain.com TEST test1"},
{in: ":host.domain.com TEST :test:test", want: ":host.domain.com TEST test:test"},
{in: ":host.domain.com TEST :test1 :test", want: ":host.domain.com TEST :test1 :test"},
{in: ":host.domain.com TEST :test1 test2", want: ":host.domain.com TEST :test1 test2"}, {in: ":host.domain.com TEST :test1 test2", want: ":host.domain.com TEST :test1 test2"},
{in: ":host.domain.com TEST arg1 arg2 :test1", want: ":host.domain.com TEST arg1 arg2 test1"}, {in: ":host.domain.com TEST arg1 arg2 :test1", want: ":host.domain.com TEST arg1 arg2 test1"},
{in: ":host.domain.com TEST arg1 arg=:10 :test1", want: ":host.domain.com TEST arg1 arg=:10 test1"}, {in: ":host.domain.com TEST arg1 arg=:10 :test1", want: ":host.domain.com TEST arg1 arg=:10 test1"},
@ -111,7 +108,7 @@ func TestParseEvent(t *testing.T) {
} }
if got == nil { if got == nil {
t.Fatalf("ParseEvent: got nil, want: %s", tt.want) t.Errorf("ParseEvent: got nil, want: %s", tt.want)
} }
if got.String() != tt.want { if got.String() != tt.want {
@ -133,7 +130,6 @@ func TestParseEvent(t *testing.T) {
} }
} }
//goland:noinspection GoNilness
func TestEventCopy(t *testing.T) { func TestEventCopy(t *testing.T) {
var nilEvent *Event var nilEvent *Event

@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/yunginnanet/girc-atomic" "github.com/lrstanley/girc"
) )
func ExampleNew() { func ExampleNew() {
@ -35,6 +35,7 @@ func Example_bare() {
Port: 6667, Port: 6667,
Nick: "test", Nick: "test",
User: "user", User: "user",
Debug: os.Stdout,
}) })
if err := client.Connect(); err != nil { if err := client.Connect(); err != nil {
@ -51,6 +52,7 @@ func Example_simple() {
Nick: "test", Nick: "test",
User: "user", User: "user",
Name: "Example bot", Name: "Example bot",
Debug: os.Stdout,
}) })
client.Handlers.Add(girc.CONNECTED, func(c *girc.Client, e girc.Event) { client.Handlers.Add(girc.CONNECTED, func(c *girc.Client, e girc.Event) {
@ -59,7 +61,7 @@ func Example_simple() {
client.Handlers.Add(girc.PRIVMSG, func(c *girc.Client, e girc.Event) { client.Handlers.Add(girc.PRIVMSG, func(c *girc.Client, e girc.Event) {
if strings.Contains(e.Last(), "hello") { if strings.Contains(e.Last(), "hello") {
_ = c.Cmd.ReplyTo(e, "hello world!") c.Cmd.ReplyTo(e, "hello world!")
return return
} }
@ -99,7 +101,7 @@ func Example_commands() {
client.Handlers.Add(girc.PRIVMSG, func(c *girc.Client, e girc.Event) { client.Handlers.Add(girc.PRIVMSG, func(c *girc.Client, e girc.Event) {
if strings.HasPrefix(e.Last(), "!hello") { if strings.HasPrefix(e.Last(), "!hello") {
_ = c.Cmd.ReplyTo(e, girc.Fmt("{b}hello{b} {blue}world{c}!")) c.Cmd.ReplyTo(e, girc.Fmt("{b}hello{b} {blue}world{c}!"))
return return
} }

118
format.go

@ -66,7 +66,7 @@ var fmtCodes = map[string]string{
// //
// For example: // For example:
// //
// client.Message("#channel", Fmt("{red}{b}Hello {red,blue}World{c}")) // client.Message("#channel", Fmt("{red}{b}Hello {red,blue}World{c}"))
func Fmt(text string) string { func Fmt(text string) string {
var last = -1 var last = -1
for i := 0; i < len(text); i++ { for i := 0; i < len(text); i++ {
@ -127,10 +127,10 @@ func Fmt(text string) string {
// See Fmt() for more information. // See Fmt() for more information.
func TrimFmt(text string) string { func TrimFmt(text string) string {
for color := range fmtColors { for color := range fmtColors {
text = strings.ReplaceAll(text, string(fmtOpenChar)+color+string(fmtCloseChar), "") text = strings.Replace(text, string(fmtOpenChar)+color+string(fmtCloseChar), "", -1)
} }
for code := range fmtCodes { for code := range fmtCodes {
text = strings.ReplaceAll(text, string(fmtOpenChar)+code+string(fmtCloseChar), "") text = strings.Replace(text, string(fmtOpenChar)+code+string(fmtCloseChar), "", -1)
} }
return text return text
@ -138,7 +138,7 @@ func TrimFmt(text string) string {
// This is really the only fastest way of doing this (marginally better than // This is really the only fastest way of doing this (marginally better than
// actually trying to parse it manually.) // actually trying to parse it manually.)
var reStripColor = regexp.MustCompile(`\x03([019]?\d(,[019]?\d)?)?`) var reStripColor = regexp.MustCompile(`\x03([019]?[0-9](,[019]?[0-9])?)?`)
// StripRaw tries to strip all ASCII format codes that are used for IRC. // StripRaw tries to strip all ASCII format codes that are used for IRC.
// Primarily, foreground/background colors, and other control bytes like // Primarily, foreground/background colors, and other control bytes like
@ -148,7 +148,7 @@ func StripRaw(text string) string {
text = reStripColor.ReplaceAllString(text, "") text = reStripColor.ReplaceAllString(text, "")
for _, code := range fmtCodes { for _, code := range fmtCodes {
text = strings.ReplaceAll(text, code, "") text = strings.Replace(text, code, "", -1)
} }
return text return text
@ -164,12 +164,12 @@ func StripRaw(text string) string {
// all ASCII printable chars. This function will NOT do that for // all ASCII printable chars. This function will NOT do that for
// compatibility reasons. // compatibility reasons.
// //
// channel = ( "#" / "+" / ( "!" channelid ) / "&" ) chanstring // channel = ( "#" / "+" / ( "!" channelid ) / "&" ) chanstring
// [ ":" chanstring ] // [ ":" chanstring ]
// chanstring = 0x01-0x07 / 0x08-0x09 / 0x0B-0x0C / 0x0E-0x1F / 0x21-0x2B // chanstring = 0x01-0x07 / 0x08-0x09 / 0x0B-0x0C / 0x0E-0x1F / 0x21-0x2B
// chanstring = / 0x2D-0x39 / 0x3B-0xFF // chanstring = / 0x2D-0x39 / 0x3B-0xFF
// ; any octet except NUL, BELL, CR, LF, " ", "," and ":" // ; any octet except NUL, BELL, CR, LF, " ", "," and ":"
// channelid = 5( 0x41-0x5A / digit ) ; 5( A-Z / 0-9 ) // channelid = 5( 0x41-0x5A / digit ) ; 5( A-Z / 0-9 )
func IsValidChannel(channel string) bool { func IsValidChannel(channel string) bool {
if len(channel) <= 1 || len(channel) > 50 { if len(channel) <= 1 || len(channel) > 50 {
return false return false
@ -214,12 +214,12 @@ func IsValidChannel(channel string) bool {
// IsValidNick validates an IRC nickname. Note that this does not validate // IsValidNick validates an IRC nickname. Note that this does not validate
// IRC nickname length. // IRC nickname length.
// //
// nickname = ( letter / special ) *8( letter / digit / special / "-" ) // nickname = ( letter / special ) *8( letter / digit / special / "-" )
// letter = 0x41-0x5A / 0x61-0x7A // letter = 0x41-0x5A / 0x61-0x7A
// digit = 0x30-0x39 // digit = 0x30-0x39
// special = 0x5B-0x60 / 0x7B-0x7D // special = 0x5B-0x60 / 0x7B-0x7D
func IsValidNick(nick string) bool { func IsValidNick(nick string) bool {
if nick == "" { if len(nick) <= 0 {
return false return false
} }
@ -253,11 +253,10 @@ func IsValidNick(nick string) bool {
// not be supported on all networks. Some limit this to only a single period. // not be supported on all networks. Some limit this to only a single period.
// //
// Per RFC: // Per RFC:
// // user = 1*( %x01-09 / %x0B-0C / %x0E-1F / %x21-3F / %x41-FF )
// user = 1*( %x01-09 / %x0B-0C / %x0E-1F / %x21-3F / %x41-FF ) // ; any octet except NUL, CR, LF, " " and "@"
// ; any octet except NUL, CR, LF, " " and "@"
func IsValidUser(name string) bool { func IsValidUser(name string) bool {
if name == "" { if len(name) <= 0 {
return false return false
} }
@ -325,7 +324,7 @@ func Glob(input, match string) bool {
if len(parts) == 1 { if len(parts) == 1 {
// No globs, test for equality. // No globs, test for equality.
return strings.EqualFold(input, match) return input == match
} }
leadingGlob, trailingGlob := strings.HasPrefix(match, globChar), strings.HasSuffix(match, globChar) leadingGlob, trailingGlob := strings.HasPrefix(match, globChar), strings.HasSuffix(match, globChar)
@ -351,3 +350,80 @@ func Glob(input, match string) bool {
// Check suffix last. // Check suffix last.
return trailingGlob || strings.HasSuffix(input, parts[last]) return trailingGlob || strings.HasSuffix(input, parts[last])
} }
const maxWordSplitLength = 30
// splitAtWord is a text splitter that takes into consideration a few things:
// * Ensuring the returned text is no longer than maxWidth.
// * Attempting to split at the closest word boundary, while still staying inside
// of the specific maxWidth.
// * if there is no good word boundry for longer words (or e.g. links, raw data, etc)
// that are above maxWordSplitLength characters, split the word into chunks to fit the
// maximum width.
func splitAtWord(input string, maxWidth int) (output []string) {
// TODO: breaks multi-spaces.
words := strings.Fields(input)
// TODO: don't split a url if there isn't enough space, if it can safely fit within
// the next line.
// TODO: also split on newline, if splitting is enabled? makes it easier to just
// pipe text in.
// TODO: if word contains a dash, and adding the word to the line causes an overflow,
// try to split on the dash?
// Increment maxWidth for calculations, because we always prefix with a space (then
// strip it before we return).
maxWidth++
var i, spaceRemaining int
var split string
setupNextSplit:
spaceRemaining = maxWidth
split = ""
beginLoop:
for i < len(words) {
// Last line was the perfect length, add to output and keep looping.
if spaceRemaining == 0 {
output = append(output, split[1:])
goto setupNextSplit
}
// Word makes the line too long.
if len(words[i])+1 > spaceRemaining {
// Is the word small enough to where we don't need to split it up?
if len(words[i]) < maxWordSplitLength && maxWidth >= maxWordSplitLength {
output = append(output, split[1:])
goto setupNextSplit
}
split += " " + words[i][0:spaceRemaining-1]
if len(words) == i {
words = append(words, words[i][spaceRemaining-1:])
} else {
words = append(words[:i+1], words[i:]...)
words[i+1] = words[i][spaceRemaining-1:]
words[i] = words[i][0 : spaceRemaining-1]
}
spaceRemaining -= 1 + len(words[i][0:spaceRemaining-1])
i++
goto beginLoop
}
split += " " + words[i]
spaceRemaining -= 1 + len(words[i])
i++
}
if len(split) > 0 {
output = append(output, split[1:])
}
// At least return some kind of string, rather than nil.
if len(output) == 0 {
output = append(output, "")
}
return output
}

@ -7,31 +7,38 @@ package girc
import ( import (
"strings" "strings"
"testing" "testing"
"unicode/utf8"
) )
func BenchmarkFormat(b *testing.B) { func BenchmarkFormat(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
Fmt("{red}test{c}") Fmt("{red}test{c}")
} }
return
} }
func BenchmarkFormatLong(b *testing.B) { func BenchmarkFormatLong(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
Fmt("{red}test {blue}2 {red}3 {brown} {italic}test{c}") Fmt("{red}test {blue}2 {red}3 {brown} {italic}test{c}")
} }
return
} }
func BenchmarkStripFormat(b *testing.B) { func BenchmarkStripFormat(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
TrimFmt("{red}test{c}") TrimFmt("{red}test{c}")
} }
return
} }
func BenchmarkStripFormatLong(b *testing.B) { func BenchmarkStripFormatLong(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
TrimFmt("{red}test {blue}2 {red}3 {brown} {italic}test{c}") TrimFmt("{red}test {blue}2 {red}3 {brown} {italic}test{c}")
} }
return
} }
func BenchmarkStripRaw(b *testing.B) { func BenchmarkStripRaw(b *testing.B) {
@ -39,6 +46,8 @@ func BenchmarkStripRaw(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
StripRaw(text) StripRaw(text)
} }
return
} }
func BenchmarkStripRawLong(b *testing.B) { func BenchmarkStripRawLong(b *testing.B) {
@ -46,287 +55,202 @@ func BenchmarkStripRawLong(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
StripRaw(text) StripRaw(text)
} }
}
var testsFormat = []struct { return
name string
test string
want string
}{
{name: "middle", test: "test{red}test{c}test", want: "test\x0304test\x03test"},
{name: "middle with bold", test: "test{red}{b}test{c}test", want: "test\x0304\x02test\x03test"},
{name: "start, end", test: "{red}test{c}", want: "\x0304test\x03"},
{name: "start, middle, end", test: "{red}te{red}st{c}", want: "\x0304te\x0304st\x03"},
{name: "partial", test: "{redtest{c}", want: "{redtest\x03"},
{name: "inside", test: "{re{c}d}test{c}", want: "{re\x03d}test\x03"},
{name: "nothing", test: "this is a test.", want: "this is a test."},
{name: "fg and bg", test: "{red,yellow}test{c}", want: "\x0304,08test\x03"},
{name: "just bg", test: "{,yellow}test{c}", want: "test\x03"},
{name: "just red", test: "{red}test", want: "\x0304test"},
{name: "just cyan", test: "{cyan}test", want: "\x0311test"},
}
func FuzzFormat(f *testing.F) {
for _, tc := range testsFormat {
f.Add(tc.test)
}
f.Fuzz(func(t *testing.T, orig string) {
got := Fmt(orig)
got2 := Fmt(got)
if utf8.ValidString(orig) {
if !utf8.ValidString(got) {
t.Errorf("produced invalid UTF-8 string %q", got)
}
if !utf8.ValidString(got2) {
t.Errorf("produced invalid UTF-8 string %q", got2)
}
}
})
} }
func TestFormat(t *testing.T) { func TestFormat(t *testing.T) {
for _, tt := range testsFormat { type args struct {
if got := Fmt(tt.test); got != tt.want { text string
t.Errorf("%s: Format(%q) = %q, want %q", tt.name, tt.test, got, tt.want)
}
}
}
var testsStripFormat = []struct {
name string
test string
want string
}{
{name: "start, end", test: "{red}test{c}", want: "test"},
{name: "start, middle, end", test: "{red}te{red}st{c}", want: "test"},
{name: "partial", test: "{redtest{c}", want: "{redtest"},
{name: "inside", test: "{re{c}d}test{c}", want: "{red}test"},
{name: "nothing", test: "this is a test.", want: "this is a test."},
}
func FuzzStripFormat(f *testing.F) {
for _, tc := range testsStripFormat {
f.Add(tc.test)
} }
f.Fuzz(func(t *testing.T, orig string) { tests := []struct {
got := TrimFmt(orig) name string
got2 := TrimFmt(got) args args
want string
}{
{name: "middle", args: args{text: "test{red}test{c}test"}, want: "test\x0304test\x03test"},
{name: "middle with bold", args: args{text: "test{red}{b}test{c}test"}, want: "test\x0304\x02test\x03test"},
{name: "start, end", args: args{text: "{red}test{c}"}, want: "\x0304test\x03"},
{name: "start, middle, end", args: args{text: "{red}te{red}st{c}"}, want: "\x0304te\x0304st\x03"},
{name: "partial", args: args{text: "{redtest{c}"}, want: "{redtest\x03"},
{name: "inside", args: args{text: "{re{c}d}test{c}"}, want: "{re\x03d}test\x03"},
{name: "nothing", args: args{text: "this is a test."}, want: "this is a test."},
{name: "fg and bg", args: args{text: "{red,yellow}test{c}"}, want: "\x0304,08test\x03"},
{name: "just bg", args: args{text: "{,yellow}test{c}"}, want: "test\x03"},
{name: "just red", args: args{text: "{red}test"}, want: "\x0304test"},
{name: "just cyan", args: args{text: "{cyan}test"}, want: "\x0311test"},
}
if utf8.ValidString(orig) { for _, tt := range tests {
if !utf8.ValidString(got) { if got := Fmt(tt.args.text); got != tt.want {
t.Errorf("produced invalid UTF-8 string %q", got) t.Errorf("%s: Format(%q) = %q, want %q", tt.name, tt.args.text, got, tt.want)
}
if !utf8.ValidString(got2) {
t.Errorf("produced invalid UTF-8 string %q", got2)
}
} }
}) }
} }
func TestStripFormat(t *testing.T) { func TestStripFormat(t *testing.T) {
for _, tt := range testsStripFormat { type args struct {
if got := TrimFmt(tt.test); got != tt.want { text string
t.Errorf("%s: StripFormat(%q) = %q, want %q", tt.name, tt.test, got, tt.want)
}
}
}
var testsStripRaw = []struct {
name string
test string // gets passed to Format() before sent
want string
}{
{name: "start, end", test: "{red}{b}test{c}", want: "test"},
{name: "start, end in numbers", test: "{red}1234{c}", want: "1234"},
{name: "start, middle, end", test: "{red}te{red}st{c}", want: "test"},
{name: "partial", test: "{redtest{c}", want: "{redtest"},
{name: "inside", test: "{re{c}d}test{c}", want: "{red}test"},
{name: "fg+bg colors start", test: "{red,yellow}test{c}", want: "test"},
{name: "fg+bg colors start in numbers", test: "{red,yellow}1234{c}", want: "1234"},
{name: "fg+bg colors end", test: "test{,yellow}", want: "test"},
{name: "bg colors start", test: "{,yellow}test{c}", want: "test"},
{name: "inside", test: "{re{c}d}test{c}", want: "{red}test"},
{name: "nothing", test: "this is a test.", want: "this is a test."},
}
func FuzzStripRaw(f *testing.F) {
for _, tc := range testsStripRaw {
f.Add(tc.test)
} }
f.Fuzz(func(t *testing.T, orig string) { tests := []struct {
got := StripRaw(orig) name string
got2 := StripRaw(got) args args
want string
}{
{name: "start, end", args: args{text: "{red}test{c}"}, want: "test"},
{name: "start, middle, end", args: args{text: "{red}te{red}st{c}"}, want: "test"},
{name: "partial", args: args{text: "{redtest{c}"}, want: "{redtest"},
{name: "inside", args: args{text: "{re{c}d}test{c}"}, want: "{red}test"},
{name: "nothing", args: args{text: "this is a test."}, want: "this is a test."},
}
if utf8.ValidString(orig) { for _, tt := range tests {
if !utf8.ValidString(got) { if got := TrimFmt(tt.args.text); got != tt.want {
t.Errorf("produced invalid UTF-8 string %q", got) t.Errorf("%s: StripFormat(%q) = %q, want %q", tt.name, tt.args.text, got, tt.want)
}
if !utf8.ValidString(got2) {
t.Errorf("produced invalid UTF-8 string %q", got2)
}
} }
}) }
} }
func TestStripRaw(t *testing.T) { func TestStripRaw(t *testing.T) {
for _, tt := range testsStripRaw { type args struct {
if got := StripRaw(Fmt(tt.test)); got != tt.want { text string
t.Fatalf("%s: StripRaw(%q) = %q, want %q", tt.name, tt.test, got, tt.want) }
tests := []struct {
name string
args args // gets passed to Format() before sent
want string
}{
{name: "start, end", args: args{text: "{red}{b}test{c}"}, want: "test"},
{name: "start, end in numbers", args: args{text: "{red}1234{c}"}, want: "1234"},
{name: "start, middle, end", args: args{text: "{red}te{red}st{c}"}, want: "test"},
{name: "partial", args: args{text: "{redtest{c}"}, want: "{redtest"},
{name: "inside", args: args{text: "{re{c}d}test{c}"}, want: "{red}test"},
{name: "fg+bg colors start", args: args{text: "{red,yellow}test{c}"}, want: "test"},
{name: "fg+bg colors start in numbers", args: args{text: "{red,yellow}1234{c}"}, want: "1234"},
{name: "fg+bg colors end", args: args{text: "test{,yellow}"}, want: "test"},
{name: "bg colors start", args: args{text: "{,yellow}test{c}"}, want: "test"},
{name: "inside", args: args{text: "{re{c}d}test{c}"}, want: "{red}test"},
{name: "nothing", args: args{text: "this is a test."}, want: "this is a test."},
}
for _, tt := range tests {
if got := StripRaw(Fmt(tt.args.text)); got != tt.want {
t.Fatalf("%s: StripRaw(%q) = %q, want %q", tt.name, tt.args.text, got, tt.want)
} }
} }
} }
var testsValidNick = []struct {
name string
test string
want bool
}{
{name: "normal", test: "test", want: true},
{name: "empty", test: "", want: false},
{name: "hyphen and special", test: "test[-]", want: true},
{name: "invalid middle", test: "test!test", want: false},
{name: "invalid dot middle", test: "test.test", want: false},
{name: "end", test: "test!", want: false},
{name: "invalid start", test: "!test", want: false},
{name: "backslash and numeric", test: "test[\\0", want: true},
{name: "long", test: "test123456789AZBKASDLASMDLKM", want: true},
{name: "index 0 dash", test: "-test", want: false},
{name: "index 0 numeric", test: "0test", want: false},
{name: "RFC1459 non-lowercase-converted", test: "test^", want: true},
{name: "RFC1459 non-lowercase-converted", test: "test~", want: false},
}
func FuzzValidNick(f *testing.F) {
for _, tc := range testsValidNick {
f.Add(tc.test)
}
f.Fuzz(func(t *testing.T, orig string) {
_ = IsValidNick(orig)
})
}
func TestIsValidNick(t *testing.T) { func TestIsValidNick(t *testing.T) {
for _, tt := range testsValidNick { type args struct {
if got := IsValidNick(tt.test); got != tt.want { nick string
t.Errorf("%s: IsValidNick(%q) = %v, want %v", tt.name, tt.test, got, tt.want) }
tests := []struct {
name string
args args
want bool
}{
{name: "normal", args: args{nick: "test"}, want: true},
{name: "empty", args: args{nick: ""}, want: false},
{name: "hyphen and special", args: args{nick: "test[-]"}, want: true},
{name: "invalid middle", args: args{nick: "test!test"}, want: false},
{name: "invalid dot middle", args: args{nick: "test.test"}, want: false},
{name: "end", args: args{nick: "test!"}, want: false},
{name: "invalid start", args: args{nick: "!test"}, want: false},
{name: "backslash and numeric", args: args{nick: "test[\\0"}, want: true},
{name: "long", args: args{nick: "test123456789AZBKASDLASMDLKM"}, want: true},
{name: "index 0 dash", args: args{nick: "-test"}, want: false},
{name: "index 0 numeric", args: args{nick: "0test"}, want: false},
{name: "RFC1459 non-lowercase-converted", args: args{nick: "test^"}, want: true},
{name: "RFC1459 non-lowercase-converted", args: args{nick: "test~"}, want: false},
}
for _, tt := range tests {
if got := IsValidNick(tt.args.nick); got != tt.want {
t.Errorf("%s: IsValidNick(%q) = %v, want %v", tt.name, tt.args.nick, got, tt.want)
} }
} }
} }
var testsValidChannel = []struct {
name string
test string
want bool
}{
{name: "valid channel", test: "#valid", want: true},
{name: "invalid channel comma", test: "#invalid,", want: false},
{name: "invalid channel space", test: "#inva lid", want: false},
{name: "valid channel with numerics", test: "#1valid0", want: true},
{name: "valid channel with special", test: "#valid[]test", want: true},
{name: "valid channel with special", test: "#[]valid[]test[]", want: true},
{name: "just hash", test: "#", want: false},
{name: "empty", test: "", want: false},
{name: "invalid prefix", test: "$invalid", want: false},
{name: "too long", test: "#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", want: false},
{name: "valid id prefix", test: "!12345test", want: true},
{name: "invalid id length", test: "!1234", want: false},
{name: "invalid id length", test: "!12345", want: false},
{name: "invalid id prefix", test: "!test1invalid", want: false},
}
func FuzzValidChannel(f *testing.F) {
for _, tc := range testsValidChannel {
f.Add(tc.test)
}
f.Fuzz(func(t *testing.T, orig string) {
_ = IsValidChannel(orig)
})
}
func TestIsValidChannel(t *testing.T) { func TestIsValidChannel(t *testing.T) {
for _, tt := range testsValidChannel { type args struct {
if got := IsValidChannel(tt.test); got != tt.want { channel string
t.Errorf("%s: IsValidChannel(%q) = %v, want %v", tt.name, tt.test, got, tt.want) }
tests := []struct {
name string
args args
want bool
}{
{name: "valid channel", args: args{channel: "#valid"}, want: true},
{name: "invalid channel comma", args: args{channel: "#invalid,"}, want: false},
{name: "invalid channel space", args: args{channel: "#inva lid"}, want: false},
{name: "valid channel with numerics", args: args{channel: "#1valid0"}, want: true},
{name: "valid channel with special", args: args{channel: "#valid[]test"}, want: true},
{name: "valid channel with special", args: args{channel: "#[]valid[]test[]"}, want: true},
{name: "just hash", args: args{channel: "#"}, want: false},
{name: "empty", args: args{channel: ""}, want: false},
{name: "invalid prefix", args: args{channel: "$invalid"}, want: false},
{name: "too long", args: args{channel: "#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, want: false},
{name: "valid id prefix", args: args{channel: "!12345test"}, want: true},
{name: "invalid id length", args: args{channel: "!1234"}, want: false},
{name: "invalid id length", args: args{channel: "!12345"}, want: false},
{name: "invalid id prefix", args: args{channel: "!test1invalid"}, want: false},
}
for _, tt := range tests {
if got := IsValidChannel(tt.args.channel); got != tt.want {
t.Errorf("%s: IsValidChannel(%q) = %v, want %v", tt.name, tt.args.channel, got, tt.want)
} }
} }
} }
var testsValidUser = []struct {
name string
test string
want bool
}{
{name: "user without ident server", test: "~test", want: true},
{name: "user with ident server", test: "test", want: true},
{name: "non-alphanumeric first index", test: "-test", want: false},
{name: "non-alphanumeric first index", test: "[test]", want: false},
{name: "numeric first index", test: "0test", want: true},
{name: "blank", test: "", want: false},
{name: "just tilde", test: "~", want: false},
{name: "special chars", test: "test-----", want: true},
{name: "special chars", test: "test-[]-", want: true},
{name: "special chars, invalid after first index", test: "t!--", want: false},
}
func FuzzValidUser(f *testing.F) {
for _, tc := range testsValidUser {
f.Add(tc.test)
}
f.Fuzz(func(t *testing.T, orig string) {
_ = IsValidUser(orig)
})
}
func TestIsValidUser(t *testing.T) { func TestIsValidUser(t *testing.T) {
for _, tt := range testsValidUser { type args struct {
if got := IsValidUser(tt.test); got != tt.want { name string
t.Errorf("%s: IsValidUser(%q) = %v, want %v", tt.name, tt.test, got, tt.want) }
tests := []struct {
name string
args args
want bool
}{
{name: "user without ident server", args: args{name: "~test"}, want: true},
{name: "user with ident server", args: args{name: "test"}, want: true},
{name: "non-alphanumeric first index", args: args{name: "-test"}, want: false},
{name: "non-alphanumeric first index", args: args{name: "[test]"}, want: false},
{name: "numeric first index", args: args{name: "0test"}, want: true},
{name: "blank", args: args{name: ""}, want: false},
{name: "just tilde", args: args{name: "~"}, want: false},
{name: "special chars", args: args{name: "test-----"}, want: true},
{name: "special chars", args: args{name: "test-[]-"}, want: true},
{name: "special chars, invalid after first index", args: args{name: "t!--"}, want: false},
}
for _, tt := range tests {
if got := IsValidUser(tt.args.name); got != tt.want {
t.Errorf("%s: IsValidUser(%q) = %v, want %v", tt.name, tt.args.name, got, tt.want)
} }
} }
} }
var testsToRFC1459 = []struct {
in string
want string
}{
{"", ""},
{"a", "a"},
{"abcd", "abcd"},
{"AbcD", "abcd"},
{"!@#$%^&*()_+-=", "!@#$%~&*()_+-="},
{"Abcd[]", "abcd{}"},
}
func FuzzToRFC1459(f *testing.F) {
for _, tc := range testsToRFC1459 {
f.Add(tc.in)
}
f.Fuzz(func(t *testing.T, orig string) {
got := ToRFC1459(orig)
if utf8.ValidString(orig) && !utf8.ValidString(got) {
t.Errorf("produced invalid UTF-8 string %q", got)
}
})
}
func TestToRFC1459(t *testing.T) { func TestToRFC1459(t *testing.T) {
for _, tt := range testsToRFC1459 { cases := []struct {
in string
want string
}{
{"", ""},
{"a", "a"},
{"abcd", "abcd"},
{"AbcD", "abcd"},
{"!@#$%^&*()_+-=", "!@#$%~&*()_+-="},
{"Abcd[]", "abcd{}"},
}
for _, tt := range cases {
if got := ToRFC1459(tt.in); got != tt.want { if got := ToRFC1459(tt.in); got != tt.want {
t.Errorf("ToRFC1459() = %q, want %q", got, tt.want) t.Errorf("ToRFC1459() = %q, want %q", got, tt.want)
} }
} }
return
} }
func BenchmarkGlob(b *testing.B) { func BenchmarkGlob(b *testing.B) {
@ -335,23 +259,31 @@ func BenchmarkGlob(b *testing.B) {
b.Fatalf("should match") b.Fatalf("should match")
} }
} }
return
} }
func testGlobMatch(t *testing.T, subj, pattern string) { func testGlobMatch(t *testing.T, subj, pattern string) {
if !Glob(subj, pattern) { if !Glob(subj, pattern) {
t.Fatalf("'%s' should match '%s'", pattern, subj) t.Fatalf("'%s' should match '%s'", pattern, subj)
} }
return
} }
func testGlobNoMatch(t *testing.T, subj, pattern string) { func testGlobNoMatch(t *testing.T, subj, pattern string) {
if Glob(subj, pattern) { if Glob(subj, pattern) {
t.Fatalf("'%s' should not match '%s'", pattern, subj) t.Fatalf("'%s' should not match '%s'", pattern, subj)
} }
return
} }
func TestEmptyPattern(t *testing.T) { func TestEmptyPattern(t *testing.T) {
testGlobMatch(t, "", "") testGlobMatch(t, "", "")
testGlobNoMatch(t, "test", "") testGlobNoMatch(t, "test", "")
return
} }
func TestEmptySubject(t *testing.T) { func TestEmptySubject(t *testing.T) {
@ -392,43 +324,37 @@ func TestEmptySubject(t *testing.T) {
for _, pattern := range cases { for _, pattern := range cases {
testGlobNoMatch(t, pattern, "") testGlobNoMatch(t, pattern, "")
} }
return
} }
func TestPatternWithoutGlobs(t *testing.T) { func TestPatternWithoutGlobs(t *testing.T) {
testGlobMatch(t, "test", "test") testGlobMatch(t, "test", "test")
}
var testsGlob = []string{ return
"*test", // Leading.
"this*", // Trailing.
"this*test", // Middle.
"*is *", // String in between two.
"*is*a*", // Lots.
"**test**", // Double glob characters.
"**is**a***test*", // Varying number.
"* *", // White space between.
"*", // Lone.
"**********", // Nothing but globs.
"*Ѿ*", // Unicode.
"*is a ϗѾ *", // Mixed ASCII/unicode.
}
func FuzzGlob(f *testing.F) {
for _, tc := range testsGlob {
f.Add(tc, tc)
}
f.Fuzz(func(t *testing.T, orig, orig2 string) {
_ = Glob(orig, orig2)
})
} }
func TestGlob(t *testing.T) { func TestGlob(t *testing.T) {
for _, pattern := range testsGlob { cases := []string{
"*test", // Leading.
"this*", // Trailing.
"this*test", // Middle.
"*is *", // String in between two.
"*is*a*", // Lots.
"**test**", // Double glob characters.
"**is**a***test*", // Varying number.
"* *", // White space between.
"*", // Lone.
"**********", // Nothing but globs.
"*Ѿ*", // Unicode.
"*is a ϗѾ *", // Mixed ASCII/unicode.
}
for _, pattern := range cases {
testGlobMatch(t, "this is a ϗѾ test", pattern) testGlobMatch(t, "this is a ϗѾ test", pattern)
} }
cases := []string{ cases = []string{
"test*", // Implicit substring match. "test*", // Implicit substring match.
"*is", // Partial match. "*is", // Partial match.
"*no*", // Globs without a match between them. "*no*", // Globs without a match between them.
@ -442,4 +368,6 @@ func TestGlob(t *testing.T) {
for _, pattern := range cases { for _, pattern := range cases {
testGlobNoMatch(t, "this is a test", pattern) testGlobNoMatch(t, "this is a test", pattern)
} }
return
} }

10
go.mod

@ -1,9 +1,3 @@
module github.com/yunginnanet/girc-atomic module github.com/lrstanley/girc
go 1.20 go 1.12
require (
git.tcp.direct/kayos/common v0.8.1
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/orcaman/concurrent-map/v2 v2.0.1
)

19
go.sum

@ -1,19 +0,0 @@
git.tcp.direct/kayos/common v0.8.1 h1:gxcCaa7QlQzkvBPzcwoVyP89mexrxKvnmlnvh4PGu4o=
git.tcp.direct/kayos/common v0.8.1/go.mod h1:r7lZuKTQz0uf/jNm61sz1XaMgK/RYRr7wtqr/cNYd8o=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c=
github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -12,31 +12,24 @@ import (
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
cmap "github.com/orcaman/concurrent-map/v2"
) )
// RunHandlers manually runs handlers for a given event. // RunHandlers manually runs handlers for a given event.
func (c *Client) RunHandlers(event *Event) { func (c *Client) RunHandlers(event *Event) {
if event == nil { if event == nil {
c.debug.Print("nil event")
return return
} }
s := strs.Get()
// Log the event. // Log the event.
s.MustWriteString("< ") prefix := "< "
if event.Echo { if event.Echo {
s.MustWriteString("[echo-message] ") prefix += "[echo-message] "
} }
s.MustWriteString(event.String()) c.debug.Print(prefix + StripRaw(event.String()))
c.debug.Print(s.String())
strs.MustPut(s)
if c.Config.Out != nil { if c.Config.Out != nil {
if pretty, ok := event.Pretty(); ok { if pretty, ok := event.Pretty(); ok {
_, _ = fmt.Fprintln(c.Config.Out, StripRaw(pretty)) fmt.Fprintln(c.Config.Out, StripRaw(pretty))
} }
} }
@ -48,16 +41,15 @@ func (c *Client) RunHandlers(event *Event) {
} }
c.Handlers.exec(ALL_EVENTS, false, c, event.Copy()) c.Handlers.exec(ALL_EVENTS, false, c, event.Copy())
if !event.Echo { if !event.Echo {
c.Handlers.exec(event.Command, false, c, event.Copy()) c.Handlers.exec(event.Command, false, c, event.Copy())
} }
// Check if it's a CTCP. // Check if it's a CTCP.
if ctcp := DecodeCTCP(event.Copy()); ctcp != nil { if ctcp := DecodeCTCP(event.Copy()); ctcp != nil {
// Execute it. // Execute it.
c.CTCP.call(c, ctcp) c.CTCP.call(c, ctcp)
} }
} }
// Handler is lower level implementation of a handler. See // Handler is lower level implementation of a handler. See
@ -75,87 +67,29 @@ func (f HandlerFunc) Execute(client *Client, event Event) {
f(client, event) f(client, event)
} }
// nestedHandlers consists of a nested concurrent map.
//
// ( cmap.ConcurrentMap[command]cmap.ConcurrentMap[cuid]Handler )
//
// command and cuid are both strings.
type nestedHandlers struct {
cm cmap.ConcurrentMap[string, cmap.ConcurrentMap[string, Handler]]
}
type handlerTuple struct {
cuid string
handler Handler
}
func newNestedHandlers() *nestedHandlers {
return &nestedHandlers{cm: cmap.New[cmap.ConcurrentMap[string, Handler]]()}
}
func (nest *nestedHandlers) len() (total int) {
for hndlrs := range nest.cm.IterBuffered() {
total += len(hndlrs.Val.Keys())
}
return
}
func (nest *nestedHandlers) lenFor(cmd string) (total int) {
cmd = strings.ToUpper(cmd)
hndlrs, ok := nest.cm.Get(cmd)
if !ok {
return 0
}
return hndlrs.Count()
}
func (nest *nestedHandlers) getAllHandlersFor(s string) (handlers chan handlerTuple, ok bool) {
var h cmap.ConcurrentMap[string, Handler]
h, ok = nest.cm.Get(s)
if !ok {
return
}
handlers = make(chan handlerTuple)
go func() {
for hi := range h.IterBuffered() {
ht := handlerTuple{
hi.Key,
hi.Val,
}
handlers <- ht
}
}()
return
}
// Caller manages internal and external (user facing) handlers. // Caller manages internal and external (user facing) handlers.
type Caller struct { type Caller struct {
// mu is the mutex that should be used when accessing handlers. // mu is the mutex that should be used when accessing handlers.
mu *sync.RWMutex mu sync.RWMutex
parent *Client
// external/internal keys are of structure: // external/internal keys are of structure:
// map[COMMAND][CUID]Handler // map[COMMAND][CUID]Handler
// Also of note: "COMMAND" should always be uppercase for normalization. // Also of note: "COMMAND" should always be uppercase for normalization.
// external is a map of user facing handlers. // external is a map of user facing handlers.
external *nestedHandlers external map[string]map[string]Handler
// external map[string]map[string]Handler
// internal is a map of internally used handlers for the client. // internal is a map of internally used handlers for the client.
internal *nestedHandlers internal map[string]map[string]Handler
// debug is the clients logger used for debugging. // debug is the clients logger used for debugging.
debug *log.Logger debug *log.Logger
} }
// newCaller creates and initializes a new handler. // newCaller creates and initializes a new handler.
func newCaller(parent *Client, debugOut *log.Logger) *Caller { func newCaller(debugOut *log.Logger) *Caller {
c := &Caller{ c := &Caller{
external: newNestedHandlers(), external: map[string]map[string]Handler{},
internal: newNestedHandlers(), internal: map[string]map[string]Handler{},
debug: debugOut, debug: debugOut,
parent: parent,
mu: &sync.RWMutex{},
} }
return c return c
@ -163,18 +97,45 @@ func newCaller(parent *Client, debugOut *log.Logger) *Caller {
// Len returns the total amount of user-entered registered handlers. // Len returns the total amount of user-entered registered handlers.
func (c *Caller) Len() int { func (c *Caller) Len() int {
return c.external.len() var total int
c.mu.RLock()
for command := range c.external {
total += len(c.external[command])
}
c.mu.RUnlock()
return total
} }
// Count is much like Caller.Len(), however it counts the number of // Count is much like Caller.Len(), however it counts the number of
// registered handlers for a given command. // registered handlers for a given command.
func (c *Caller) Count(cmd string) int { func (c *Caller) Count(cmd string) int {
var total int
cmd = strings.ToUpper(cmd) cmd = strings.ToUpper(cmd)
return c.external.lenFor(cmd)
c.mu.RLock()
for command := range c.external {
if command == cmd {
total += len(c.external[command])
}
}
c.mu.RUnlock()
return total
} }
func (c *Caller) String() string { func (c *Caller) String() string {
return fmt.Sprintf("<Caller external:%d internal:%d>", c.Len(), c.internal.len()) var total int
c.mu.RLock()
for cmd := range c.internal {
total += len(c.internal[cmd])
}
c.mu.RUnlock()
return fmt.Sprintf("<Caller external:%d internal:%d>", c.Len(), total)
} }
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
@ -212,90 +173,88 @@ type execStack struct {
// Please note that there is no specific order/priority for which the handlers // Please note that there is no specific order/priority for which the handlers
// are executed. // are executed.
func (c *Caller) exec(command string, bg bool, client *Client, event *Event) { func (c *Caller) exec(command string, bg bool, client *Client, event *Event) {
c.mu.RLock()
defer c.mu.RUnlock()
// Build a stack of handlers which can be executed concurrently. // Build a stack of handlers which can be executed concurrently.
var stack []execStack var stack []execStack
c.mu.RLock()
// Get internal handlers first. // Get internal handlers first.
hmap, iok := c.internal.cm.Get(command) if _, ok := c.internal[command]; ok {
if iok { for cuid := range c.internal[command] {
for assigned := range hmap.IterBuffered() {
cuid := assigned.Key
if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) { if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) {
continue continue
} }
hndlr, _ := hmap.Get(cuid)
stack = append(stack, execStack{hndlr, cuid}) stack = append(stack, execStack{c.internal[command][cuid], cuid})
} }
} }
// Then external handlers. // Then external handlers.
hmap, eok := c.external.cm.Get(command) if _, ok := c.external[command]; ok {
if eok { for cuid := range c.external[command] {
for _, cuid := range hmap.Keys() {
if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) { if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) {
continue continue
} }
hndlr, _ := hmap.Get(cuid)
stack = append(stack, execStack{hndlr, cuid}) stack = append(stack, execStack{c.external[command][cuid], cuid})
} }
} }
c.mu.RUnlock()
// Run all handlers concurrently across the same event. This should // Run all handlers concurrently across the same event. This should
// still help prevent mis-ordered events, while speeding up the // still help prevent mis-ordered events, while speeding up the
// execution speed. // execution speed.
var working int32 var wg sync.WaitGroup
atomic.AddInt32(&working, int32(len(stack))) wg.Add(len(stack))
// c.debug.Printf("starting %d jobs", atomic.LoadInt32(&working))
for i := 0; i < len(stack); i++ { for i := 0; i < len(stack); i++ {
go func(index int) { go func(index int) {
// c.debug.Printf("(%s) [%d/%d] exec %s => %s", c.parent.Config.Nick, defer wg.Done()
// index+1, len(stack), stack[index].cuid, command) c.debug.Printf("[%d/%d] exec %s => %s", index+1, len(stack), stack[index].cuid, command)
// start := time.Now() start := time.Now()
if bg { if bg {
go func() { go func() {
defer atomic.AddInt32(&working, -1)
if client.Config.RecoverFunc != nil { if client.Config.RecoverFunc != nil {
defer recoverHandlerPanic(client, event, stack[index].cuid, 3) defer recoverHandlerPanic(client, event, stack[index].cuid, 3)
} }
stack[index].Handler.Execute(client, *event)
// c.debug.Printf("(%s) done %s == %s", c.parent.Config.Nick, stack[index].Execute(client, *event)
// stack[index].cuid, time.Since(start)) c.debug.Printf("[%d/%d] done %s == %s", index+1, len(stack), stack[index].cuid, time.Since(start))
}() }()
return return
} }
defer atomic.AddInt32(&working, -1)
if client.Config.RecoverFunc != nil { if client.Config.RecoverFunc != nil {
defer recoverHandlerPanic(client, event, stack[index].cuid, 3) defer recoverHandlerPanic(client, event, stack[index].cuid, 3)
} }
stack[index].Handler.Execute(client, *event) stack[index].Execute(client, *event)
// c.debug.Printf("(%s) done %s == %s", c.parent.Config.Nick, stack[index].cuid, time.Since(start)) c.debug.Printf("[%d/%d] done %s == %s", index+1, len(stack), stack[index].cuid, time.Since(start))
}(i) }(i)
// new events from becoming ahead of ol1 handlers.
// c.debug.Printf("(%s) atomic.CompareAndSwap: %d jobs running", c.parent.Config.Nick, atomic.LoadInt32(&working))
if atomic.CompareAndSwapInt32(&working, 0, -1) {
// c.debug.Printf("(%s) exec stack completed", c.parent.Config.Nick)
return
}
} }
// Wait for all of the handlers to complete. Not doing this may cause
// new events from becoming ahead of older handlers.
wg.Wait()
} }
// ClearAll clears all external handlers currently setup within the client. // ClearAll clears all external handlers currently setup within the client.
// This ignores internal handlers. // This ignores internal handlers.
func (c *Caller) ClearAll() { func (c *Caller) ClearAll() {
c.external.cm.Clear() c.mu.Lock()
c.external = map[string]map[string]Handler{}
c.mu.Unlock()
c.debug.Print("cleared all external handlers") c.debug.Print("cleared all external handlers")
} }
// clearInternal clears all internal handlers currently setup within the // clearInternal clears all internal handlers currently setup within the
// client. // client.
func (c *Caller) clearInternal() { func (c *Caller) clearInternal() {
c.internal.cm.Clear() c.mu.Lock()
c.internal = map[string]map[string]Handler{}
c.mu.Unlock()
c.debug.Print("cleared all internal handlers") c.debug.Print("cleared all internal handlers")
} }
@ -303,39 +262,46 @@ func (c *Caller) clearInternal() {
// This ignores internal handlers. // This ignores internal handlers.
func (c *Caller) Clear(cmd string) { func (c *Caller) Clear(cmd string) {
cmd = strings.ToUpper(cmd) cmd = strings.ToUpper(cmd)
c.external.cm.Remove(cmd)
c.debug.Printf("(%s) cleared external handlers for %s", c.parent.Config.Nick, cmd) c.mu.Lock()
if _, ok := c.external[cmd]; ok {
delete(c.external, cmd)
}
c.mu.Unlock()
c.debug.Printf("cleared external handlers for %s", cmd)
} }
// Remove removes the handler with cuid from the handler stack. success // Remove removes the handler with cuid from the handler stack. success
// indicates that it existed, and has been removed. If not success, it // indicates that it existed, and has been removed. If not success, it
// wasn't a registered handler. // wasn't a registered handler.
func (c *Caller) Remove(cuid string) (success bool) { func (c *Caller) Remove(cuid string) (success bool) {
c.remove(cuid) c.mu.Lock()
return true success = c.remove(cuid)
c.mu.Unlock()
return success
} }
// remove is much like Remove, however is NOT concurrency safe. Lock Caller.mu // remove is much like Remove, however is NOT concurrency safe. Lock Caller.mu
// on your own. // on your own.
func (c *Caller) remove(cuid string) (ok bool) { func (c *Caller) remove(cuid string) (success bool) {
cmd, uid := c.cuidToID(cuid) cmd, uid := c.cuidToID(cuid)
if len(cmd) == 0 || len(uid) == 0 { if len(cmd) == 0 || len(uid) == 0 {
return false return false
} }
// Check if the irc command/event has any handlers on it. // Check if the irc command/event has any handlers on it.
var hs cmap.ConcurrentMap[string, Handler] if _, ok := c.external[cmd]; !ok {
hs, ok = c.external.cm.Get(cmd) return false
if !ok {
return
} }
// Check to see if it's actually a registered handler. // Check to see if it's actually a registered handler.
if _, ok = hs.Get(cuid); !ok { if _, ok := c.external[cmd][uid]; !ok {
return return false
} }
hs.Remove(uid) delete(c.external[cmd], uid)
c.debug.Printf("removed handler %s", cuid) c.debug.Printf("removed handler %s", cuid)
// Assume success. // Assume success.
@ -346,8 +312,9 @@ func (c *Caller) remove(cuid string) (ok bool) {
// the Caller mutex. // the Caller mutex.
func (c *Caller) sregister(internal, bg bool, cmd string, handler Handler) (cuid string) { func (c *Caller) sregister(internal, bg bool, cmd string, handler Handler) (cuid string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock()
cuid = c.register(internal, bg, cmd, handler) cuid = c.register(internal, bg, cmd, handler)
c.mu.Unlock()
return cuid return cuid
} }
@ -364,29 +331,22 @@ func (c *Caller) register(internal, bg bool, cmd string, handler Handler) (cuid
cuid += ":bg" cuid += ":bg"
} }
var (
parent *nestedHandlers
chandlers cmap.ConcurrentMap[string, Handler]
ok bool
)
if internal { if internal {
parent = c.internal if _, ok := c.internal[cmd]; !ok {
c.internal[cmd] = map[string]Handler{}
}
c.internal[cmd][uid] = handler
} else { } else {
parent = c.external if _, ok := c.external[cmd]; !ok {
c.external[cmd] = map[string]Handler{}
}
c.external[cmd][uid] = handler
} }
chandlers, ok = parent.cm.Get(cmd) _, file, line, _ := runtime.Caller(3)
if !ok {
chandlers = cmap.New[Handler]()
}
chandlers.Set(uid, handler)
parent.cm.Set(cmd, chandlers)
_, file, line, _ := runtime.Caller(2)
c.debug.Printf("reg %q => %s [int:%t bg:%t] %s:%d", uid, cmd, internal, bg, file, line) c.debug.Printf("reg %q => %s [int:%t bg:%t] %s:%d", uid, cmd, internal, bg, file, line)
return cuid return cuid
@ -498,6 +458,7 @@ func recoverHandlerPanic(client *Client, event *Event, id string, skip int) {
} }
client.Config.RecoverFunc(client, err) client.Config.RecoverFunc(client, err)
return
} }
// HandlerError is the error returned when a panic is intentionally recovered // HandlerError is the error returned when a panic is intentionally recovered
@ -534,8 +495,6 @@ func (e *HandlerError) String() string {
// DefaultRecoverHandler can be used with Config.RecoverFunc as a default // DefaultRecoverHandler can be used with Config.RecoverFunc as a default
// catch-all for panics. This will log the error, and the call trace to the // catch-all for panics. This will log the error, and the call trace to the
// debug log (see Config.Debug), or os.Stdout if Config.Debug is unset. // debug log (see Config.Debug), or os.Stdout if Config.Debug is unset.
//
//goland:noinspection GoUnusedExportedFunction
func DefaultRecoverHandler(client *Client, err *HandlerError) { func DefaultRecoverHandler(client *Client, err *HandlerError) {
if client.Config.Debug == nil { if client.Config.Debug == nil {
fmt.Println(err.Error()) fmt.Println(err.Error())

@ -1,30 +0,0 @@
package girc
import (
"log"
"sync"
"testing"
)
func TestCaller_AddHandler(t *testing.T) {
var passChan = make(chan struct{})
nullClient := &Client{mu: sync.RWMutex{}}
c := newCaller(nullClient, log.Default())
c.AddBg("PRIVMSG", func(c *Client, e Event) {
passChan <- struct{}{}
})
go func() {
c.exec("PRIVMSG", true, nullClient, &Event{})
}()
if c.external.lenFor("JONES") != 0 {
t.Fatalf("wanted %d handlers, got %d", 0, c.internal.lenFor("JONES"))
}
if c.external.lenFor("PRIVMSG") != 1 {
t.Fatalf("wanted %d handlers, got %d", 1, c.external.lenFor("PRIVMSG"))
}
<-passChan
}

@ -7,8 +7,7 @@ package girc
import ( import (
"encoding/json" "encoding/json"
"strings" "strings"
"sync"
cmap "github.com/orcaman/concurrent-map/v2"
) )
// CMode represents a single step of a given mode change. // CMode represents a single step of a given mode change.
@ -119,14 +118,13 @@ func (c *CModes) Get(mode string) (args string, ok bool) {
} }
// hasArg checks to see if the mode supports arguments. What ones support this?: // hasArg checks to see if the mode supports arguments. What ones support this?:
// // A = Mode that adds or removes a nick or address to a list. Always has a parameter.
// A = Mode that adds or removes a nick or address to a list. Always has a parameter. // B = Mode that changes a setting and always has a parameter.
// B = Mode that changes a setting and always has a parameter. // C = Mode that changes a setting and only has a parameter when set.
// C = Mode that changes a setting and only has a parameter when set. // D = Mode that changes a setting and never has a parameter.
// D = Mode that changes a setting and never has a parameter. // Note: Modes of type A return the list when there is no parameter present.
// Note: Modes of type A return the list when there is no parameter present. // Note: Some clients assumes that any mode not listed is of type D.
// Note: Some clients assumes that any mode not listed is of type D. // Note: Modes in PREFIX are not listed but could be considered type B.
// Note: Modes in PREFIX are not listed but could be considered type B.
func (c *CModes) hasArg(set bool, mode byte) (hasArgs, isSetting bool) { func (c *CModes) hasArg(set bool, mode byte) (hasArgs, isSetting bool) {
if len(c.raw) < 1 { if len(c.raw) < 1 {
return false, true return false, true
@ -159,7 +157,7 @@ func (c *CModes) hasArg(set bool, mode byte) (hasArgs, isSetting bool) {
// For example, the latter would mean applying an incoming MODE with the modes // For example, the latter would mean applying an incoming MODE with the modes
// stored for a channel. // stored for a channel.
func (c *CModes) Apply(modes []CMode) { func (c *CModes) Apply(modes []CMode) {
var newcm []CMode var new []CMode
for j := 0; j < len(c.modes); j++ { for j := 0; j < len(c.modes); j++ {
isin := false isin := false
@ -168,14 +166,14 @@ func (c *CModes) Apply(modes []CMode) {
continue continue
} }
if c.modes[j].name == modes[i].name && modes[i].add { if c.modes[j].name == modes[i].name && modes[i].add {
newcm = append(newcm, modes[i]) new = append(new, modes[i])
isin = true isin = true
break break
} }
} }
if !isin { if !isin {
newcm = append(newcm, c.modes[j]) new = append(new, c.modes[j])
} }
} }
@ -185,19 +183,19 @@ func (c *CModes) Apply(modes []CMode) {
} }
isin := false isin := false
for j := 0; j < len(newcm); j++ { for j := 0; j < len(new); j++ {
if modes[i].name == newcm[j].name { if modes[i].name == new[j].name {
isin = true isin = true
break break
} }
} }
if !isin { if !isin {
newcm = append(newcm, modes[i]) new = append(new, modes[i])
} }
} }
c.modes = newcm c.modes = new
} }
// Parse parses a set of flags and args, returning the necessary list of // Parse parses a set of flags and args, returning the necessary list of
@ -335,9 +333,10 @@ func handleMODE(c *Client, e Event) {
return return
} }
c.state.RLock()
channel := c.state.lookupChannel(e.Params[0]) channel := c.state.lookupChannel(e.Params[0])
if channel == nil { if channel == nil {
c.state.RUnlock()
return return
} }
@ -364,15 +363,17 @@ func handleMODE(c *Client, e Event) {
} }
} }
c.state.RUnlock()
c.state.notify(c, UPDATE_STATE) c.state.notify(c, UPDATE_STATE)
} }
// chanModes returns the ISUPPORT list of server-supported channel modes, // chanModes returns the ISUPPORT list of server-supported channel modes,
// alternatively falling back to ModeDefaults. // alternatively falling back to ModeDefaults.
func (s *state) chanModes() string { func (s *state) chanModes() string {
if validmodes, ok := s.serverOptions.Get("CHANMODES"); ok && IsValidChannelMode(validmodes) { if modes, ok := s.serverOptions["CHANMODES"]; ok && IsValidChannelMode(modes) {
return validmodes return modes
} }
return ModeDefaults return ModeDefaults
} }
@ -380,47 +381,64 @@ func (s *state) chanModes() string {
// This includes mode characters, as well as user prefix symbols. Falls back // This includes mode characters, as well as user prefix symbols. Falls back
// to DefaultPrefixes if not server-supported. // to DefaultPrefixes if not server-supported.
func (s *state) userPrefixes() string { func (s *state) userPrefixes() string {
if prefix, ok := s.serverOptions.Get("PREFIX"); ok && isValidUserPrefix(prefix) { if prefix, ok := s.serverOptions["PREFIX"]; ok && isValidUserPrefix(prefix) {
return prefix return prefix
} }
return DefaultPrefixes return DefaultPrefixes
} }
// UserPerms contains all of the permissions for each channel the user is // UserPerms contains all of the permissions for each channel the user is
// in. // in.
type UserPerms struct { type UserPerms struct {
channels cmap.ConcurrentMap[string, *Perms] mu sync.RWMutex
channels map[string]Perms
} }
// Copy returns a deep copy of the channel permissions. // Copy returns a deep copy of the channel permissions.
func (p *UserPerms) Copy() (perms *UserPerms) { func (p *UserPerms) Copy() (perms *UserPerms) {
np := &UserPerms{ np := &UserPerms{
channels: cmap.New[*Perms](), channels: make(map[string]Perms),
} }
for tuple := range p.channels.IterBuffered() {
np.channels.Set(tuple.Key, tuple.Val) p.mu.RLock()
for key := range p.channels {
np.channels[key] = p.channels[key]
} }
p.mu.RUnlock()
return np return np
} }
// MarshalJSON implements json.Marshaler. // MarshalJSON implements json.Marshaler.
func (p *UserPerms) MarshalJSON() ([]byte, error) { func (p *UserPerms) MarshalJSON() ([]byte, error) {
p.mu.Lock()
out, err := json.Marshal(&p.channels) out, err := json.Marshal(&p.channels)
p.mu.Unlock()
return out, err return out, err
} }
// Lookup looks up the users permissions for a given channel. ok is false // Lookup looks up the users permissions for a given channel. ok is false
// if the user is not in the given channel. // if the user is not in the given channel.
func (p *UserPerms) Lookup(channel string) (perms *Perms, ok bool) { func (p *UserPerms) Lookup(channel string) (perms Perms, ok bool) {
return p.channels.Get(ToRFC1459(channel)) p.mu.RLock()
perms, ok = p.channels[ToRFC1459(channel)]
p.mu.RUnlock()
return perms, ok
} }
func (p *UserPerms) set(channel string, perms *Perms) { func (p *UserPerms) set(channel string, perms Perms) {
p.channels.Set(ToRFC1459(channel), perms) p.mu.Lock()
p.channels[ToRFC1459(channel)] = perms
p.mu.Unlock()
} }
func (p *UserPerms) remove(channel string) { func (p *UserPerms) remove(channel string) {
p.channels.Remove(ToRFC1459(channel)) p.mu.Lock()
delete(p.channels, ToRFC1459(channel))
p.mu.Unlock()
} }
// Perms contains all channel-based user permissions. The minimum op, and // Perms contains all channel-based user permissions. The minimum op, and
@ -446,7 +464,7 @@ type Perms struct {
// IsAdmin indicates that the user has banning abilities, and are likely a // IsAdmin indicates that the user has banning abilities, and are likely a
// very trustable user (e.g. op+). // very trustable user (e.g. op+).
func (m *Perms) IsAdmin() bool { func (m Perms) IsAdmin() bool {
if m.Owner || m.Admin || m.Op { if m.Owner || m.Admin || m.Op {
return true return true
} }
@ -456,7 +474,7 @@ func (m *Perms) IsAdmin() bool {
// IsTrusted indicates that the user at least has modes set upon them, higher // IsTrusted indicates that the user at least has modes set upon them, higher
// than a regular joining user. // than a regular joining user.
func (m *Perms) IsTrusted() bool { func (m Perms) IsTrusted() bool {
if m.IsAdmin() || m.HalfOp || m.Voice { if m.IsAdmin() || m.HalfOp || m.Voice {
return true return true
} }

368
state.go

@ -6,47 +6,45 @@ package girc
import ( import (
"fmt" "fmt"
"sort"
"sync" "sync"
"sync/atomic"
"time" "time"
cmap "github.com/orcaman/concurrent-map/v2"
) )
// state represents the actively-changing variables within the client // state represents the actively-changing variables within the client
// runtime. Note that everything within the state should be guarded by the // runtime. Note that everything within the state should be guarded by the
// embedded sync.RWMutex. // embedded sync.RWMutex.
type state struct { type state struct {
*sync.RWMutex sync.RWMutex
// nick, ident, and host are the internal trackers for our user. // nick, ident, and host are the internal trackers for our user.
nick, ident, host atomic.Value nick, ident, host string
// channels represents all channels we're active in. // channels represents all channels we're active in.
// channels map[string]*Channel channels map[string]*Channel
channels cmap.ConcurrentMap[string, *Channel]
// users represents all of users that we're tracking. // users represents all of users that we're tracking.
// users map[string]*User users map[string]*User
users cmap.ConcurrentMap[string, *User]
// enabledCap are the capabilities which are enabled for this connection. // enabledCap are the capabilities which are enabled for this connection.
// enabledCap map[string]map[string]string enabledCap map[string]map[string]string
enabledCap cmap.ConcurrentMap[string, map[string]string]
// tmpCap are the capabilties which we share with the server during the // tmpCap are the capabilties which we share with the server during the
// last capability check. These will get sent once we have received the // last capability check. These will get sent once we have received the
// last capability list command from the server. // last capability list command from the server.
tmpCap map[string]map[string]string tmpCap map[string]map[string]string
// serverOptions are the standard capabilities and configurations // serverOptions are the standard capabilities and configurations
// supported by the server at connection time. This also includes // supported by the server at connection time. This also includes
// RPL_ISUPPORT entries. // RPL_ISUPPORT entries.
// serverOptions map[string]string serverOptions map[string]string
serverOptions cmap.ConcurrentMap[string, string]
// network is an alternative way to store and retrieve the NETWORK server option.
network atomic.Value
// motd is the servers message of the day. // motd is the servers message of the day.
motd string
// client is a useful pointer to the state's related Client instance. // maxLineLength defines how long before we truncate (or split) messages.
client *Client // DefaultMaxLineLength is what is used by default, as this is going to be a common
// standard. However, protocols like IRCv3, or ISUPPORT can override this.
maxLineLength int
// maxPrefixLength defines the estimated prefix length (":nick!user@host ") that
// we can use to calculate line splits.
maxPrefixLength int
motd string
// sts are strict transport security configurations, if specified by the // sts are strict transport security configurations, if specified by the
// server. // server.
@ -56,69 +54,46 @@ type state struct {
sts strictTransport sts strictTransport
} }
type Clearer interface {
Clear()
}
// reset resets the state back to it's original form. // reset resets the state back to it's original form.
func (s *state) reset(initial bool) { func (s *state) reset(initial bool) {
s.nick.Store("") s.Lock()
s.ident.Store("") s.nick = ""
s.host.Store("") s.ident = ""
s.network.Store("") s.host = ""
var cmaps = []Clearer{&s.channels, &s.users, &s.serverOptions} s.channels = make(map[string]*Channel)
for i, cm := range cmaps { s.users = make(map[string]*User)
switch { s.enabledCap = make(map[string]map[string]string)
case i == 0 && initial:
cm = cmap.New[*Channel]()
case i == 1 && initial:
cm = cmap.New[*User]()
case i == 2 && initial:
cm = cmap.New[string]()
default:
cm.Clear()
}
}
s.enabledCap = cmap.New[map[string]string]()
s.tmpCap = make(map[string]map[string]string) s.tmpCap = make(map[string]map[string]string)
s.serverOptions = make(map[string]string)
s.maxLineLength = DefaultMaxLineLength
s.maxPrefixLength = DefaultMaxPrefixLength
s.motd = "" s.motd = ""
if initial { if initial {
s.sts.reset() s.sts.reset()
} }
s.Unlock()
} }
// User represents an IRC user and the state attached to them. // User represents an IRC user and the state attached to them.
type User struct { type User struct {
// Nick is the users current nickname. rfc1459 compliant. // Nick is the users current nickname. rfc1459 compliant.
Nick *MarshalableAtomicValue `json:"nick"` Nick string `json:"nick"`
// Ident is the users username/ident. Ident is commonly prefixed with a // Ident is the users username/ident. Ident is commonly prefixed with a
// "~", which indicates that they do not have a identd server setup for // "~", which indicates that they do not have a identd server setup for
// authentication. // authentication.
Ident *MarshalableAtomicValue `json:"ident"` Ident string `json:"ident"`
// Host is the visible host of the users connection that the server has // Host is the visible host of the users connection that the server has
// provided to us for their connection. May not always be accurate due to // provided to us for their connection. May not always be accurate due to
// many networks spoofing/hiding parts of the hostname for privacy // many networks spoofing/hiding parts of the hostname for privacy
// reasons. // reasons.
Host string `json:"host"` Host string `json:"host"`
// Mask is the combined Nick!Ident@Host of the given user.
Mask *MarshalableAtomicValue `json:"mask"`
// Network is the name of the IRC network where this user was found.
// This has been added for the purposes of girc being used in multi-client scenarios with data persistence.
Network string `json:"network"`
// ChannelList is a sorted list of all channels that we are currently // ChannelList is a sorted list of all channels that we are currently
// tracking the user in. Each channel name is rfc1459 compliant. See // tracking the user in. Each channel name is rfc1459 compliant. See
// User.Channels() for a shorthand if you're looking for the *Channel // User.Channels() for a shorthand if you're looking for the *Channel
// version of the channel list. // version of the channel list.
// ChannelList []string `json:"channels"`
// NOTE: If the ChannelList is empty for the user, then the user's info could be out of date.
// turns out Concurrent-Map implements json.Marhsal!
// https://github.com/orcaman/concurrent-map/v2/blob/893feb299719d9cbb2cfbe08b6dd4eb567d8039d/concurrent_map.go#L305
ChannelList cmap.ConcurrentMap[string, *Channel] `json:"channels"`
// FirstSeen represents the first time that the user was seen by the // FirstSeen represents the first time that the user was seen by the
// client for the given channel. Only usable if from state, not in past. // client for the given channel. Only usable if from state, not in past.
@ -132,8 +107,6 @@ type User struct {
// channel. This supports non-rfc style modes like Admin, Owner, and HalfOp. // channel. This supports non-rfc style modes like Admin, Owner, and HalfOp.
Perms *UserPerms `json:"perms"` Perms *UserPerms `json:"perms"`
Stale bool
// Extras are things added on by additional tracking methods, which may // Extras are things added on by additional tracking methods, which may
// or may not work on the IRC server in mention. // or may not work on the IRC server in mention.
Extras struct { Extras struct {
@ -154,26 +127,24 @@ type User struct {
} `json:"extras"` } `json:"extras"`
} }
// Channels returns a slice of pointers to Channel types that the client knows the user is in. // Channels returns a reference of *Channels that the client knows the user
func (u *User) Channels(c *Client) []*Channel { // is in. If you're just looking for the namme of the channels, use
// User.ChannelList.
func (u User) Channels(c *Client) []*Channel {
if c == nil { if c == nil {
panic("nil Client provided") panic("nil Client provided")
} }
var channels []*Channel channels := []*Channel{}
for listed := range u.ChannelList.IterBuffered() { c.state.RLock()
chn := listed.Val for i := 0; i < len(u.ChannelList); i++ {
if chn != nil { ch := c.state.lookupChannel(u.ChannelList[i])
channels = append(channels, chn)
continue
}
ch := c.state.lookupChannel(listed.Key)
if ch != nil { if ch != nil {
u.ChannelList.Set(listed.Key, ch)
channels = append(channels, ch) channels = append(channels, ch)
} }
} }
c.state.RUnlock()
return channels return channels
} }
@ -189,45 +160,53 @@ func (u *User) Copy() *User {
*nu = *u *nu = *u
nu.Perms = u.Perms.Copy() nu.Perms = u.Perms.Copy()
for ch := range u.ChannelList.IterBuffered() { _ = copy(nu.ChannelList, u.ChannelList)
nu.ChannelList.Set(ch.Key, ch.Val)
}
return nu return nu
} }
// addChannel adds the channel to the users channel list. // addChannel adds the channel to the users channel list.
func (u *User) addChannel(name string, chn *Channel) { func (u *User) addChannel(name string) {
name = ToRFC1459(name)
if u.InChannel(name) { if u.InChannel(name) {
return return
} }
if u.ChannelList.Has(name) { u.ChannelList = append(u.ChannelList, ToRFC1459(name))
return sort.Strings(u.ChannelList)
}
u.ChannelList.Set(name, chn) u.Perms.set(name, Perms{})
u.Perms.set(name, &Perms{})
} }
// deleteChannel removes an existing channel from the users channel list. // deleteChannel removes an existing channel from the users channel list.
func (u *User) deleteChannel(name string) { func (u *User) deleteChannel(name string) {
name = ToRFC1459(name) name = ToRFC1459(name)
u.ChannelList.Remove(name) j := -1
for i := 0; i < len(u.ChannelList); i++ {
if u.ChannelList[i] == name {
j = i
break
}
}
if j != -1 {
u.ChannelList = append(u.ChannelList[:j], u.ChannelList[j+1:]...)
}
u.Perms.remove(name) u.Perms.remove(name)
} }
// InChannel checks to see if a user is in the given channel. // InChannel checks to see if a user is in the given channel.
// Maybe don't rely on it though, hasn't been the same since the war. :^)
func (u *User) InChannel(name string) bool { func (u *User) InChannel(name string) bool {
name = ToRFC1459(name) name = ToRFC1459(name)
return u.ChannelList.Has(name) for i := 0; i < len(u.ChannelList); i++ {
if u.ChannelList[i] == name {
return true
}
}
return false
} }
// Lifetime represents the amount of time that has passed since we have first // Lifetime represents the amount of time that has passed since we have first
@ -253,16 +232,10 @@ type Channel struct {
Name string `json:"name"` Name string `json:"name"`
// Topic of the channel. // Topic of the channel.
Topic string `json:"topic"` Topic string `json:"topic"`
// Created is the time/date the channel was created (if available).
// Created time.Time `json:"created"`
// TODO: Figure out if these are all unix timestamps, if so, convert it to time.Time
Created string `json:"created"`
// UserList is a sorted list of all users we are currently tracking within // UserList is a sorted list of all users we are currently tracking within
// the channel. Each is the1 nickname, and is rfc1459 compliant. // the channel. Each is the nickname, and is rfc1459 compliant.
UserList cmap.ConcurrentMap[string, *User] `json:"user_list"` UserList []string `json:"user_list"`
// Network is the name of the IRC network where this channel was found.
// This has been added for the purposes of girc being used in multi-client scenarios with data persistence.
Network string `json:"network"`
// Joined represents the first time that the client joined the channel. // Joined represents the first time that the client joined the channel.
Joined time.Time `json:"joined"` Joined time.Time `json:"joined"`
// Modes are the known channel modes that the bot has captured. // Modes are the known channel modes that the bot has captured.
@ -271,35 +244,37 @@ type Channel struct {
// Users returns a reference of *Users that the client knows the channel has // Users returns a reference of *Users that the client knows the channel has
// If you're just looking for just the name of the users, use Channnel.UserList. // If you're just looking for just the name of the users, use Channnel.UserList.
func (ch *Channel) Users(c *Client) []*User { func (ch Channel) Users(c *Client) []*User {
if c == nil { if c == nil {
panic("nil Client provided") panic("nil Client provided")
} }
var users []*User users := []*User{}
for listed := range ch.UserList.IterBuffered() { c.state.RLock()
user := c.state.lookupUser(listed.Key) for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
if user != nil { if user != nil {
ch.UserList.Set(listed.Key, user)
users = append(users, user) users = append(users, user)
} }
} }
c.state.RUnlock()
return users return users
} }
// Trusted returns a list of users which have voice or greater in the given // Trusted returns a list of users which have voice or greater in the given
// channel. See Perms.IsTrusted() for more information. // channel. See Perms.IsTrusted() for more information.
func (ch *Channel) Trusted(c *Client) []*User { func (ch Channel) Trusted(c *Client) []*User {
if c == nil { if c == nil {
panic("nil Client provided") panic("nil Client provided")
} }
var users []*User users := []*User{}
for listed := range ch.UserList.IterBuffered() { c.state.RLock()
user := c.state.lookupUser(listed.Key) for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
if user == nil { if user == nil {
continue continue
} }
@ -309,6 +284,7 @@ func (ch *Channel) Trusted(c *Client) []*User {
users = append(users, user) users = append(users, user)
} }
} }
c.state.RUnlock()
return users return users
} }
@ -316,44 +292,55 @@ func (ch *Channel) Trusted(c *Client) []*User {
// Admins returns a list of users which have half-op (if supported), or // Admins returns a list of users which have half-op (if supported), or
// greater permissions (op, admin, owner, etc) in the given channel. See // greater permissions (op, admin, owner, etc) in the given channel. See
// Perms.IsAdmin() for more information. // Perms.IsAdmin() for more information.
func (ch *Channel) Admins(c *Client) []*User { func (ch Channel) Admins(c *Client) []*User {
if c == nil { if c == nil {
panic("nil Client provided") panic("nil Client provided")
} }
var users []*User users := []*User{}
for listed := range ch.UserList.IterBuffered() { c.state.RLock()
ui := listed.Val for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
if ui == nil { if user == nil {
if ui = c.state.lookupUser(listed.Key); ui == nil { continue
continue
}
ch.UserList.Set(listed.Key, ui)
} }
perms, ok := ui.Perms.Lookup(ch.Name) perms, ok := user.Perms.Lookup(ch.Name)
if ok && perms.IsAdmin() { if ok && perms.IsAdmin() {
users = append(users, ui) users = append(users, user)
} }
} }
c.state.RUnlock()
return users return users
} }
// addUser adds a user to the users list. // addUser adds a user to the users list.
func (ch *Channel) addUser(nick string, usr *User) { func (ch *Channel) addUser(nick string) {
if ch.UserIn(nick) { if ch.UserIn(nick) {
return return
} }
ch.UserList.Set(ToRFC1459(nick), usr)
ch.UserList = append(ch.UserList, ToRFC1459(nick))
sort.Strings(ch.UserList)
} }
// deleteUser removes an existing user from the users list. // deleteUser removes an existing user from the users list.
func (ch *Channel) deleteUser(nick string) { func (ch *Channel) deleteUser(nick string) {
nick = ToRFC1459(nick) nick = ToRFC1459(nick)
ch.UserList.Remove(nick)
j := -1
for i := 0; i < len(ch.UserList); i++ {
if ch.UserList[i] == nick {
j = i
break
}
}
if j != -1 {
ch.UserList = append(ch.UserList[:j], ch.UserList[j+1:]...)
}
} }
// Copy returns a deep copy of a given channel. // Copy returns a deep copy of a given channel.
@ -365,9 +352,7 @@ func (ch *Channel) Copy() *Channel {
nc := &Channel{} nc := &Channel{}
*nc = *ch *nc = *ch
for v := range ch.UserList.IterBuffered() { _ = copy(nc.UserList, ch.UserList)
nc.UserList.Set(v.Val.Nick.Load().(string), v.Val)
}
// And modes. // And modes.
nc.Modes = ch.Modes.Copy() nc.Modes = ch.Modes.Copy()
@ -377,13 +362,20 @@ func (ch *Channel) Copy() *Channel {
// Len returns the count of users in a given channel. // Len returns the count of users in a given channel.
func (ch *Channel) Len() int { func (ch *Channel) Len() int {
return ch.UserList.Count() return len(ch.UserList)
} }
// UserIn checks to see if a given user is in a channel. // UserIn checks to see if a given user is in a channel.
func (ch *Channel) UserIn(name string) bool { func (ch *Channel) UserIn(name string) bool {
name = ToRFC1459(name) name = ToRFC1459(name)
return ch.UserList.Has(name)
for i := 0; i < len(ch.UserList); i++ {
if ch.UserList[i] == name {
return true
}
}
return false
} }
// Lifetime represents the amount of time that has passed since we have first // Lifetime represents the amount of time that has passed since we have first
@ -394,21 +386,19 @@ func (ch *Channel) Lifetime() time.Duration {
// createChannel creates the channel in state, if not already done. // createChannel creates the channel in state, if not already done.
func (s *state) createChannel(name string) (ok bool) { func (s *state) createChannel(name string) (ok bool) {
supported := s.chanModes() supported := s.chanModes()
prefixes, _ := parsePrefixes(s.userPrefixes()) prefixes, _ := parsePrefixes(s.userPrefixes())
if _, ok := s.channels.Get(ToRFC1459(name)); ok { if _, ok := s.channels[ToRFC1459(name)]; ok {
return false return false
} }
s.channels.Set(ToRFC1459(name), &Channel{ s.channels[ToRFC1459(name)] = &Channel{
Name: name, Name: name,
UserList: cmap.New[*User](), UserList: []string{},
Joined: time.Now(), Joined: time.Now(),
Network: s.client.NetworkName(),
Modes: NewCModes(supported, prefixes), Modes: NewCModes(supported, prefixes),
}) }
return true return true
} }
@ -417,92 +407,69 @@ func (s *state) createChannel(name string) (ok bool) {
func (s *state) deleteChannel(name string) { func (s *state) deleteChannel(name string) {
name = ToRFC1459(name) name = ToRFC1459(name)
chn, ok := s.channels.Get(name) _, ok := s.channels[name]
if !ok { if !ok {
return return
} }
for listed := range chn.UserList.IterBuffered() { for _, user := range s.channels[name].UserList {
usr, uok := s.users.Get(listed.Key) s.users[user].deleteChannel(name)
if uok {
usr.deleteChannel(name) if len(s.users[user].ChannelList) == 0 {
// Assume we were only tracking them in this channel, and they
// should be removed from state.
delete(s.users, user)
} }
} }
s.channels.Remove(name) delete(s.channels, name)
} }
// lookupChannel returns a reference to a channel, nil returned if no results // lookupChannel returns a reference to a channel, nil returned if no results
// found. // found.
func (s *state) lookupChannel(name string) *Channel { func (s *state) lookupChannel(name string) *Channel {
ci, cok := s.channels.Get(ToRFC1459(name)) return s.channels[ToRFC1459(name)]
if ci == nil || !cok {
return nil
}
return ci
} }
// lookupUser returns a reference to a user, nil returned if no results // lookupUser returns a reference to a user, nil returned if no results
// found. // found.
func (s *state) lookupUser(name string) *User { func (s *state) lookupUser(name string) *User {
usr, uok := s.users.Get(ToRFC1459(name)) return s.users[ToRFC1459(name)]
if usr == nil || !uok {
return nil
}
return usr
} }
func (s *state) createUser(src *Source) (u *User, ok bool) { // createUser creates the user in state, if not already done.
if u, ok = s.users.Get(src.ID()); ok { func (s *state) createUser(src *Source) (ok bool) {
if _, ok := s.users[src.ID()]; ok {
// User already exists. // User already exists.
return u, false return false
} }
mask := strs.Get() s.users[src.ID()] = &User{
if src.Name != "" { Nick: src.Name,
mask.MustWriteString(src.Name) Host: src.Host,
} Ident: src.Ident,
_ = mask.WriteByte('!') FirstSeen: time.Now(),
if src.Ident != "" { LastActive: time.Now(),
mask.MustWriteString(src.Ident) Perms: &UserPerms{channels: make(map[string]Perms)},
}
_ = mask.WriteByte('@')
if src.Host != "" {
mask.MustWriteString(src.Host)
} }
u = &User{ return true
Nick: NewAtomicString(src.Name),
Host: src.Host,
Ident: NewAtomicString(src.Ident),
Mask: NewAtomicString(mask.String()),
ChannelList: cmap.New[*Channel](),
FirstSeen: time.Now(),
LastActive: time.Now(),
Network: s.client.NetworkName(),
Perms: &UserPerms{channels: cmap.New[*Perms]()},
}
strs.MustPut(mask)
s.users.Set(src.ID(), u)
return u, true
} }
// deleteUser removes the user from channel state. // deleteUser removes the user from channel state.
func (s *state) deleteUser(channelName, nick string) { func (s *state) deleteUser(channelName, nick string) {
user := s.lookupUser(nick) user := s.lookupUser(nick)
if user == nil { if user == nil {
s.client.debug.Printf(nick + ": was not found when trying to deleteUser from " + channelName)
return return
} }
if channelName == "" { if channelName == "" {
user.ChannelList.Clear() for i := 0; i < len(user.ChannelList); i++ {
// While we do still want to remove them from the channels, s.channels[user.ChannelList[i]].deleteUser(nick)
// We want to hold onto that user object regardless on if they dip-set. }
// s.users.Remove(ToRFC1459(nick))
user.Stale = true delete(s.users, ToRFC1459(nick))
return return
} }
@ -513,8 +480,12 @@ func (s *state) deleteUser(channelName, nick string) {
user.deleteChannel(channelName) user.deleteChannel(channelName)
channel.deleteUser(nick) channel.deleteUser(nick)
if user.ChannelList.Count() == 0 {
user.Stale = true if len(user.ChannelList) == 0 {
// This means they are no longer in any channels we track, delete
// them from state.
delete(s.users, ToRFC1459(nick))
} }
} }
@ -523,32 +494,29 @@ func (s *state) renameUser(from, to string) {
from = ToRFC1459(from) from = ToRFC1459(from)
// Update our nickname. // Update our nickname.
if from == ToRFC1459(s.nick.Load().(string)) { if from == ToRFC1459(s.nick) {
s.nick.Store(to) s.nick = to
} }
user := s.lookupUser(from) user := s.lookupUser(from)
if user == nil {
old, oldok := s.users.Pop(from)
if !oldok && user == nil {
return return
} }
if old != nil && user == nil { delete(s.users, from)
user = old
}
user.Nick.Store(to) user.Nick = to
user.LastActive = time.Now() user.LastActive = time.Now()
s.users.Set(ToRFC1459(to), user) s.users[ToRFC1459(to)] = user
for chanchan := range s.channels.IterBuffered() { for i := 0; i < len(user.ChannelList); i++ {
chn := chanchan.Val for j := 0; j < len(s.channels[user.ChannelList[i]].UserList); j++ {
if chn == nil { if s.channels[user.ChannelList[i]].UserList[j] == from {
continue s.channels[user.ChannelList[i]].UserList[j] = ToRFC1459(to)
}
if old, oldok = chn.UserList.Pop(from); oldok { sort.Strings(s.channels[user.ChannelList[i]].UserList)
chn.UserList.Set(to, old) break
}
} }
} }
} }

@ -29,30 +29,30 @@ const mockConnStartState = `:dummy.int NOTICE * :*** Looking up your hostname...
:dummy.int NOTICE * :*** Checking Ident :dummy.int NOTICE * :*** Checking Ident
:dummy.int NOTICE * :*** Found your hostname :dummy.int NOTICE * :*** Found your hostname
:dummy.int NOTICE * :*** No Ident response :dummy.int NOTICE * :*** No Ident response
:dummy.int 001 fhjones :Welcome to the DUMMY Internet Relay Chat Network fhjones :dummy.int 001 nick :Welcome to the DUMMY Internet Relay Chat Network nick
:dummy.int 005 fhjones NETWORK=DummyIRC NICKLEN=20 :are supported by this server :dummy.int 005 nick NETWORK=DummyIRC NICKLEN=20 :are supported by this server
:dummy.int 375 fhjones :- dummy.int Message of the Day - :dummy.int 375 nick :- dummy.int Message of the Day -
:dummy.int 372 fhjones :example motd :dummy.int 372 nick :example motd
:dummy.int 376 fhjones :End of /MOTD command. :dummy.int 376 nick :End of /MOTD command.
:fhjones!~user@local.int JOIN #channel * :realname :nick!~user@local.int JOIN #channel * :realname
:dummy.int 332 fhjones #channel :example topic :dummy.int 332 nick #channel :example topic
:dummy.int 353 fhjones = #channel :fhjones!~user@local.int @nick2!nick2@other.int :dummy.int 353 nick = #channel :nick!~user@local.int @nick2!nick2@other.int
:dummy.int 366 fhjones #channel :End of /NAMES list. :dummy.int 366 nick #channel :End of /NAMES list.
:dummy.int 354 fhjones 1 #channel ~user local.int fhjones 0 :realname :dummy.int 354 nick 1 #channel ~user local.int nick 0 :realname
:dummy.int 354 fhjones 1 #channel nick2 other.int nick2 nick2 :realname2 :dummy.int 354 nick 1 #channel nick2 other.int nick2 nick2 :realname2
:dummy.int 315 fhjones #channel :End of /WHO list. :dummy.int 315 nick #channel :End of /WHO list.
:fhjones!~user@local.int JOIN #channel2 * :realname :nick!~user@local.int JOIN #channel2 * :realname
:dummy.int 332 fhjones #channel2 :example topic :dummy.int 332 nick #channel2 :example topic
:dummy.int 353 fhjones = #channel2 :fhjones!~user@local.int @nick2!nick2@other.int :dummy.int 353 nick = #channel2 :nick!~user@local.int @nick2!nick2@other.int
:dummy.int 366 fhjones #channel2 :End of /NAMES list. :dummy.int 366 nick #channel2 :End of /NAMES list.
:dummy.int 354 fhjones 1 #channel2 ~user local.int fhjones 0 :realname :dummy.int 354 nick 1 #channel2 ~user local.int nick 0 :realname
:dummy.int 354 fhjones 1 #channel2 nick2 other.int nick2 nick2 :realname2 :dummy.int 354 nick 1 #channel2 nick2 other.int nick2 nick2 :realname2
:dummy.int 315 fhjones #channel2 :End of /WHO list. :dummy.int 315 nick #channel2 :End of /WHO list.
` `
const mockConnEndState = `:nick2!nick2@other.int QUIT :example reason const mockConnEndState = `:nick2!nick2@other.int QUIT :example reason
:fhjones!~user@local.int PART #channel2 :example reason :nick!~user@local.int PART #channel2 :example reason
:fhjones!~user@local.int NICK notjones :nick!~user@local.int NICK newnick
` `
func TestState(t *testing.T) { func TestState(t *testing.T) {
@ -71,176 +71,123 @@ func TestState(t *testing.T) {
finishStart := make(chan bool, 1) finishStart := make(chan bool, 1)
go debounce(250*time.Millisecond, bounceStart, func() { go debounce(250*time.Millisecond, bounceStart, func() {
if motd := c.ServerMOTD(); motd != "example motd" { if motd := c.ServerMOTD(); motd != "example motd" {
t.Errorf("Client.ServerMOTD() returned invalid MOTD: %q", motd) t.Fatalf("Client.ServerMOTD() returned invalid MOTD: %q", motd)
return
} }
network := c.NetworkName() if network := c.NetworkName(); network != "DummyIRC" {
t.Fatalf("Client.NetworkName() returned invalid network name: %q", network)
if network != "DummyIRC" && network != "DUMMY" {
t.Errorf("User.Network == %q, want \"DummyIRC\" or \"DUMMY\"", network)
return
} }
t.Logf("successfully tested network name: %s", network) if caseExample, ok := c.GetServerOption("NICKLEN"); !ok || caseExample != "20" {
t.Fatalf("Client.GetServerOptions returned invalid ISUPPORT variable")
caseExample, ok := c.GetServerOpt("NICKLEN")
if !ok || caseExample != "20" {
t.Errorf("Client.GetServerOptions returned invalid ISUPPORT variable: %q", caseExample)
} }
t.Logf("successfully serveroption NICKLEN: %s", caseExample)
users := c.UserList() users := c.UserList()
channels := c.ChannelList() channels := c.ChannelList()
if !reflect.DeepEqual(users, []string{"fhjones", "nick2"}) { if !reflect.DeepEqual(users, []string{"nick", "nick2"}) {
// This could fail too, if sorting isn't occurring. // This could fail too, if sorting isn't occurring.
t.Errorf("got state users %#v, wanted: %#v", users, []string{"fhjones", "nick2"}) t.Fatalf("got state users %#v, wanted: %#v", users, []string{"nick", "nick2"})
return
} }
t.Logf("successfully checked userlist: %v", users)
if !reflect.DeepEqual(channels, []string{"#channel", "#channel2"}) { if !reflect.DeepEqual(channels, []string{"#channel", "#channel2"}) {
// This could fail too, if sorting isn't occurring. // This could fail too, if sorting isn't occurring.
t.Errorf("got state channels %#v, wanted: %#v", channels, []string{"#channel", "#channel2"}) t.Fatalf("got state channels %#v, wanted: %#v", channels, []string{"#channel", "#channel2"})
return
} }
t.Logf("successfully checked channel list: %v", channels)
fullChannels := c.Channels() fullChannels := c.Channels()
for i := 0; i < len(fullChannels); i++ { for i := 0; i < len(fullChannels); i++ {
if fullChannels[i].Name != channels[i] { if fullChannels[i].Name != channels[i] {
t.Errorf("fullChannels name doesn't map to same name in ChannelsList: %q :: %#v", fullChannels[i].Name, channels) t.Fatalf("fullChannels name doesn't map to same name in ChannelsList: %q :: %#v", fullChannels[i].Name, channels)
return
} }
t.Logf("successfully checked full channel list: %s: %v", fullChannels[i].Name, channels)
} }
fullUsers := c.Users() fullUsers := c.Users()
for i := 0; i < len(fullUsers); i++ { for i := 0; i < len(fullUsers); i++ {
if fullUsers[i].Nick.Load().(string) != users[i] { if fullUsers[i].Nick != users[i] {
t.Errorf("fullUsers nick doesn't map to same nick in UsersList: %q :: %#v", fullUsers[i].Nick, users) t.Fatalf("fullUsers nick doesn't map to same nick in UsersList: %q :: %#v", fullUsers[i].Nick, users)
return
} }
} }
ch := c.LookupChannel("#channel") ch := c.LookupChannel("#channel")
if ch == nil { if ch == nil {
t.Error("Client.LookupChannel returned nil on existing channel") t.Fatal("Client.LookupChannel returned nil on existing channel")
return
} }
adm := ch.Admins(c) adm := ch.Admins(c)
var admList []string admList := []string{}
for i := 0; i < len(adm); i++ { for i := 0; i < len(adm); i++ {
admList = append(admList, adm[i].Nick.Load().(string)) admList = append(admList, adm[i].Nick)
} }
trusted := ch.Trusted(c) trusted := ch.Trusted(c)
var trustedList []string trustedList := []string{}
for i := 0; i < len(trusted); i++ { for i := 0; i < len(trusted); i++ {
trustedList = append(trustedList, trusted[i].Nick.Load().(string)) trustedList = append(trustedList, trusted[i].Nick)
} }
if !reflect.DeepEqual(admList, []string{"nick2"}) { if !reflect.DeepEqual(admList, []string{"nick2"}) {
t.Errorf("got Channel.Admins() == %#v, wanted %#v", admList, []string{"nick2"}) t.Fatalf("got Channel.Admins() == %#v, wanted %#v", admList, []string{"nick2"})
return
} }
if !reflect.DeepEqual(trustedList, []string{"nick2"}) { if !reflect.DeepEqual(trustedList, []string{"nick2"}) {
t.Errorf("got Channel.Trusted() == %#v, wanted %#v", trustedList, []string{"nick2"}) t.Fatalf("got Channel.Trusted() == %#v, wanted %#v", trustedList, []string{"nick2"})
return
} }
if topic := ch.Topic; topic != "example topic" { if topic := ch.Topic; topic != "example topic" {
t.Errorf("Channel.Topic == %q, want \"example topic\"", topic) t.Fatalf("Channel.Topic == %q, want \"example topic\"", topic)
return
} }
if ch.Network != "DummyIRC" && ch.Network != "DUMMY" { if in := ch.UserIn("nick"); !in {
t.Errorf("Channel.Network == %q, want \"DummyIRC\" or \"DUMMY\"", ch.Network) t.Fatalf("Channel.UserIn == %t, want %t", in, true)
return
}
if in := ch.UserIn("fhjones"); !in {
t.Errorf("Channel.UserIn == %t, want %t", in, true)
return
} }
if users := ch.Users(c); len(users) != 2 { if users := ch.Users(c); len(users) != 2 {
t.Errorf("Channel.Users == %#v, wanted length of 2", users) t.Fatalf("Channel.Users == %#v, wanted length of 2", users)
return
} }
if h := c.GetHost(); h != "local.int" { if h := c.GetHost(); h != "local.int" {
t.Errorf("Client.GetHost() == %q, want local.int", h) t.Fatalf("Client.GetHost() == %q, want local.int", h)
return
} }
if nick := c.GetNick(); nick != "fhjones" { if nick := c.GetNick(); nick != "nick" {
t.Errorf("Client.GetNick() == %q, want nick", nick) t.Fatalf("Client.GetNick() == %q, want nick", nick)
return
} }
if ident := c.GetIdent(); ident != "~user" { if ident := c.GetIdent(); ident != "~user" {
t.Errorf("Client.GetIdent() == %q, want ~user", ident) t.Fatalf("Client.GetIdent() == %q, want ~user", ident)
return
} }
user := c.LookupUser("fhjones") user := c.LookupUser("nick")
if user == nil { if user == nil {
t.Error("Client.LookupUser() returned nil on existing user") t.Fatal("Client.LookupUser() returned nil on existing user")
return
} }
if user.ChannelList.Count() != len([]string{"#channel", "#channel2"}) { if !reflect.DeepEqual(user.ChannelList, []string{"#channel", "#channel2"}) {
t.Errorf("user.ChannelList.Count() == %d, wanted %d", t.Fatalf("User.ChannelList == %#v, wanted %#v", user.ChannelList, []string{"#channel", "#channel2"})
user.ChannelList.Count(), len([]string{"#channel", "#channel2"}))
return
}
if !user.ChannelList.Has("#channel") || !user.ChannelList.Has("#channel2") {
t.Errorf("channel list is missing either #channel or #channel2")
return
} }
if count := len(user.Channels(c)); count != 2 { if count := len(user.Channels(c)); count != 2 {
t.Errorf("len(User.Channels) == %d, want 2", count) t.Fatalf("len(User.Channels) == %d, want 2", count)
return
} }
if user.Nick.Load().(string) != "fhjones" { if user.Nick != "nick" {
t.Errorf("User.Nick == %q, wanted \"nick\"", user.Nick) t.Fatalf("User.Nick == %q, wanted \"nick\"", user.Nick)
return
} }
if user.Extras.Name != "realname" { if user.Extras.Name != "realname" {
t.Errorf("User.Extras.Name == %q, wanted \"realname\"", user.Extras.Name) t.Fatalf("User.Extras.Name == %q, wanted \"realname\"", user.Extras.Name)
return
} }
if user.Host != "local.int" { if user.Host != "local.int" {
t.Errorf("User.Host == %q, wanted \"local.int\"", user.Host) t.Fatalf("User.Host == %q, wanted \"local.int\"", user.Host)
return
} }
if user.Ident.Load().(string) != "~user" { if user.Ident != "~user" {
t.Errorf("User.Ident == %q, wanted \"~user\"", user.Ident) t.Fatalf("User.Ident == %q, wanted \"~user\"", user.Ident)
return
}
if user.Network != "DummyIRC" && user.Network != "DUMMY" {
t.Errorf("User.Network == %q, want \"DummyIRC\" or \"DUMMY\"", user.Network)
return
} }
if !user.InChannel("#channel2") { if !user.InChannel("#channel2") {
t.Error("User.InChannel() returned false for existing channel") t.Fatal("User.InChannel() returned false for existing channel")
return
} }
finishStart <- true finishStart <- true
@ -250,9 +197,7 @@ func TestState(t *testing.T) {
bounceStart <- true bounceStart <- true
}) })
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { conn.SetDeadline(time.Now().Add(5 * time.Second))
t.Fatal(err)
}
_, err := conn.Write([]byte(mockConnStartState)) _, err := conn.Write([]byte(mockConnStartState))
if err != nil { if err != nil {
panic(err) panic(err)
@ -269,44 +214,29 @@ func TestState(t *testing.T) {
finishEnd := make(chan bool, 1) finishEnd := make(chan bool, 1)
go debounce(250*time.Millisecond, bounceEnd, func() { go debounce(250*time.Millisecond, bounceEnd, func() {
if !reflect.DeepEqual(c.ChannelList(), []string{"#channel"}) { if !reflect.DeepEqual(c.ChannelList(), []string{"#channel"}) {
t.Errorf("Client.ChannelList() == %#v, wanted %#v", c.ChannelList(), []string{"#channel"}) t.Fatalf("Client.ChannelList() == %#v, wanted %#v", c.ChannelList(), []string{"#channel"})
return
} }
if !reflect.DeepEqual(c.UserList(), []string{"notjones"}) { if !reflect.DeepEqual(c.UserList(), []string{"newnick"}) {
t.Errorf("Client.UserList() == %#v, wanted %#v", c.UserList(), []string{"notjones"}) t.Fatalf("Client.UserList() == %#v, wanted %#v", c.UserList(), []string{"newnick"})
return
} }
user := c.LookupUser("notjones") user := c.LookupUser("newnick")
if user == nil { if user == nil {
t.Error("Client.LookupUser() returned nil for existing user") t.Fatal("Client.LookupUser() returned nil for existing user")
return
} }
chn, chnok := user.ChannelList.Get("#channel") if !reflect.DeepEqual(user.ChannelList, []string{"#channel"}) {
t.Fatalf("user.ChannelList == %q, wanted %q", user.ChannelList, []string{"#channel"})
if !chnok {
t.Errorf("should have been able to get a pointer by looking up #channel")
return
} }
if chn == nil { channel := c.LookupChannel("#channel")
t.Error("Client.LookupChannel() returned nil for existing channel") if channel == nil {
return t.Fatal("Client.LookupChannel() returned nil for existing channel")
} }
chn2, _ := user.ChannelList.Get("#channel2") if !reflect.DeepEqual(channel.UserList, []string{"newnick"}) {
t.Fatalf("channel.UserList == %q, wanted %q", channel.UserList, []string{"newnick"})
if chn2.Len() != len([]string{"notjones"}) {
t.Errorf("channel.UserList.Count() == %d, wanted %d",
chn2.Len(), len([]string{"notjones"}))
return
}
if !chn.UserList.Has("notjones") {
t.Errorf("missing notjones from channel.UserList")
return
} }
finishEnd <- true finishEnd <- true
@ -316,9 +246,7 @@ func TestState(t *testing.T) {
bounceEnd <- true bounceEnd <- true
}) })
if err = conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { conn.SetDeadline(time.Now().Add(5 * time.Second))
t.Fatal(err)
}
_, err = conn.Write([]byte(mockConnEndState)) _, err = conn.Write([]byte(mockConnEndState))
if err != nil { if err != nil {
panic(err) panic(err)

@ -1,29 +0,0 @@
package girc
import (
"fmt"
"sync/atomic"
)
type MarshalableAtomicValue struct {
*atomic.Value
}
func (m *MarshalableAtomicValue) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf("%v", m.Value.Load())), nil
}
func (m *MarshalableAtomicValue) UnmarshalJSON(b []byte) error {
m.Value.Store(string(b))
return nil
}
func (m *MarshalableAtomicValue) String() string {
return m.Value.Load().(string)
}
func NewAtomicString(s string) *MarshalableAtomicValue {
obj := &atomic.Value{}
obj.Store(s)
return &MarshalableAtomicValue{Value: obj}
}

@ -1,4 +0,0 @@
package girc
// Version represents the current library version of girc-atomic.
const Version = "v0.5.2"