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
* When submitting an issue or bug report, please ensure to provide as much information as possible, please ensure that
you are running on the latest stable version (tagged), or when using master, provide the specific commit being used.
* Provide the minimum needed viable source to replicate the problem.
* When submitting an issue or bug report, please ensure to provide as much
information as possible, please ensure that you are running on the latest
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
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
should match Golang's standard library and common idioms.
Below are a few guidelines if you would like to contribute. Keep the code
clean, standardized, and much of the quality should match Golang's standard
library and common idioms.
* Always test using the latest Go version.
* Always use `gofmt` before committing anything.
* Always have proper documentation before committing.
* Keep the same whitespacing, documentation, and newline format as the rest of the project.
* Only use 3rd party libraries if necessary. If only a small portion of 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)
* Always test using the latest Go version.
* Always use `gofmt` before committing anything.
* Always have proper documentation before committing.
* Keep the same whitespacing, documentation, and newline format as the
rest of the project.
* Only use 3rd party libraries if necessary. If only a small portion of
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,
please open an issue first so it can be discussed.
If you would like to assist, and the pull request is quite large and/or it has
the potential of being a breaking change, please open an issue first so it can
be discussed.

@ -1,7 +1,6 @@
MIT License
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
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">girc-atomic, a terrifying fork of an IRC library for Go</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, a flexible IRC library for Go</p>
<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://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="ircs://ircd.chat:6697/#tcpdirect"><img src="https://img.shields.io/badge/ircd.chat-%23tcpdirect-blue.svg" alt="IRC Chat"></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://github.com/lrstanley/girc/actions"><img src="https://github.com/lrstanley/girc/workflows/test/badge.svg" alt="Test Status"></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="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://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>
## Fork changes
[Click here to see the changes in girc-atomic vs girc](https://github.com/lrstanley/girc/compare/master...yunginnanet:master)
## Status
### ₜₕₑ ₛₖy ᵢₛ 𝆑ₐₗₗᵢₙg ʇɥǝ sʞʎ ᴉs ⅎɐʅʅᴉuƃ
### 𝚝𝚑𝚎𝚢 𝚜𝚑𝚘𝚞𝚕𝚍 𝚑𝚊𝚟𝚎 𝚕𝚒𝚜𝚝𝚎𝚗𝚎𝚍
### ʇɥǝ sʞʎ ᴉs ⅎɐʅʅᴉuƃ ₜₕₑ ₛₖy ᵢₛ 𝆑ₐₗₗᵢₙg
**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
you're using won't have breaking changes**
## Features
- 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.
- 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)!)
- [Documentation](https://godoc.org/github.com/yunginnanet/girc-atomic) is _mostly_ complete.
- Focuses on simplicity, yet tries to still be flexible.
- Only requires [standard library packages](https://godoc.org/github.com/lrstanley/girc?imports)
- 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/lrstanley/girc) is _mostly_ complete.
- 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
mechanisms.)
- 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).
- 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/lrstanley/girc/blob/master/cap.go) for more info).
- 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!)
- 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.
- Nick collision detection and prevention (also see [Config.HandleNickCollide](https://godoc.org/github.com/yunginnanet/girc-atomic#Config).)
- 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.)
- CTCP handling and auto-responses ([CTCP](https://godoc.org/github.com/yunginnanet/girc-atomic#CTCP))
- Utilizes atomics and concurrent maps to reduce backpressure in multi-client usage. (fork)
- Additional CTCP handlers and customization. (fork)
- ??????
- PROFIT!!!1!
- Nick collision detection and prevention (also see [Config.HandleNickCollide](https://godoc.org/github.com/lrstanley/girc#Config).)
- Event/message rate limiting.
- 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/lrstanley/girc#CTCP))
- And more!
## Installing
$ 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
~~Please review the [CONTRIBUTING](CONTRIBUTING.md) doc for submitting issues/a guide
on submitting pull requests and helping out.~~
**OH GOD PLEASE MAKE IT STOP**
Please review the [CONTRIBUTING](CONTRIBUTING.md) doc for submitting issues/a guide
on submitting pull requests and helping out.
## License
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
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
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
...and then later defiled by [some idiot](https://github.com/yunginnanet).
girc artwork licensed under [CC 3.0](http://creativecommons.org/licenses/by/3.0/) based on Renee French under Creative Commons 3.0 Attributions.
## References

@ -5,88 +5,75 @@
package girc
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/araddon/dateparse"
)
// registerBuiltin sets up built-in handlers, based on client
// configuration.
func (c *Client) registerBuiltins() {
c.debug.Print("registering built-in handlers")
c.Handlers.mu.Lock()
// Built-in things that should always be supported.
c.Handlers.register(true, true, RPL_WELCOME, HandlerFunc(handleConnect))
c.Handlers.register(true, false, PING, HandlerFunc(handlePING))
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.
c.Handlers.register(true, false, ERR_NICKNAMEINUSE, HandlerFunc(nickCollisionHandler))
c.Handlers.register(true, false, ERR_NICKCOLLISION, HandlerFunc(nickCollisionHandler))
c.Handlers.register(true, false, ERR_UNAVAILRESOURCE, HandlerFunc(nickCollisionHandler))
if c.Config.disableTracking {
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
c.Handlers.mu.Unlock()
}
// 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
// users on connect.
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)
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)
c.mu.RLock()
server := c.server()
c.mu.RUnlock()
c.RunHandlers(&Event{Command: CONNECTED, Params: []string{server}})
}
// nickCollisionHandler helps prevent the client from having conflicting
// nicknames with another bot, user, etc.
//
//goland:noinspection GoUnusedParameter
func nickCollisionHandler(c *Client, e Event) {
if c.Config.HandleNickCollide == nil {
c.Cmd.Nick(c.GetNick() + "_")
return
}
newNick := c.Config.HandleNickCollide(c.GetNick())
if newNick != "" {
c.Cmd.Nick(newNick)
}
c.Cmd.Nick(c.Config.HandleNickCollide(c.GetNick()))
}
// handlePING helps respond to ping requests from the server.
@ -145,9 +116,10 @@ func handlePING(c *Client, e Event) {
c.Cmd.Pong(e.Last())
}
//goland:noinspection GoUnusedParameter
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.
@ -158,9 +130,12 @@ func handleJOIN(c *Client, e Event) {
channelName := e.Params[0]
c.state.Lock()
channel := c.state.lookupChannel(channelName)
if channel == nil {
if ok := c.state.createChannel(channelName); !ok {
c.state.Unlock()
return
}
@ -169,7 +144,8 @@ func handleJOIN(c *Client, e Event) {
user := c.state.lookupUser(e.Source.Name)
if user == nil {
if _, ok := c.state.createUser(e.Source); !ok {
if ok := c.state.createUser(e.Source); !ok {
c.state.Unlock()
return
}
user = c.state.lookupUser(e.Source.Name)
@ -177,8 +153,8 @@ func handleJOIN(c *Client, e Event) {
defer c.state.notify(c, UPDATE_STATE)
channel.addUser(user.Nick.Load().(string), user)
user.addChannel(channel.Name, channel)
channel.addUser(user.Nick)
user.addChannel(channel.Name)
// Assume extended-join (ircv3).
if len(e.Params) >= 2 {
@ -190,6 +166,7 @@ func handleJOIN(c *Client, e Event) {
user.Extras.Name = e.Params[2]
}
}
c.state.Unlock()
if e.Source.ID() == c.GetID() {
// 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
// cleaner method to do this.
c.state.ident.Store(e.Source.Ident)
c.state.host.Store(e.Source.Host)
c.state.Lock()
c.state.ident = e.Source.Ident
c.state.host = e.Source.Host
c.state.Unlock()
return
}
@ -216,11 +195,7 @@ func handlePART(c *Client, e Event) {
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?
// er yes, but needs a test case
channel := e.Params[0]
@ -230,42 +205,16 @@ func handlePART(c *Client, e Event) {
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() {
c.state.Lock()
c.state.deleteChannel(channel)
c.state.Unlock()
return
}
c.state.Lock()
c.state.deleteUser(channel, e.Source.ID())
}
// 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)
c.state.Unlock()
}
// handleTOPIC handles incoming TOPIC events and keeps channel tracking info
@ -281,14 +230,15 @@ func handleTOPIC(c *Client, e Event) {
name = e.Params[1]
}
c.state.Lock()
channel := c.state.lookupChannel(name)
if channel == nil {
c.state.Unlock()
return
}
channel.Topic = e.Last()
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
@ -333,35 +283,22 @@ func handleWHO(c *Client, e Event) {
}
}
c.state.Lock()
user := c.state.lookupUser(nick)
if user == nil {
usr, _ := c.state.createUser(&Source{nick, ident, host})
usr.Extras.Name = realname
if account != "0" {
usr.Extras.Account = account
}
c.state.notify(c, UPDATE_STATE)
c.state.Unlock()
return
}
user.Host = host
user.Ident.Store(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.Ident = ident
user.Extras.Name = realname
if account != "0" {
user.Extras.Account = account
}
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
@ -376,16 +313,16 @@ func handleKICK(c *Client, e Event) {
defer c.state.notify(c, UPDATE_STATE)
if e.Params[1] == c.GetNick() {
c.state.Lock()
c.state.deleteChannel(e.Params[0])
c.state.Unlock()
return
}
// Assume it's just another user.
c.state.Lock()
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
@ -395,10 +332,12 @@ func handleNICK(c *Client, e Event) {
return
}
c.state.Lock()
// renameUser updates the LastActive time automatically.
if len(e.Params) >= 1 {
c.state.renameUser(e.Source.ID(), e.Last())
}
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
@ -412,101 +351,32 @@ func handleQUIT(c *Client, e Event) {
return
}
c.state.Lock()
c.state.deleteUser("", e.Source.ID())
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
func handleGLOBALUSERS(c *Client, e Event) {
cusers, err := strconv.Atoi(e.Params[0])
if err != nil {
// handleMYINFO handles incoming MYINFO events -- these are commonly used
// to tell us what the server name is, what version of software is being used
// 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
}
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) {
cusers, err := strconv.Atoi(e.Params[1])
if err != nil {
return
}
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.Lock()
c.state.serverOptions["SERVER"] = e.Params[1]
c.state.serverOptions["VERSION"] = e.Params[2]
c.state.Unlock()
c.state.notify(c, UPDATE_GENERAL)
}
// 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) {
// Must be a ISUPPORT-based message.
@ -520,26 +390,62 @@ func handleISUPPORT(c *Client, e Event) {
return
}
c.state.Lock()
// Skip the first parameter, as it's our nickname, and the last, as it's the doc.
for i := range e.Params {
split := strings.Split(e.Params[i], "=")
for i := 1; i < len(e.Params)-1; i++ {
j := strings.IndexByte(e.Params[i], '=')
if len(split) != 2 {
c.state.serverOptions.Set(e.Params[i], "")
if j < 1 || (j+1) == len(e.Params[i]) {
c.state.serverOptions[e.Params[i]] = ""
continue
}
if len(split[0]) < 1 || len(split[1]) < 1 {
c.state.serverOptions.Set(e.Params[i], "")
continue
}
if split[0] == "NETWORK" {
c.state.network.Store(split[1])
}
c.state.serverOptions.Set(split[0], split[1])
name := e.Params[i][0:j]
val := e.Params[i][j+1:]
c.state.serverOptions[name] = val
}
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)
}
@ -547,19 +453,24 @@ func handleISUPPORT(c *Client, e Event) {
// handleMOTD handles incoming MOTD messages and buffers them up for use with
// Client.ServerMOTD().
func handleMOTD(c *Client, e Event) {
c.state.Lock()
defer c.state.notify(c, UPDATE_GENERAL)
// Beginning of the MOTD.
if e.Command == RPL_MOTDSTART {
c.state.motd = ""
c.state.Unlock()
return
}
// 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 += e.Last()
c.state.Unlock()
}
// 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 ok bool
var s *Source
s := &Source{}
c.state.Lock()
for i := 0; i < len(parts); i++ {
modes, nick, ok = parseUserPrefix(parts[i])
if !ok {
@ -610,14 +522,15 @@ func handleNAMES(c *Client, e Event) {
continue
}
user.addChannel(channel.Name, channel)
channel.addUser(s.ID(), user)
user.addChannel(channel.Name)
channel.addUser(s.ID())
// Don't append modes, overwrite them.
perms, _ := user.Perms.Lookup(channel.Name)
perms.set(modes, false)
user.Perms.set(channel.Name, perms)
}
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
@ -630,11 +543,15 @@ func updateLastActive(c *Client, e Event) {
return
}
c.state.Lock()
// Update the users last active time, if they exist.
user := c.state.lookupUser(e.Source.Name)
if user == nil {
c.state.Unlock()
return
}
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"
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 {
@ -62,7 +64,7 @@ func possibleCapList(c *Client) map[string][]string {
if !c.Config.DisableSTS && !c.Config.SSL {
// 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
// sts negotiation).
// sts negotation).
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")
} else {
@ -104,7 +106,7 @@ func parseCap(raw string) map[string]map[string]string {
if j < 0 {
out[parts[i][:val]][option] = ""
} 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 {
caps := parseCap(e.Last())
for capab := range caps {
c.state.enabledCap.Remove(capab)
for cap := range caps {
// TODO: test the deletion.
delete(c.state.enabledCap, cap)
}
return
}
@ -191,12 +194,12 @@ func handleCAP(c *Client, e Event) {
if len(e.Params) == 3 && e.Params[1] == CAP_ACK {
enabled := strings.Split(e.Last(), " ")
for _, capab := range enabled {
if val, ok := c.state.tmpCap[capab]; ok {
c.state.enabledCap.Set(capab, val)
continue
for _, cap := range enabled {
if val, ok := c.state.tmpCap[cap]; ok {
c.state.enabledCap[cap] = val
} else {
c.state.enabledCap[cap] = nil
}
c.state.enabledCap.Remove(capab)
}
// 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
// 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
// Some things are updated in the policy depending on if the current
// connection is over tls or not.
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.
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()}})
// Don't "CAP END", since we want to authenticate.
return
@ -305,24 +309,25 @@ func handleCHGHOST(c *Client, e Event) {
return
}
c.state.Lock()
user := c.state.lookupUser(e.Source.Name)
if user != nil {
user.Ident.Store(e.Params[0])
user.Ident = e.Params[0]
user.Host = e.Params[1]
}
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
// handleAWAY handles incoming IRCv3 AWAY events, for which are sent both
// when users are no longer away, or when they are away.
func handleAWAY(c *Client, e Event) {
c.state.Lock()
user := c.state.lookupUser(e.Source.Name)
if user != nil {
user.Extras.Away = e.Last()
}
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
@ -340,9 +345,11 @@ func handleACCOUNT(c *Client, e Event) {
account = ""
}
c.state.Lock()
user := c.state.lookupUser(e.Source.Name)
if user != nil {
user.Extras.Account = account
}
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}

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

@ -24,11 +24,12 @@ func handleTags(c *Client, e Event) {
return
}
c.state.Lock()
user := c.state.lookupUser(e.Source.ID())
if user != nil {
user.Extras.Account = account
}
c.state.Unlock()
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
// data, not a full message. For example:
//
// @aaa=bbb;ccc;example.com/ddd=eee
//
// @aaa=bbb;ccc;example.com/ddd=eee
// 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
// 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
// this is not concurrent safe.
func (t Tags) Set(key, value string) error {
if t == nil {
t = make(Tags)
}
if !validTag(key) {
return fmt.Errorf("tag key %q is invalid", key)
}

@ -16,7 +16,6 @@ func TestCapSupported(t *testing.T) {
User: "user",
SASL: &SASLPlain{User: "test", Pass: "example"},
SupportedCaps: map[string][]string{"example": nil},
// Debug: os.Stdout,
})
var ok bool
@ -34,36 +33,26 @@ func TestCapSupported(t *testing.T) {
}
}
var testsParseCap = []struct {
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: "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{
"userhost-in-names": nil,
"example/name": nil,
"example/name2": {"test": "1", "test2": "true"},
func TestParseCap(t *testing.T) {
tests := []struct {
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: "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{
"userhost-in-names": nil,
"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) {
_ = parseCap(orig)
})
}
func TestParseCap(t *testing.T) {
for _, tt := range testsParseCap {
for _, tt := range tests {
got := parseCap(tt.in)
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.
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")
}
}

329
client.go

@ -7,10 +7,10 @@ package girc
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"os"
@ -19,10 +19,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
cmap "github.com/orcaman/concurrent-map/v2"
)
// 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
// vice versa.
mu sync.RWMutex
// IRCd encapsulates IRC Server details.
IRCd Server
// stop is used to communicate with Connect(), letting it know that the
// client wishes to cancel/close.
stop context.CancelFunc
@ -64,32 +57,6 @@ type Client struct {
conn *ircConn
// debug is used if a writer is supplied for Client.Config.Debugger.
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
@ -179,20 +146,9 @@ type Config struct {
// to the server if supported.
SupportedCaps map[string][]string
// 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
// 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
// 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
@ -212,9 +168,6 @@ type Config struct {
// 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_",
// 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)
}
@ -224,13 +177,13 @@ type Config struct {
// server.
//
// Client expectations:
// - Perform any proxy resolution.
// - Check the reverse DNS and forward DNS match.
// - Check the IP against suitable access controls (ipaccess, dnsbl, etc).
// - Perform any proxy resolution.
// - Check the reverse DNS and forward DNS match.
// - Check the IP against suitable access controls (ipaccess, dnsbl, etc).
//
// More information:
// - https://ircv3.net/specs/extensions/webirc.html
// - https://kiwiirc.com/docs/webirc
// - https://ircv3.net/specs/extensions/webirc.html
// - https://kiwiirc.com/docs/webirc
type WebIRC struct {
// Password that authenticates the WEBIRC command from this client.
Password string
@ -276,10 +229,10 @@ func (conf *Config) isValid() error {
}
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) {
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
@ -299,15 +252,6 @@ func New(config Config) *Client {
initTime: time.Now(),
}
c.IRCd = Server{
Network: atomic.Value{},
Version: "",
UserCount: 0,
MaxUserCount: 0,
}
c.IRCd.Network.Store("")
c.Cmd = &Commands{c: c}
if c.Config.PingDelay >= 0 && c.Config.PingDelay < (20*time.Second) {
@ -321,7 +265,7 @@ func New(config Config) *Client {
if envDebug {
c.debug = log.New(os.Stderr, "debug:", log.Ltime|log.Lshortfile)
} else {
c.debug = log.New(io.Discard, "", 0)
c.debug = log.New(ioutil.Discard, "", 0)
}
} else {
if envDebug {
@ -333,26 +277,18 @@ func New(config Config) *Client {
c.debug.Print("initializing debugging")
}
envDisableSTS, _ := strconv.ParseBool(os.Getenv("GIRC_DISABLE_STS"))
envDisableSTS, _ := strconv.ParseBool((os.Getenv("GIRC_DISABLE_STS")))
if envDisableSTS {
c.Config.DisableSTS = envDisableSTS
}
// Setup the caller.
c.Handlers = newCaller(c, c.debug)
c.Handlers = newCaller(c.debug)
// Give ourselves a new 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 = &state{}
c.state.reset(true)
c.state.client = c
// Register builtin handlers.
c.registerBuiltins()
@ -377,11 +313,16 @@ func (c *Client) String() string {
// connection wasn't established using TLS (see ErrConnNotTLS), or if the
// client isn't connected.
func (c *Client) TLSConnectionState() (*tls.ConnectionState, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.conn == nil {
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
}
@ -402,10 +343,12 @@ var ErrConnNotTLS = errors.New("underlying connection is not tls")
// safe to call multiple times. See Connect()'s documentation on how
// handlers and goroutines are handled when disconnected from the server.
func (c *Client) Close() {
c.mu.RLock()
if c.stop != nil {
c.debug.Print("requesting client to stop")
c.stop()
}
c.mu.RUnlock()
}
// 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()
}
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")
defer c.debug.Print("closing execLoop")
defer atomic.AddInt32(working, -1)
var event *Event
for {
@ -460,6 +401,7 @@ func (c *Client) execLoop(ctx context.Context, errs chan error, working *int32)
}
done:
wg.Done()
return
case event = <-c.rx:
if event != nil && event.Command == ERROR {
@ -490,7 +432,9 @@ func (c *Client) DisableTracking() {
c.Config.disableTracking = true
c.Handlers.clearInternal()
c.state.channels.Clear()
c.state.Lock()
c.state.channels = nil
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
c.registerBuiltins()
@ -498,6 +442,9 @@ func (c *Client) DisableTracking() {
// Server returns the string representation of host+port pair for the connection.
func (c *Client) Server() string {
c.state.Lock()
defer c.state.Lock()
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
// server.
func (c *Client) Uptime() (up time.Time, err error) {
func (c *Client) Uptime() (up *time.Time, err error) {
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
}
@ -535,40 +486,43 @@ func (c *Client) ConnSince() (since *time.Duration, err error) {
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
}
// IsConnected returns true if the client is connected to the server.
func (c *Client) IsConnected() bool {
if c == nil {
return false
}
c.mu.RLock()
if c.conn == nil {
c.mu.RUnlock()
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
// tracking is disabled.
func (c *Client) GetNick() string {
if c == nil {
return ""
}
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 n
return c.state.nick
}
// 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 {
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.state.ident.Load().(string)
return c.state.ident
}
// 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) {
c.panicIfNotTracking()
host = c.state.host.Load().(string)
c.state.RLock()
host = c.state.host
c.state.RUnlock()
return host
}
@ -605,15 +563,12 @@ func (c *Client) GetHost() (host string) {
func (c *Client) ChannelList() []string {
c.panicIfNotTracking()
channels := make([]string, 0, len(c.state.channels.Keys()))
for channel := range c.state.channels.IterBuffered() {
chn := channel.Val
if !chn.UserIn(c.GetNick()) {
continue
}
channels = append(channels, chn.Name)
c.state.RLock()
channels := make([]string, 0, len(c.state.channels))
for channel := range c.state.channels {
channels = append(channels, c.state.channels[channel].Name)
}
c.state.RUnlock()
sort.Strings(channels)
return channels
}
@ -623,11 +578,12 @@ func (c *Client) ChannelList() []string {
func (c *Client) Channels() []*Channel {
c.panicIfNotTracking()
channels := make([]*Channel, 0, c.state.channels.Count())
for channel := range c.state.channels.IterBuffered() {
chn := channel.Val
channels = append(channels, chn.Copy())
c.state.RLock()
channels := make([]*Channel, 0, len(c.state.channels))
for channel := range c.state.channels {
channels = append(channels, c.state.channels[channel].Copy())
}
c.state.RUnlock()
sort.Slice(channels, func(i, j int) bool {
return channels[i].Name < channels[j].Name
@ -640,15 +596,12 @@ func (c *Client) Channels() []*Channel {
func (c *Client) UserList() []string {
c.panicIfNotTracking()
users := make([]string, 0, c.state.users.Count())
for user := range c.state.users.IterBuffered() {
usr := user.Val
if usr.Stale {
continue
}
users = append(users, usr.Nick.Load().(string))
c.state.RLock()
users := make([]string, 0, len(c.state.users))
for user := range c.state.users {
users = append(users, c.state.users[user].Nick)
}
c.state.RUnlock()
sort.Strings(users)
return users
}
@ -658,14 +611,15 @@ func (c *Client) UserList() []string {
func (c *Client) Users() []*User {
c.panicIfNotTracking()
users := make([]*User, 0, c.state.users.Count())
for user := range c.state.users.IterBuffered() {
usr := user.Val
users = append(users, usr.Copy())
c.state.RLock()
users := make([]*User, 0, len(c.state.users))
for user := range c.state.users {
users = append(users, c.state.users[user].Copy())
}
c.state.RUnlock()
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
}
@ -678,8 +632,9 @@ func (c *Client) LookupChannel(name string) (channel *Channel) {
return nil
}
c.state.RLock()
channel = c.state.lookupChannel(name).Copy()
c.state.RUnlock()
return channel
}
@ -691,49 +646,59 @@ func (c *Client) LookupUser(nick string) (user *User) {
return nil
}
c.state.RLock()
user = c.state.lookupUser(nick).Copy()
c.state.RUnlock()
return user
}
// IsInChannel returns true if the client is in channel. Panics if tracking
// is disabled.
// TODO: make sure this still works.
func (c *Client) IsInChannel(channel string) (in bool) {
c.panicIfNotTracking()
_, in = c.state.channels.Get(ToRFC1459(channel))
c.state.RLock()
_, in = c.state.channels[ToRFC1459(channel)]
c.state.RUnlock()
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).
// Will panic if used when tracking has been disabled. Examples of usage:
//
// nickLen, success := GetServerOpt("MAXNICKLEN")
func (c *Client) GetServerOpt(key string) (result string, ok bool) {
// nickLen, success := GetServerOption("MAXNICKLEN")
//
func (c *Client) GetServerOption(key string) (result string, ok bool) {
c.panicIfNotTracking()
result, ok = c.state.serverOptions.Get(key)
if !ok {
return "", ok
}
if len(result) > 0 {
ok = true
}
c.state.RLock()
result, ok = c.state.serverOptions[key]
c.state.RUnlock()
return result, ok
}
// GetServerOptions retrieves all of a server's capability settings that were retrieved
// during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL).
func (c *Client) GetServerOptions() []byte {
o := make(map[string]string)
for opt := range c.state.serverOptions.IterBuffered() {
o[opt.Key] = opt.Val
// GetServerOptionInt retrieves a server capability setting (as an integer) that was
// retrieved during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL).
// Will panic if used when tracking has been disabled. Examples of usage:
//
// nickLen, success := GetServerOption("MAXNICKLEN")
//
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".
@ -741,21 +706,8 @@ func (c *Client) GetServerOptions() []byte {
// Will panic if used when tracking has been disabled.
func (c *Client) NetworkName() (name string) {
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
}
@ -766,7 +718,7 @@ func (c *Client) NetworkName() (name string) {
func (c *Client) ServerVersion() (version string) {
c.panicIfNotTracking()
version, _ = c.GetServerOpt("VERSION")
version, _ = c.GetServerOption("VERSION")
return version
}
@ -775,14 +727,21 @@ func (c *Client) ServerVersion() (version string) {
func (c *Client) ServerMOTD() (motd string) {
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
// by determining the difference in time between when we ping the server, and
// when we receive a pong.
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 {
return 0
@ -803,24 +762,38 @@ func (c *Client) HasCapability(name string) (has bool) {
name = strings.ToLower(name)
for capab := range c.state.enabledCap.IterBuffered() {
key := strings.ToLower(capab.Key)
c.state.RLock()
for key := range c.state.enabledCap {
key = strings.ToLower(key)
if key == name {
has = true
break
}
}
c.state.RUnlock()
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
// disabled. Adds useful info like what function specifically, and where it
// was called from.
func (c *Client) panicIfNotTracking() {
if c == nil {
return
}
if !c.Config.disableTracking {
return
}
@ -848,10 +821,8 @@ func (c *Client) debugLogEvent(e *Event, dropped bool) {
}
if c.Config.Out != nil {
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",
})
if client.Handlers.internal.len() < 1 {
if len(client.Handlers.internal) < 1 {
t.Fatal("Client.Handlers empty, though just initialized")
}
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")
}
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")
}
}
@ -93,24 +96,14 @@ func TestClientLifetime(t *testing.T) {
func TestClientUptime(t *testing.T) {
c, conn, server := genMockConn()
defer func() {
if err := conn.Close(); err != nil {
t.Errorf("failed to close connection: %s", err)
}
if err := server.Close(); err != nil {
t.Errorf("failed to close server: %s", err)
}
}()
defer conn.Close()
defer server.Close()
go mockReadBuffer(conn)
done := make(chan struct{}, 1)
c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) })
go func() {
if err := c.MockConnect(server); err != nil {
t.Errorf("failed to connect: %s", err)
}
}()
go c.MockConnect(server)
defer c.Close()
select {
@ -124,7 +117,7 @@ func TestClientUptime(t *testing.T) {
t.Fatalf("Client.Uptime() = %s, wanted time", err)
}
since := time.Since(uptime)
since := time.Since(*uptime)
connsince, err := c.ConnSince()
if err != nil {
t.Fatalf("Client.ConnSince() = %s, wanted time", err)
@ -148,24 +141,14 @@ func TestClientUptime(t *testing.T) {
func TestClientGet(t *testing.T) {
c, conn, server := genMockConn()
defer func() {
if err := conn.Close(); err != nil {
t.Errorf("failed to close connection: %s", err)
}
if err := server.Close(); err != nil {
t.Errorf("failed to close server: %s", err)
}
}()
defer conn.Close()
defer server.Close()
go mockReadBuffer(conn)
done := make(chan struct{}, 1)
c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) })
go func() {
if err := c.MockConnect(server); err != nil {
t.Errorf("failed to connect: %s", err)
}
}()
go c.MockConnect(server)
defer c.Close()
select {
@ -189,14 +172,8 @@ func TestClientGet(t *testing.T) {
func TestClientClose(t *testing.T) {
c, conn, server := genMockConn()
defer func() {
if err := conn.Close(); err != nil {
t.Errorf("failed to close connection: %s", err)
}
if err := server.Close(); err != nil {
t.Errorf("failed to close server: %s", err)
}
}()
defer server.Close()
defer conn.Close()
go mockReadBuffer(conn)
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) {
// We can join multiple channels at once, however we need to ensure that
// we are not exceeding the line length. (see maxLength)
max := maxLength - len(JOIN) - 1
max := cmd.c.MaxEventLength() - len(JOIN) - 1
var buffer string
@ -36,7 +36,7 @@ func (cmd *Commands) Join(channels ...string) {
continue
}
if buffer == "" {
if len(buffer) == 0 {
buffer = channels[i]
} else {
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
// user).
func (cmd *Commands) Messagef(target, format string, a ...interface{}) {
message := fmt.Sprintf(format, a...)
cmd.Message(target, Fmt(message))
cmd.Message(target, fmt.Sprintf(format, a...))
}
// 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.)
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
// originated from. See also ReplyTo(). Panics if the incoming event has no
// source.
func (cmd *Commands) Reply(event Event, message string) error {
func (cmd *Commands) Reply(event Event, message string) {
if event.Source == nil {
return ErrInvalidSource
panic(ErrInvalidSource)
}
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Message(event.Params[0], message)
return nil
return
}
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
// where the supplied event originated from. See also ReplyTof(). 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) Replyf(event Event, format string, a ...interface{}) error {
message := fmt.Sprintf(format, a...)
return cmd.Reply(event, Fmt(message))
func (cmd *Commands) Replyf(event Event, format string, a ...interface{}) {
cmd.Reply(event, fmt.Sprintf(format, a...))
}
// ReplyTo sends a reply to a channel or user, based on where the supplied
// event originated from. ReplyTo(), when originating from a channel will
// default to replying with "<user>, <message>". See also Reply(). Panics if
// 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 {
return ErrInvalidSource
panic(ErrInvalidSource)
}
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Message(event.Params[0], event.Source.Name+", "+message)
} else {
cmd.Message(event.Source.Name, message)
return
}
return nil
cmd.Message(event.Source.Name, message)
}
// ReplyTof sends a reply to a channel or user with a format string, based
// on where the supplied event originated from. ReplyTo(), when originating
// from a channel will default to replying with "<user>, <message>". See
// 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{}) error {
message := fmt.Sprintf(format, a...)
return cmd.ReplyTo(event, Fmt(message))
func (cmd *Commands) ReplyTof(event Event, format string, a ...interface{}) {
cmd.ReplyTo(event, fmt.Sprintf(format, a...))
}
// 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
// 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{}) {
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
@ -244,9 +212,9 @@ func (cmd *Commands) SendRaw(raw ...string) error {
}
// 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 {
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
@ -255,19 +223,13 @@ func (cmd *Commands) Topic(channel, message string) {
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.
// 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) {
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})
}
// 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
// channel, with reason. If reason is blank, one will not be sent to the
// server.
@ -321,6 +267,7 @@ func (cmd *Commands) Kick(channel, user, reason string) {
if reason != "" {
cmd.c.Send(&Event{Command: KICK, Params: []string{channel, user, reason}})
}
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 are not exceeding the line length. (see maxLength)
max := maxLength - len(JOIN) - 1
max := cmd.c.MaxEventLength() - len(JOIN) - 1
var buffer string
@ -393,7 +340,7 @@ func (cmd *Commands) List(channels ...string) {
continue
}
if buffer == "" {
if len(buffer) == 0 {
buffer = channels[i]
} else {
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
// you want back.
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

230
conn.go

@ -10,10 +10,8 @@ import (
"crypto/tls"
"fmt"
"net"
"sync/atomic"
"sync"
"time"
"git.tcp.direct/kayos/common/pool"
)
// Messages are delimited with CR and LF line endings, we're using the last
@ -28,23 +26,25 @@ type ircConn struct {
io *bufio.ReadWriter
sock net.Conn
mu sync.RWMutex
// 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,
// 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
// the server.
writeDelay atomic.Value
writeDelay time.Duration
// 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 atomic.Value
connTime *time.Time
// 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
// 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
@ -57,8 +57,6 @@ type Dialer interface {
Dial(network, address string) (net.Conn, error)
}
var strs = pool.NewStringFactory()
// newConn sets up and returns a new connection to the server.
func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ircConn, error) {
if err := conf.isValid(); err != nil {
@ -73,11 +71,7 @@ func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ir
if conf.Bind != "" {
var local *net.TCPAddr
s := strs.Get()
s.MustWriteString(conf.Bind)
s.MustWriteString(":0")
local, err = net.ResolveTCPAddr("tcp", s.String())
strs.MustPut(s)
local, err = net.ResolveTCPAddr("tcp", conf.Bind+":0")
if err != nil {
return nil, err
}
@ -118,27 +112,25 @@ func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ir
conn = tlsConn
}
ctime := time.Now()
c := &ircConn{
sock: conn,
connTime: atomic.Value{},
connected: atomic.Value{},
connTime: &ctime,
connected: true,
}
c.connTime.Store(time.Now())
c.connected.Store(true)
c.newReadWriter()
return c, nil
}
func newMockConn(conn net.Conn) *ircConn {
ctime := time.Now()
c := &ircConn{
sock: conn,
connTime: atomic.Value{},
connected: atomic.Value{},
connTime: &ctime,
connected: true,
}
c.connTime.Store(time.Now())
c.connected.Store(true)
c.newReadWriter()
return c
@ -151,17 +143,6 @@ type ErrParseEvent struct {
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) {
line, err := c.io.ReadString(delim)
if err != nil {
@ -175,6 +156,17 @@ func (c *ircConn) decode() (event *Event, err error) {
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() {
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 {
startConn:
// We want to be the only one handling connects/disconnects right now.
c.mu.Lock()
if c.conn != nil {
panic("use of connect more than once")
}
@ -281,7 +276,7 @@ startConn:
if mock == nil {
// 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)
if err != nil {
if _, ok := err.(*ErrSTSUpgradeFailed); ok {
@ -289,6 +284,7 @@ startConn:
c.RunHandlers(&Event{Command: STS_ERR_FALLBACK})
}
}
c.mu.Unlock()
return err
}
@ -299,16 +295,17 @@ startConn:
var ctx context.Context
ctx, c.stop = context.WithCancel(context.Background())
c.mu.Unlock()
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
// returns.
atomic.AddInt32(&working, 4)
go c.execLoop(ctx, errs, &working)
go c.readLoop(ctx, errs, &working)
go c.sendLoop(ctx, errs, &working)
go c.pingLoop(ctx, errs, &working)
wg.Add(4)
go c.execLoop(ctx, errs, &wg)
go c.readLoop(ctx, errs, &wg)
go c.sendLoop(ctx, errs, &wg)
go c.pingLoop(ctx, errs, &wg)
// Passwords first.
@ -329,9 +326,7 @@ startConn:
c.listCAP()
// Then nickname.
c.state.RLock()
c.write(&Event{Command: NICK, Params: []string{c.Config.Nick}})
c.state.RUnlock()
// Then username and realname.
if c.Config.Name == "" {
@ -352,41 +347,40 @@ startConn:
}
c.RunHandlers(&Event{Command: CLOSED, Params: []string{addr}})
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
}
// Make sure that the connection is closed if not already.
c.mu.RLock()
if c.stop != nil {
c.stop()
}
c.conn.connected.Store(false)
c.conn.mu.Lock()
c.conn.connected = false
_ = c.conn.Close()
c.conn.mu.Unlock()
c.mu.RUnlock()
c.RunHandlers(&Event{Command: DISCONNECTED, Params: []string{addr}})
// Once we have our error/result, let all other functions know we're done.
c.debug.Print("waiting for all routines to finish")
for {
if atomic.LoadInt32(&working) <= 0 {
break
}
}
// Wait for all goroutines to finish.
wg.Wait()
close(errs)
// 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
// clients, not multiple instances of Connect().
c.mu.Lock()
c.conn = nil
if result == nil {
if c.state.sts.beginUpgrade {
c.state.sts.beginUpgrade = false
c.mu.Unlock()
goto startConn
}
@ -394,13 +388,14 @@ startConn:
c.state.sts.persistenceReceived = time.Now()
}
}
c.mu.Unlock()
return result
}
// readLoop sets a timeout of 300 seconds, and then attempts to read from the
// 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")
defer c.debug.Print("closing readLoop")
@ -410,19 +405,17 @@ func (c *Client) readLoop(ctx context.Context, errs chan error, working *int32)
for {
select {
case <-ctx.Done():
atomic.AddInt32(working, -1)
wg.Done()
return
default:
_ = c.conn.sock.SetReadDeadline(time.Now().Add(300 * time.Second))
event, err = c.conn.decode()
if err != nil {
errs <- err
atomic.AddInt32(working, -1)
wg.Done()
return
}
event.Network = c.NetworkName()
// Check if it's an echo-message.
if !c.Config.disableTracking {
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
// simply looking to trigger handlers with an event.
// Send sends an event to the server. Send will split events if the event is longer than
// 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) {
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] != "" &&
(event.Command == PRIVMSG || event.Command == TOPIC || event.Command == NOTICE) {
event.Params[len(event.Params)-1] = Fmt(event.Params[len(event.Params)-1])
}
<-time.After(delay)
c.write(event)
var events []*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-delay when sending events.
func (c *Client) write(event *Event) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.conn == nil {
// Drop the event if disconnected.
c.debugLogEvent(event, true)
@ -477,30 +483,21 @@ func (c *Client) write(event *Event) {
func (c *ircConn) rate(chars int) time.Duration {
_time := time.Second + ((time.Duration(chars) * time.Second) / 100)
if c.writeDelay.Load() == nil {
c.writeDelay.Store(time.Duration(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 += _time - time.Now().Sub(c.lastWrite); c.writeDelay < 0 {
c.writeDelay = 0
}
if c.writeDelay.Load().(time.Duration) > (8 * time.Second) {
if c.writeDelay > (8 * time.Second) {
return _time
}
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")
defer c.debug.Print("closing sendLoop")
defer atomic.AddInt32(working, -1)
var err error
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
// isn't a supported capability, remove them from the event.
if event.Tags != nil {
c.state.RLock()
var in bool
for i := 0; i < c.state.enabledCap.Count(); i++ {
if _, ok := c.state.enabledCap.Get("message-tags"); ok {
for i := 0; i < len(c.state.enabledCap); i++ {
if _, ok := c.state.enabledCap["message-tags"]; ok {
in = true
break
}
}
c.state.RUnlock()
if !in {
event.Tags = Tags{}
@ -524,11 +523,13 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, working *int32)
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 {
c.conn.lastActive = c.conn.lastWrite
}
c.conn.mu.Unlock()
// Write the raw line.
_, 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 {
c.Close()
wg.Done()
return
}
if err != nil {
errs <- err
wg.Done()
return
}
case <-ctx.Done():
wg.Done()
return
}
}
@ -571,25 +575,26 @@ type ErrTimedOut struct {
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) {
defer atomic.AddInt32(working, -1)
func (c *Client) pingLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
// Don't run the pingLoop if they want to disable it.
if c.Config.PingDelay <= 0 {
wg.Done()
return
}
c.debug.Print("starting pingLoop")
defer c.debug.Print("closing pingLoop")
c.conn.lastPing.Store(time.Now())
c.conn.lastPong.Store(time.Now())
c.conn.mu.Lock()
c.conn.lastPing = time.Now()
c.conn.lastPong = time.Now()
c.conn.mu.Unlock()
tick := time.NewTicker(c.Config.PingDelay)
defer tick.Stop()
started := time.Now()
past := false
pingSent := false
for {
select {
@ -604,27 +609,30 @@ func (c *Client) pingLoop(ctx context.Context, errs chan error, working *int32)
past = true
}
if pingSent && time.Since(c.conn.lastPong.Load().(time.Time)) > c.Config.PingDelay+(180*time.Second) {
// It's 180 seconds over what out ping delay is, connection has probably dropped.
err := ErrTimedOut{
TimeSinceSuccess: time.Since(c.conn.lastPong.Load().(time.Time)),
LastPong: c.conn.lastPong.Load().(time.Time),
LastPing: c.conn.lastPing.Load().(time.Time),
c.conn.mu.RLock()
if time.Since(c.conn.lastPong) > c.Config.PingDelay+(60*time.Second) {
// It's 60 seconds over what out ping delay is, connection
// has probably dropped.
errs <- ErrTimedOut{
TimeSinceSuccess: time.Since(c.conn.lastPong),
LastPong: c.conn.lastPong,
LastPing: c.conn.lastPing,
Delay: c.Config.PingDelay,
}
go func() {
errs <- err
}()
wg.Done()
c.conn.mu.RUnlock()
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()))
pingSent = true
case <-ctx.Done():
wg.Done()
return
}
}

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

@ -5,8 +5,6 @@
package girc
// Standard CTCP based constants.
//
//goland:noinspection ALL
const (
CTCP_ACTION = "ACTION"
CTCP_PING = "PING"
@ -22,8 +20,6 @@ const (
// Emulated event commands used to allow easier hooks into the changing
// state of the client.
//
//goland:noinspection ALL
const (
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.
@ -37,8 +33,6 @@ const (
)
// User/channel prefixes :: RFC1459.
//
//goland:noinspection ALL
const (
DefaultPrefixes = "(ov)@+" // the most common default prefixes
ModeAddPrefix = "+" // modes are being added
@ -54,8 +48,6 @@ const (
)
// User modes :: RFC1459; section 4.2.3.2.
//
//goland:noinspection ALL
const (
UserModeInvisible = "i" // invisible
UserModeOperator = "o" // server operator
@ -64,8 +56,6 @@ const (
)
// Channel modes :: RFC1459; section 4.2.3.1.
//
//goland:noinspection ALL
const (
ModeDefaults = "beI,k,l,imnpst" // the most common default modes
@ -85,8 +75,6 @@ const (
)
// IRC commands :: RFC2812; section 3 :: RFC2813; section 4.
//
//goland:noinspection ALL
const (
ADMIN = "ADMIN"
AWAY = "AWAY"
@ -139,8 +127,6 @@ const (
)
// Numeric IRC reply mapping :: RFC2812; section 5.
//
//goland:noinspection ALL
const (
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
@ -284,8 +270,6 @@ const (
)
// IRCv3 commands and extensions :: http://ircv3.net/irc/.
//
//goland:noinspection ALL
const (
AUTHENTICATE = "AUTHENTICATE"
MONITOR = "MONITOR"
@ -309,8 +293,6 @@ const (
)
// Numeric IRC reply mapping for ircv3 :: http://ircv3.net/irc/.
//
//goland:noinspection ALL
const (
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
@ -331,8 +313,6 @@ const (
)
// Numeric IRC event mapping :: RFC2812; section 5.3.
//
//goland:noinspection ALL
const (
RPL_STATSCLINE = "213"
RPL_STATSNLINE = "214"
@ -361,22 +341,10 @@ const (
)
// Misc.
//
//goland:noinspection ALL
const (
ERR_TOOMANYMATCHES = "416" // IRCNet.
RPL_GLOBALUSERS = "266" // aircd/hybrid/bahamut, used on freenode.
RPL_LOCALUSERS = "265" // aircd/hybrid/bahamut, used on freenode.
RPL_TOPICWHOTIME = "333" // ircu, used on freenode.
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
import (
"fmt"
"runtime"
"strings"
"sync"
"time"
cmap "github.com/orcaman/concurrent-map/v2"
)
// 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
// string as input.
func EncodeCTCPRaw(cmd, text string) (out string) {
if cmd == "" {
return cmd
if len(cmd) <= 0 {
return ""
}
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 sync.RWMutex
// handlers is a map of CTCP message -> functions.
handlers cmap.ConcurrentMap[string, CTCPHandler]
handlers map[string]CTCPHandler
}
// newCTCP returns a new clean CTCP handler.
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
// command.
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 client.Config.RecoverFunc != nil && event.Origin != nil {
defer recoverHandlerPanic(client, event.Origin, "ctcp-"+strings.ToLower(event.Command), 3)
}
// Support wildcard CTCP event handling. Gets executed first before
// regular event handlers.
if val, ok := c.handlers.Get("*"); ok && val != nil {
val(client, *event)
if _, ok := c.handlers["*"]; ok {
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
}
val(client, *event)
c.handlers[event.Command](client, *event)
}
// 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 == "" {
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,
@ -197,12 +213,18 @@ func (c *CTCP) Clear(cmd string) {
if cmd = c.parseCMD(cmd); cmd == "" {
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.
func (c *CTCP) ClearAll() {
c.handlers = cmap.New[CTCPHandler]()
c.mu.Lock()
c.handlers = map[string]CTCPHandler{}
c.mu.Unlock()
// Register necessary handlers.
c.addDefaultHandlers()
}
@ -217,8 +239,6 @@ func (c *CTCP) addDefaultHandlers() {
c.SetBg(CTCP_PONG, handleCTCPPong)
c.SetBg(CTCP_VERSION, handleCTCPVersion)
c.SetBg(CTCP_SOURCE, handleCTCPSource)
c.SetBg(CTCP_USERINFO, handleCTCPUserInfo)
c.SetBg(CTCP_CLIENTINFO, handleCTCPClientInfo)
c.SetBg(CTCP_TIME, handleCTCPTime)
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
// 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) {
if 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(
ctcp.Source.ID(), CTCP_VERSION,
"girc-atomic %s (%s, %s)",
Version, runtime.GOOS, runtime.GOARCH,
"girc (github.com/lrstanley/girc) using %s (%s, %s)",
runtime.Version(), runtime.GOOS, runtime.GOARCH,
)
}
// handleCTCPUserInfo replies with the configured user information if available, otherwise it does not reply.
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.
// handleCTCPSource replies with the public git location of this library.
func handleCTCPSource(client *Client, ctcp CTCPEvent) {
if client.Config.Source != "" {
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")
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_SOURCE, "https://github.com/lrstanley/girc")
}
// 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
// is obsoleted by improvements to the IRC protocol, however still supported.
func handleCTCPFinger(client *Client, ctcp CTCPEvent) {
if client.Config.Finger != "" {
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_FINGER, client.Config.Finger)
return
}
// irssi doesn't appear to do this on a stock install so gonna just go ahead and nix it.
// 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)))
client.conn.mu.RLock()
active := client.conn.lastActive
client.conn.mu.RUnlock()
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"
"testing"
"time"
"unicode/utf8"
)
var testsEncodeCTCP = []struct {
name string
test *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)
func TestEncodeCTCP(t *testing.T) {
type args struct {
ctcp *CTCPEvent
}
f.Fuzz(func(t *testing.T, cmd, text string) {
got := EncodeCTCP(&CTCPEvent{Command: cmd, Text: text})
tests := []struct {
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) {
t.Errorf("produced invalid UTF-8 string %q", got)
}
})
}
func TestEncodeCTCP(t *testing.T) {
for _, tt := range testsEncodeCTCP {
if got := EncodeCTCP(tt.test); got != tt.want {
for _, tt := range tests {
if got := EncodeCTCP(tt.args.ctcp); 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)
})
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"})
if atomic.LoadUint64(&counter) != 1 {
if ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}); atomic.LoadUint64(&counter) != 1 {
t.Fatal("regular execution: call() didn't increase counter")
}
ctcp.Clear("TEST")
@ -135,8 +120,7 @@ func TestCall(t *testing.T) {
})
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"})
time.Sleep(250 * time.Millisecond)
if atomic.LoadUint64(&counter) != 2 {
if time.Sleep(250 * time.Millisecond); atomic.LoadUint64(&counter) != 2 {
t.Fatal("goroutine execution: call() in goroutine didn't increase counter")
}
ctcp.Clear("TEST")
@ -145,15 +129,14 @@ func TestCall(t *testing.T) {
atomic.AddUint64(&counter, 1)
})
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"})
if atomic.LoadUint64(&counter) != 3 {
if ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}); atomic.LoadUint64(&counter) != 3 {
t.Fatal("wildcard execution: call() didn't increase counter")
}
ctcp.Clear("*")
ctcp.Clear("TEST")
ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"})
if atomic.LoadUint64(&counter) != 3 {
if ctcp.call(New(Config{}), &CTCPEvent{Command: "TEST"}); atomic.LoadUint64(&counter) != 3 {
t.Fatal("empty execution: call() with no handler incremented the counter")
}
}
@ -162,13 +145,13 @@ func TestSet(t *testing.T) {
ctcp := newCTCP()
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")
}
ctcp.Set("TEST", func(client *Client, event CTCPEvent) {})
// 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")
}
}
@ -179,7 +162,7 @@ func TestClear(t *testing.T) {
ctcp.Set("TEST", func(client *Client, event CTCPEvent) {})
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")
}
}
@ -191,8 +174,8 @@ func TestClearAll(t *testing.T) {
ctcp.Set("TEST2", func(client *Client, event CTCPEvent) {})
ctcp.ClearAll()
_, first := ctcp.handlers.Get("TEST1")
_, second := ctcp.handlers.Get("TEST2")
_, first := ctcp.handlers["TEST1"]
_, second := ctcp.handlers["TEST2"]
if 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 (
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.
@ -52,7 +86,7 @@ func ParseEvent(raw string) (e *Event) {
i = 0
}
if raw != "" && raw[0] == messagePrefix {
if raw[0] == messagePrefix {
// Prefix ends with a space.
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
// not be logged/shown in debugging output).
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"`
// Network represents the originating IRC network the event came from.
Network string
}
// Last returns the last parameter in Event.Params if it exists.
@ -169,16 +201,6 @@ func (e *Event) Last() string {
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
// functions/handlers edit the event without causing potential issues with
// other handlers.
@ -235,11 +257,82 @@ func (e *Event) Equals(ev *Event) bool {
return true
}
// Len calculates the length of the string representation of event. 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.
// split will split a potentially large event that is larger than what the server
// supports, into multiple events. split will ignore events that cannot be split, and
// if the event isn't longer than what the server supports, it will just return an array
// 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) {
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 {
// Include tags and trailing space.
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
// prefixed with a colon (:).
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], ":")) {
length++
}
}
@ -272,18 +364,12 @@ func (e *Event) Len() (length int) {
// Bytes returns a []byte representation of event. Strips all newlines and
// 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 {
buffer := new(bytes.Buffer)
// Tags.
if e.Tags != nil {
if _, err := e.Tags.writeTo(buffer); err != nil {
return nil
}
e.Tags.writeTo(buffer)
}
// Event prefix.
@ -298,9 +384,8 @@ func (e *Event) Bytes() []byte {
// Space separated list of arguments.
if len(e.Params) > 0 {
// buffer.WriteByte(eventSpace)
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])
continue
}
@ -308,14 +393,7 @@ func (e *Event) Bytes() []byte {
}
}
// We need the limit the buffer length.
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)
out := buffer.Bytes()
// Strip newlines and carriage returns.
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
}
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
return fmt.Sprintf("[%s] (%s) %s", strings.Join(e.Params[0:len(e.Params)-1], ","), e.Source.Name, e.Last()), true
}
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.
func (s *Source) String() (out string) {
out = ""
if len(s.Name) > 0 {
out = s.Name
}
out = s.Name
if len(s.Ident) > 0 {
out = out + string(prefixIdent) + s.Ident
}
if len(s.Host) > 0 {
out = out + string(prefixHost) + s.Host
}
return
}
@ -640,7 +708,7 @@ func (s *Source) IsHostmask() bool {
// IsServer returns true if this source looks like a server name.
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
@ -655,4 +723,6 @@ func (s *Source) writeTo(buffer *bytes.Buffer) {
buffer.WriteByte(prefixHost)
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 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 :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 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"},
@ -111,7 +108,7 @@ func TestParseEvent(t *testing.T) {
}
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 {
@ -133,7 +130,6 @@ func TestParseEvent(t *testing.T) {
}
}
//goland:noinspection GoNilness
func TestEventCopy(t *testing.T) {
var nilEvent *Event

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

118
format.go

@ -66,7 +66,7 @@ var fmtCodes = map[string]string{
//
// 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 {
var last = -1
for i := 0; i < len(text); i++ {
@ -127,10 +127,10 @@ func Fmt(text string) string {
// See Fmt() for more information.
func TrimFmt(text string) string {
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 {
text = strings.ReplaceAll(text, string(fmtOpenChar)+code+string(fmtCloseChar), "")
text = strings.Replace(text, string(fmtOpenChar)+code+string(fmtCloseChar), "", -1)
}
return text
@ -138,7 +138,7 @@ func TrimFmt(text string) string {
// This is really the only fastest way of doing this (marginally better than
// 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.
// Primarily, foreground/background colors, and other control bytes like
@ -148,7 +148,7 @@ func StripRaw(text string) string {
text = reStripColor.ReplaceAllString(text, "")
for _, code := range fmtCodes {
text = strings.ReplaceAll(text, code, "")
text = strings.Replace(text, code, "", -1)
}
return text
@ -164,12 +164,12 @@ func StripRaw(text string) string {
// all ASCII printable chars. This function will NOT do that for
// compatibility reasons.
//
// channel = ( "#" / "+" / ( "!" channelid ) / "&" ) chanstring
// [ ":" chanstring ]
// chanstring = 0x01-0x07 / 0x08-0x09 / 0x0B-0x0C / 0x0E-0x1F / 0x21-0x2B
// chanstring = / 0x2D-0x39 / 0x3B-0xFF
// ; any octet except NUL, BELL, CR, LF, " ", "," and ":"
// channelid = 5( 0x41-0x5A / digit ) ; 5( A-Z / 0-9 )
// channel = ( "#" / "+" / ( "!" channelid ) / "&" ) chanstring
// [ ":" chanstring ]
// chanstring = 0x01-0x07 / 0x08-0x09 / 0x0B-0x0C / 0x0E-0x1F / 0x21-0x2B
// chanstring = / 0x2D-0x39 / 0x3B-0xFF
// ; any octet except NUL, BELL, CR, LF, " ", "," and ":"
// channelid = 5( 0x41-0x5A / digit ) ; 5( A-Z / 0-9 )
func IsValidChannel(channel string) bool {
if len(channel) <= 1 || len(channel) > 50 {
return false
@ -214,12 +214,12 @@ func IsValidChannel(channel string) bool {
// IsValidNick validates an IRC nickname. Note that this does not validate
// IRC nickname length.
//
// nickname = ( letter / special ) *8( letter / digit / special / "-" )
// letter = 0x41-0x5A / 0x61-0x7A
// digit = 0x30-0x39
// special = 0x5B-0x60 / 0x7B-0x7D
// nickname = ( letter / special ) *8( letter / digit / special / "-" )
// letter = 0x41-0x5A / 0x61-0x7A
// digit = 0x30-0x39
// special = 0x5B-0x60 / 0x7B-0x7D
func IsValidNick(nick string) bool {
if nick == "" {
if len(nick) <= 0 {
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.
//
// Per RFC:
//
// user = 1*( %x01-09 / %x0B-0C / %x0E-1F / %x21-3F / %x41-FF )
// ; any octet except NUL, CR, LF, " " and "@"
// user = 1*( %x01-09 / %x0B-0C / %x0E-1F / %x21-3F / %x41-FF )
// ; any octet except NUL, CR, LF, " " and "@"
func IsValidUser(name string) bool {
if name == "" {
if len(name) <= 0 {
return false
}
@ -325,7 +324,7 @@ func Glob(input, match string) bool {
if len(parts) == 1 {
// No globs, test for equality.
return strings.EqualFold(input, match)
return input == match
}
leadingGlob, trailingGlob := strings.HasPrefix(match, globChar), strings.HasSuffix(match, globChar)
@ -351,3 +350,80 @@ func Glob(input, match string) bool {
// Check suffix 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 (
"strings"
"testing"
"unicode/utf8"
)
func BenchmarkFormat(b *testing.B) {
for i := 0; i < b.N; i++ {
Fmt("{red}test{c}")
}
return
}
func BenchmarkFormatLong(b *testing.B) {
for i := 0; i < b.N; i++ {
Fmt("{red}test {blue}2 {red}3 {brown} {italic}test{c}")
}
return
}
func BenchmarkStripFormat(b *testing.B) {
for i := 0; i < b.N; i++ {
TrimFmt("{red}test{c}")
}
return
}
func BenchmarkStripFormatLong(b *testing.B) {
for i := 0; i < b.N; i++ {
TrimFmt("{red}test {blue}2 {red}3 {brown} {italic}test{c}")
}
return
}
func BenchmarkStripRaw(b *testing.B) {
@ -39,6 +46,8 @@ func BenchmarkStripRaw(b *testing.B) {
for i := 0; i < b.N; i++ {
StripRaw(text)
}
return
}
func BenchmarkStripRawLong(b *testing.B) {
@ -46,287 +55,202 @@ func BenchmarkStripRawLong(b *testing.B) {
for i := 0; i < b.N; i++ {
StripRaw(text)
}
}
var testsFormat = []struct {
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)
}
}
})
return
}
func TestFormat(t *testing.T) {
for _, tt := range testsFormat {
if got := Fmt(tt.test); got != tt.want {
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)
type args struct {
text string
}
f.Fuzz(func(t *testing.T, orig string) {
got := TrimFmt(orig)
got2 := TrimFmt(got)
tests := []struct {
name string
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) {
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)
}
for _, tt := range tests {
if got := Fmt(tt.args.text); got != tt.want {
t.Errorf("%s: Format(%q) = %q, want %q", tt.name, tt.args.text, got, tt.want)
}
})
}
}
func TestStripFormat(t *testing.T) {
for _, tt := range testsStripFormat {
if got := TrimFmt(tt.test); got != tt.want {
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)
type args struct {
text string
}
f.Fuzz(func(t *testing.T, orig string) {
got := StripRaw(orig)
got2 := StripRaw(got)
tests := []struct {
name string
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) {
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)
}
for _, tt := range tests {
if got := TrimFmt(tt.args.text); got != tt.want {
t.Errorf("%s: StripFormat(%q) = %q, want %q", tt.name, tt.args.text, got, tt.want)
}
})
}
}
func TestStripRaw(t *testing.T) {
for _, tt := range testsStripRaw {
if got := StripRaw(Fmt(tt.test)); got != tt.want {
t.Fatalf("%s: StripRaw(%q) = %q, want %q", tt.name, tt.test, got, tt.want)
type args struct {
text string
}
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) {
for _, tt := range testsValidNick {
if got := IsValidNick(tt.test); got != tt.want {
t.Errorf("%s: IsValidNick(%q) = %v, want %v", tt.name, tt.test, got, tt.want)
type args struct {
nick string
}
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) {
for _, tt := range testsValidChannel {
if got := IsValidChannel(tt.test); got != tt.want {
t.Errorf("%s: IsValidChannel(%q) = %v, want %v", tt.name, tt.test, got, tt.want)
type args struct {
channel string
}
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) {
for _, tt := range testsValidUser {
if got := IsValidUser(tt.test); got != tt.want {
t.Errorf("%s: IsValidUser(%q) = %v, want %v", tt.name, tt.test, got, tt.want)
type args struct {
name string
}
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) {
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 {
t.Errorf("ToRFC1459() = %q, want %q", got, tt.want)
}
}
return
}
func BenchmarkGlob(b *testing.B) {
@ -335,23 +259,31 @@ func BenchmarkGlob(b *testing.B) {
b.Fatalf("should match")
}
}
return
}
func testGlobMatch(t *testing.T, subj, pattern string) {
if !Glob(subj, pattern) {
t.Fatalf("'%s' should match '%s'", pattern, subj)
}
return
}
func testGlobNoMatch(t *testing.T, subj, pattern string) {
if Glob(subj, pattern) {
t.Fatalf("'%s' should not match '%s'", pattern, subj)
}
return
}
func TestEmptyPattern(t *testing.T) {
testGlobMatch(t, "", "")
testGlobNoMatch(t, "test", "")
return
}
func TestEmptySubject(t *testing.T) {
@ -392,43 +324,37 @@ func TestEmptySubject(t *testing.T) {
for _, pattern := range cases {
testGlobNoMatch(t, pattern, "")
}
return
}
func TestPatternWithoutGlobs(t *testing.T) {
testGlobMatch(t, "test", "test")
}
var testsGlob = []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.
}
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)
})
return
}
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)
}
cases := []string{
cases = []string{
"test*", // Implicit substring match.
"*is", // Partial match.
"*no*", // Globs without a match between them.
@ -442,4 +368,6 @@ func TestGlob(t *testing.T) {
for _, pattern := range cases {
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
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
)
go 1.12

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"
"strings"
"sync"
"sync/atomic"
"time"
cmap "github.com/orcaman/concurrent-map/v2"
)
// RunHandlers manually runs handlers for a given event.
func (c *Client) RunHandlers(event *Event) {
if event == nil {
c.debug.Print("nil event")
return
}
s := strs.Get()
// Log the event.
s.MustWriteString("< ")
prefix := "< "
if event.Echo {
s.MustWriteString("[echo-message] ")
prefix += "[echo-message] "
}
s.MustWriteString(event.String())
c.debug.Print(s.String())
strs.MustPut(s)
c.debug.Print(prefix + StripRaw(event.String()))
if c.Config.Out != nil {
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())
if !event.Echo {
c.Handlers.exec(event.Command, false, c, event.Copy())
}
// Check if it's a CTCP.
if ctcp := DecodeCTCP(event.Copy()); ctcp != nil {
// Execute it.
c.CTCP.call(c, ctcp)
}
}
// Handler is lower level implementation of a handler. See
@ -75,87 +67,29 @@ func (f HandlerFunc) Execute(client *Client, event 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.
type Caller struct {
// mu is the mutex that should be used when accessing handlers.
mu *sync.RWMutex
parent *Client
mu sync.RWMutex
// external/internal keys are of structure:
// map[COMMAND][CUID]Handler
// Also of note: "COMMAND" should always be uppercase for normalization.
// 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 *nestedHandlers
internal map[string]map[string]Handler
// debug is the clients logger used for debugging.
debug *log.Logger
}
// newCaller creates and initializes a new handler.
func newCaller(parent *Client, debugOut *log.Logger) *Caller {
func newCaller(debugOut *log.Logger) *Caller {
c := &Caller{
external: newNestedHandlers(),
internal: newNestedHandlers(),
external: map[string]map[string]Handler{},
internal: map[string]map[string]Handler{},
debug: debugOut,
parent: parent,
mu: &sync.RWMutex{},
}
return c
@ -163,18 +97,45 @@ func newCaller(parent *Client, debugOut *log.Logger) *Caller {
// Len returns the total amount of user-entered registered handlers.
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
// registered handlers for a given command.
func (c *Caller) Count(cmd string) int {
var total int
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 {
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"
@ -212,90 +173,88 @@ type execStack struct {
// Please note that there is no specific order/priority for which the handlers
// are executed.
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.
var stack []execStack
c.mu.RLock()
// Get internal handlers first.
hmap, iok := c.internal.cm.Get(command)
if iok {
for assigned := range hmap.IterBuffered() {
cuid := assigned.Key
if _, ok := c.internal[command]; ok {
for cuid := range c.internal[command] {
if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) {
continue
}
hndlr, _ := hmap.Get(cuid)
stack = append(stack, execStack{hndlr, cuid})
stack = append(stack, execStack{c.internal[command][cuid], cuid})
}
}
// Then external handlers.
hmap, eok := c.external.cm.Get(command)
if eok {
for _, cuid := range hmap.Keys() {
if _, ok := c.external[command]; ok {
for cuid := range c.external[command] {
if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) {
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
// still help prevent mis-ordered events, while speeding up the
// execution speed.
var working int32
atomic.AddInt32(&working, int32(len(stack)))
// c.debug.Printf("starting %d jobs", atomic.LoadInt32(&working))
var wg sync.WaitGroup
wg.Add(len(stack))
for i := 0; i < len(stack); i++ {
go func(index int) {
// c.debug.Printf("(%s) [%d/%d] exec %s => %s", c.parent.Config.Nick,
// index+1, len(stack), stack[index].cuid, command)
// start := time.Now()
defer wg.Done()
c.debug.Printf("[%d/%d] exec %s => %s", index+1, len(stack), stack[index].cuid, command)
start := time.Now()
if bg {
go func() {
defer atomic.AddInt32(&working, -1)
if client.Config.RecoverFunc != nil {
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].cuid, time.Since(start))
stack[index].Execute(client, *event)
c.debug.Printf("[%d/%d] done %s == %s", index+1, len(stack), stack[index].cuid, time.Since(start))
}()
return
}
defer atomic.AddInt32(&working, -1)
if client.Config.RecoverFunc != nil {
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].cuid, time.Since(start))
stack[index].Execute(client, *event)
c.debug.Printf("[%d/%d] done %s == %s", index+1, len(stack), stack[index].cuid, time.Since(start))
}(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.
// This ignores internal handlers.
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")
}
// clearInternal clears all internal handlers currently setup within the
// client.
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")
}
@ -303,39 +262,46 @@ func (c *Caller) clearInternal() {
// This ignores internal handlers.
func (c *Caller) Clear(cmd string) {
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
// indicates that it existed, and has been removed. If not success, it
// wasn't a registered handler.
func (c *Caller) Remove(cuid string) (success bool) {
c.remove(cuid)
return true
c.mu.Lock()
success = c.remove(cuid)
c.mu.Unlock()
return success
}
// remove is much like Remove, however is NOT concurrency safe. Lock Caller.mu
// on your own.
func (c *Caller) remove(cuid string) (ok bool) {
func (c *Caller) remove(cuid string) (success bool) {
cmd, uid := c.cuidToID(cuid)
if len(cmd) == 0 || len(uid) == 0 {
return false
}
// Check if the irc command/event has any handlers on it.
var hs cmap.ConcurrentMap[string, Handler]
hs, ok = c.external.cm.Get(cmd)
if !ok {
return
if _, ok := c.external[cmd]; !ok {
return false
}
// Check to see if it's actually a registered handler.
if _, ok = hs.Get(cuid); !ok {
return
if _, ok := c.external[cmd][uid]; !ok {
return false
}
hs.Remove(uid)
delete(c.external[cmd], uid)
c.debug.Printf("removed handler %s", cuid)
// Assume success.
@ -346,8 +312,9 @@ func (c *Caller) remove(cuid string) (ok bool) {
// the Caller mutex.
func (c *Caller) sregister(internal, bg bool, cmd string, handler Handler) (cuid string) {
c.mu.Lock()
defer c.mu.Unlock()
cuid = c.register(internal, bg, cmd, handler)
c.mu.Unlock()
return cuid
}
@ -364,29 +331,22 @@ func (c *Caller) register(internal, bg bool, cmd string, handler Handler) (cuid
cuid += ":bg"
}
var (
parent *nestedHandlers
chandlers cmap.ConcurrentMap[string, Handler]
ok bool
)
if internal {
parent = c.internal
if _, ok := c.internal[cmd]; !ok {
c.internal[cmd] = map[string]Handler{}
}
c.internal[cmd][uid] = handler
} 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)
return cuid
@ -498,6 +458,7 @@ func recoverHandlerPanic(client *Client, event *Event, id string, skip int) {
}
client.Config.RecoverFunc(client, err)
return
}
// 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
// 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.
//
//goland:noinspection GoUnusedExportedFunction
func DefaultRecoverHandler(client *Client, err *HandlerError) {
if client.Config.Debug == nil {
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 (
"encoding/json"
"strings"
cmap "github.com/orcaman/concurrent-map/v2"
"sync"
)
// 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?:
//
// 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.
// C = Mode that changes a setting and only has a parameter when set.
// 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: 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.
// 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.
// C = Mode that changes a setting and only has a parameter when set.
// 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: 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.
func (c *CModes) hasArg(set bool, mode byte) (hasArgs, isSetting bool) {
if len(c.raw) < 1 {
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
// stored for a channel.
func (c *CModes) Apply(modes []CMode) {
var newcm []CMode
var new []CMode
for j := 0; j < len(c.modes); j++ {
isin := false
@ -168,14 +166,14 @@ func (c *CModes) Apply(modes []CMode) {
continue
}
if c.modes[j].name == modes[i].name && modes[i].add {
newcm = append(newcm, modes[i])
new = append(new, modes[i])
isin = true
break
}
}
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
for j := 0; j < len(newcm); j++ {
if modes[i].name == newcm[j].name {
for j := 0; j < len(new); j++ {
if modes[i].name == new[j].name {
isin = true
break
}
}
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
@ -335,9 +333,10 @@ func handleMODE(c *Client, e Event) {
return
}
c.state.RLock()
channel := c.state.lookupChannel(e.Params[0])
if channel == nil {
c.state.RUnlock()
return
}
@ -364,15 +363,17 @@ func handleMODE(c *Client, e Event) {
}
}
c.state.RUnlock()
c.state.notify(c, UPDATE_STATE)
}
// chanModes returns the ISUPPORT list of server-supported channel modes,
// alternatively falling back to ModeDefaults.
func (s *state) chanModes() string {
if validmodes, ok := s.serverOptions.Get("CHANMODES"); ok && IsValidChannelMode(validmodes) {
return validmodes
if modes, ok := s.serverOptions["CHANMODES"]; ok && IsValidChannelMode(modes) {
return modes
}
return ModeDefaults
}
@ -380,47 +381,64 @@ func (s *state) chanModes() string {
// This includes mode characters, as well as user prefix symbols. Falls back
// to DefaultPrefixes if not server-supported.
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 DefaultPrefixes
}
// UserPerms contains all of the permissions for each channel the user is
// in.
type UserPerms struct {
channels cmap.ConcurrentMap[string, *Perms]
mu sync.RWMutex
channels map[string]Perms
}
// Copy returns a deep copy of the channel permissions.
func (p *UserPerms) Copy() (perms *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
}
// MarshalJSON implements json.Marshaler.
func (p *UserPerms) MarshalJSON() ([]byte, error) {
p.mu.Lock()
out, err := json.Marshal(&p.channels)
p.mu.Unlock()
return out, err
}
// Lookup looks up the users permissions for a given channel. ok is false
// if the user is not in the given channel.
func (p *UserPerms) Lookup(channel string) (perms *Perms, ok bool) {
return p.channels.Get(ToRFC1459(channel))
func (p *UserPerms) Lookup(channel string) (perms Perms, ok bool) {
p.mu.RLock()
perms, ok = p.channels[ToRFC1459(channel)]
p.mu.RUnlock()
return perms, ok
}
func (p *UserPerms) set(channel string, perms *Perms) {
p.channels.Set(ToRFC1459(channel), perms)
func (p *UserPerms) set(channel string, perms Perms) {
p.mu.Lock()
p.channels[ToRFC1459(channel)] = perms
p.mu.Unlock()
}
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
@ -446,7 +464,7 @@ type Perms struct {
// IsAdmin indicates that the user has banning abilities, and are likely a
// very trustable user (e.g. op+).
func (m *Perms) IsAdmin() bool {
func (m Perms) IsAdmin() bool {
if m.Owner || m.Admin || m.Op {
return true
}
@ -456,7 +474,7 @@ func (m *Perms) IsAdmin() bool {
// IsTrusted indicates that the user at least has modes set upon them, higher
// than a regular joining user.
func (m *Perms) IsTrusted() bool {
func (m Perms) IsTrusted() bool {
if m.IsAdmin() || m.HalfOp || m.Voice {
return true
}

368
state.go

@ -6,47 +6,45 @@ package girc
import (
"fmt"
"sort"
"sync"
"sync/atomic"
"time"
cmap "github.com/orcaman/concurrent-map/v2"
)
// state represents the actively-changing variables within the client
// runtime. Note that everything within the state should be guarded by the
// embedded sync.RWMutex.
type state struct {
*sync.RWMutex
sync.RWMutex
// 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 map[string]*Channel
channels cmap.ConcurrentMap[string, *Channel]
channels map[string]*Channel
// users represents all of users that we're tracking.
// users map[string]*User
users cmap.ConcurrentMap[string, *User]
users map[string]*User
// enabledCap are the capabilities which are enabled for this connection.
// enabledCap map[string]map[string]string
enabledCap cmap.ConcurrentMap[string, map[string]string]
enabledCap map[string]map[string]string
// 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 list command from the server.
tmpCap map[string]map[string]string
// serverOptions are the standard capabilities and configurations
// supported by the server at connection time. This also includes
// RPL_ISUPPORT entries.
// 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
serverOptions map[string]string
// motd is the servers message of the day.
motd string
// client is a useful pointer to the state's related Client instance.
client *Client
// maxLineLength defines how long before we truncate (or split) messages.
// 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
// server.
@ -56,69 +54,46 @@ type state struct {
sts strictTransport
}
type Clearer interface {
Clear()
}
// reset resets the state back to it's original form.
func (s *state) reset(initial bool) {
s.nick.Store("")
s.ident.Store("")
s.host.Store("")
s.network.Store("")
var cmaps = []Clearer{&s.channels, &s.users, &s.serverOptions}
for i, cm := range cmaps {
switch {
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.Lock()
s.nick = ""
s.ident = ""
s.host = ""
s.channels = make(map[string]*Channel)
s.users = make(map[string]*User)
s.enabledCap = 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 = ""
if initial {
s.sts.reset()
}
s.Unlock()
}
// User represents an IRC user and the state attached to them.
type User struct {
// 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
// "~", which indicates that they do not have a identd server setup for
// authentication.
Ident *MarshalableAtomicValue `json:"ident"`
Ident string `json:"ident"`
// 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
// many networks spoofing/hiding parts of the hostname for privacy
// reasons.
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
// tracking the user in. Each channel name is rfc1459 compliant. See
// User.Channels() for a shorthand if you're looking for the *Channel
// version of the channel list.
//
// 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"`
ChannelList []string `json:"channels"`
// 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.
@ -132,8 +107,6 @@ type User struct {
// channel. This supports non-rfc style modes like Admin, Owner, and HalfOp.
Perms *UserPerms `json:"perms"`
Stale bool
// Extras are things added on by additional tracking methods, which may
// or may not work on the IRC server in mention.
Extras struct {
@ -154,26 +127,24 @@ type User struct {
} `json:"extras"`
}
// Channels returns a slice of pointers to Channel types that the client knows the user is in.
func (u *User) Channels(c *Client) []*Channel {
// Channels returns a reference of *Channels that the client knows the user
// 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 {
panic("nil Client provided")
}
var channels []*Channel
channels := []*Channel{}
for listed := range u.ChannelList.IterBuffered() {
chn := listed.Val
if chn != nil {
channels = append(channels, chn)
continue
}
ch := c.state.lookupChannel(listed.Key)
c.state.RLock()
for i := 0; i < len(u.ChannelList); i++ {
ch := c.state.lookupChannel(u.ChannelList[i])
if ch != nil {
u.ChannelList.Set(listed.Key, ch)
channels = append(channels, ch)
}
}
c.state.RUnlock()
return channels
}
@ -189,45 +160,53 @@ func (u *User) Copy() *User {
*nu = *u
nu.Perms = u.Perms.Copy()
for ch := range u.ChannelList.IterBuffered() {
nu.ChannelList.Set(ch.Key, ch.Val)
}
_ = copy(nu.ChannelList, u.ChannelList)
return nu
}
// addChannel adds the channel to the users channel list.
func (u *User) addChannel(name string, chn *Channel) {
name = ToRFC1459(name)
func (u *User) addChannel(name string) {
if u.InChannel(name) {
return
}
if u.ChannelList.Has(name) {
return
}
u.ChannelList = append(u.ChannelList, ToRFC1459(name))
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.
func (u *User) deleteChannel(name string) {
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)
}
// 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 {
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
@ -253,16 +232,10 @@ type Channel struct {
Name string `json:"name"`
// Topic of the channel.
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
// the channel. Each is the1 nickname, and is rfc1459 compliant.
UserList cmap.ConcurrentMap[string, *User] `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"`
// the channel. Each is the nickname, and is rfc1459 compliant.
UserList []string `json:"user_list"`
// Joined represents the first time that the client joined the channel.
Joined time.Time `json:"joined"`
// 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
// 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 {
panic("nil Client provided")
}
var users []*User
users := []*User{}
for listed := range ch.UserList.IterBuffered() {
user := c.state.lookupUser(listed.Key)
c.state.RLock()
for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
if user != nil {
ch.UserList.Set(listed.Key, user)
users = append(users, user)
}
}
c.state.RUnlock()
return users
}
// Trusted returns a list of users which have voice or greater in the given
// channel. See Perms.IsTrusted() for more information.
func (ch *Channel) Trusted(c *Client) []*User {
func (ch Channel) Trusted(c *Client) []*User {
if c == nil {
panic("nil Client provided")
}
var users []*User
users := []*User{}
for listed := range ch.UserList.IterBuffered() {
user := c.state.lookupUser(listed.Key)
c.state.RLock()
for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
if user == nil {
continue
}
@ -309,6 +284,7 @@ func (ch *Channel) Trusted(c *Client) []*User {
users = append(users, user)
}
}
c.state.RUnlock()
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
// greater permissions (op, admin, owner, etc) in the given channel. See
// Perms.IsAdmin() for more information.
func (ch *Channel) Admins(c *Client) []*User {
func (ch Channel) Admins(c *Client) []*User {
if c == nil {
panic("nil Client provided")
}
var users []*User
users := []*User{}
for listed := range ch.UserList.IterBuffered() {
ui := listed.Val
if ui == nil {
if ui = c.state.lookupUser(listed.Key); ui == nil {
continue
}
ch.UserList.Set(listed.Key, ui)
c.state.RLock()
for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
if user == nil {
continue
}
perms, ok := ui.Perms.Lookup(ch.Name)
perms, ok := user.Perms.Lookup(ch.Name)
if ok && perms.IsAdmin() {
users = append(users, ui)
users = append(users, user)
}
}
c.state.RUnlock()
return users
}
// 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) {
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.
func (ch *Channel) deleteUser(nick string) {
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.
@ -365,9 +352,7 @@ func (ch *Channel) Copy() *Channel {
nc := &Channel{}
*nc = *ch
for v := range ch.UserList.IterBuffered() {
nc.UserList.Set(v.Val.Nick.Load().(string), v.Val)
}
_ = copy(nc.UserList, ch.UserList)
// And modes.
nc.Modes = ch.Modes.Copy()
@ -377,13 +362,20 @@ func (ch *Channel) Copy() *Channel {
// Len returns the count of users in a given channel.
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.
func (ch *Channel) UserIn(name string) bool {
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
@ -394,21 +386,19 @@ func (ch *Channel) Lifetime() time.Duration {
// createChannel creates the channel in state, if not already done.
func (s *state) createChannel(name string) (ok bool) {
supported := s.chanModes()
prefixes, _ := parsePrefixes(s.userPrefixes())
if _, ok := s.channels.Get(ToRFC1459(name)); ok {
if _, ok := s.channels[ToRFC1459(name)]; ok {
return false
}
s.channels.Set(ToRFC1459(name), &Channel{
s.channels[ToRFC1459(name)] = &Channel{
Name: name,
UserList: cmap.New[*User](),
UserList: []string{},
Joined: time.Now(),
Network: s.client.NetworkName(),
Modes: NewCModes(supported, prefixes),
})
}
return true
}
@ -417,92 +407,69 @@ func (s *state) createChannel(name string) (ok bool) {
func (s *state) deleteChannel(name string) {
name = ToRFC1459(name)
chn, ok := s.channels.Get(name)
_, ok := s.channels[name]
if !ok {
return
}
for listed := range chn.UserList.IterBuffered() {
usr, uok := s.users.Get(listed.Key)
if uok {
usr.deleteChannel(name)
for _, user := range s.channels[name].UserList {
s.users[user].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
// found.
func (s *state) lookupChannel(name string) *Channel {
ci, cok := s.channels.Get(ToRFC1459(name))
if ci == nil || !cok {
return nil
}
return ci
return s.channels[ToRFC1459(name)]
}
// lookupUser returns a reference to a user, nil returned if no results
// found.
func (s *state) lookupUser(name string) *User {
usr, uok := s.users.Get(ToRFC1459(name))
if usr == nil || !uok {
return nil
}
return usr
return s.users[ToRFC1459(name)]
}
func (s *state) createUser(src *Source) (u *User, ok bool) {
if u, ok = s.users.Get(src.ID()); ok {
// createUser creates the user in state, if not already done.
func (s *state) createUser(src *Source) (ok bool) {
if _, ok := s.users[src.ID()]; ok {
// User already exists.
return u, false
return false
}
mask := strs.Get()
if src.Name != "" {
mask.MustWriteString(src.Name)
}
_ = mask.WriteByte('!')
if src.Ident != "" {
mask.MustWriteString(src.Ident)
}
_ = mask.WriteByte('@')
if src.Host != "" {
mask.MustWriteString(src.Host)
s.users[src.ID()] = &User{
Nick: src.Name,
Host: src.Host,
Ident: src.Ident,
FirstSeen: time.Now(),
LastActive: time.Now(),
Perms: &UserPerms{channels: make(map[string]Perms)},
}
u = &User{
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
return true
}
// deleteUser removes the user from channel state.
func (s *state) deleteUser(channelName, nick string) {
user := s.lookupUser(nick)
if user == nil {
s.client.debug.Printf(nick + ": was not found when trying to deleteUser from " + channelName)
return
}
if channelName == "" {
user.ChannelList.Clear()
// While we do still want to remove them from the channels,
// We want to hold onto that user object regardless on if they dip-set.
// s.users.Remove(ToRFC1459(nick))
user.Stale = true
for i := 0; i < len(user.ChannelList); i++ {
s.channels[user.ChannelList[i]].deleteUser(nick)
}
delete(s.users, ToRFC1459(nick))
return
}
@ -513,8 +480,12 @@ func (s *state) deleteUser(channelName, nick string) {
user.deleteChannel(channelName)
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)
// Update our nickname.
if from == ToRFC1459(s.nick.Load().(string)) {
s.nick.Store(to)
if from == ToRFC1459(s.nick) {
s.nick = to
}
user := s.lookupUser(from)
old, oldok := s.users.Pop(from)
if !oldok && user == nil {
if user == nil {
return
}
if old != nil && user == nil {
user = old
}
delete(s.users, from)
user.Nick.Store(to)
user.Nick = to
user.LastActive = time.Now()
s.users.Set(ToRFC1459(to), user)
s.users[ToRFC1459(to)] = user
for chanchan := range s.channels.IterBuffered() {
chn := chanchan.Val
if chn == nil {
continue
}
if old, oldok = chn.UserList.Pop(from); oldok {
chn.UserList.Set(to, old)
for i := 0; i < len(user.ChannelList); i++ {
for j := 0; j < len(s.channels[user.ChannelList[i]].UserList); j++ {
if s.channels[user.ChannelList[i]].UserList[j] == from {
s.channels[user.ChannelList[i]].UserList[j] = ToRFC1459(to)
sort.Strings(s.channels[user.ChannelList[i]].UserList)
break
}
}
}
}

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