Compare commits

...

109 Commits

Author SHA1 Message Date
f9a80d019b
Fix #2 2023-04-27 10:04:53 -07:00
623ca6ffc1
tricking github into knowing what a tag is 2023-03-18 00:11:10 -07:00
568e9652da
Fix race condition 2023-03-17 23:57:45 -07:00
633a5dea16
Overhaul, breaking change in GetPerms 2023-03-17 23:33:19 -07:00
0d38e8f3d9
Update README.md 2022-11-05 00:43:49 -07:00
ee1c72a7f5
Chores: lint, nolint, fmt, etc 2022-10-23 02:01:03 -07:00
5f71c07dee
fmt 2022-10-23 01:56:07 -07:00
d91de1c3c0
Improve tests 2022-10-23 01:56:01 -07:00
3458ae3d6d
Fix pointer receiver inconsistencies 2022-10-23 01:51:42 -07:00
0ae5183ad5
Optimizations stage 1 2022-10-23 01:49:34 -07:00
c1560dfd88
Fix: formatting inconsistencies. Feat: KickBan cmd. 2022-08-17 20:09:25 -07:00
9083cf55cf
Fix: Check for nil from cmap Get 2022-07-14 01:47:52 -07:00
32f2b078c3
Switch UserPerms to cmap and fix panic 2022-07-11 22:36:36 -07:00
61ef265c6c
Not worried about coverage, the sky is falling. 2022-06-25 17:14:29 -07:00
0b5ba7050c
Minor stealth change and backport https://github.com/lrstanley/girc/pull/56 2022-06-25 17:08:40 -07:00
0dec6ca8e2
Coverage: Add silly little test to appease 2022-05-02 22:05:31 -07:00
af4ae39f57
CI: Update go version 2022-05-02 21:25:30 -07:00
18243520dc
Upstream: merge in some changes 2022-05-02 21:15:53 -07:00
f6b2020909
1.18 changes 2022-05-02 20:37:54 -07:00
78e9032f25
Documentation: slight change 2022-04-10 05:25:50 -07:00
c2a726248a
Lint + version bump 2022-03-21 17:32:29 -07:00
00308d0ce6
Fix bugs from merge 2022-03-21 17:08:41 -07:00
4be5266426
Merge from yung stanley's master branch
Signed-off-by: kayos@tcp.direct <kayos@tcp.direct>
2022-03-21 17:06:14 -07:00
Liam Stanley
9664730c78
fix golint path
Signed-off-by: Liam Stanley <me@liamstanley.io>
2022-03-21 17:55:35 -04:00
Liam Stanley
6681863d12
go 1.18 changes
Signed-off-by: Liam Stanley <me@liamstanley.io>
2022-03-21 17:51:44 -04:00
Liam Stanley
aaebfc1f09
fix golint install
Signed-off-by: Liam Stanley <me@liamstanley.io>
2022-03-21 17:48:49 -04:00
Liam Stanley
3a46659a82
move to github actions (porting from split-messages branch)
Signed-off-by: Liam Stanley <me@liamstanley.io>
2022-03-21 17:46:56 -04:00
Liam Stanley
887ab30b90
prevent potential hangup after ping timeout (fixes: #50)
Signed-off-by: Liam Stanley <me@liamstanley.io>
2022-03-21 17:25:26 -04:00
Liam Stanley
8487a7de15
lint fixes
Signed-off-by: Liam Stanley <me@liamstanley.io>
2022-03-21 16:27:51 -04:00
Liam Stanley
b5af8a2128
add RPL_CREATIONTIME
Signed-off-by: Liam Stanley <me@liamstanley.io>
2022-03-21 16:19:03 -04:00
dea3455490
Bump version to v0.5.1 2022-03-20 16:47:24 -07:00
12e6cfbe3b
Merge pull request #3 from yunginnanet/cmap 2022-03-20 16:39:14 -07:00
1051473e2b
Fix: race condition, relieve even more pressure 2022-03-20 16:17:50 -07:00
5619a7527f
Fix: version.go 2022-03-20 14:30:05 -07:00
41ffc42937
v0.5 2022-03-19 21:11:22 -07:00
2378deab84
Massive overhaul - More danger - Less backpressure 2022-03-19 21:08:12 -07:00
e4014626b5
More anti-race-conditions 2022-03-19 21:05:36 -07:00
e835e5898d
Reduce raciness 2022-03-19 20:59:13 -07:00
f6902689f1
Update version 2022-03-19 20:44:18 -07:00
6fc7f6e1d7
Fix concurrent map read-write for user perms 2022-03-19 18:06:16 -07:00
8b3710579e
Add: missing ALREADYOPER error 2022-03-19 17:13:01 -07:00
eee810320c
Settle for caps using traditional locks and maps 2022-03-19 15:36:46 -07:00
86271f76fa
Fix: SASL not firing off (round 2) 2022-03-19 13:44:36 -07:00
ac38ef0258
Fix: SASL not firing off 2022-03-19 13:25:56 -07:00
640d183c1b
Update license 2022-03-19 13:08:09 -07:00
8ce24dc17c
Fix: missing entries in codebook.go 2022-03-19 12:42:53 -07:00
5e68e20bbb
Add: codes + event.IsError() 2022-03-19 12:36:33 -07:00
3c9ed46490
Add: version.go 2022-03-19 12:08:45 -07:00
507dd2f271
Fix: broken cap regression 2022-03-16 11:38:33 -07:00
290eedf446
Refactor server option retrieval 2022-03-16 11:20:31 -07:00
b9d39b20fe
Adjust benchmark tests 2022-03-16 10:13:02 -07:00
de1dae9799
Remove unnecessary debug output 2022-03-16 10:09:43 -07:00
9c72236c15
Fix: pass all tests 2022-03-16 09:04:58 -07:00
b8186e2144
Fixed: functional with concurrent maps 2022-03-16 08:11:05 -07:00
23cea998f1
Implement cmaps and break everything 2022-03-16 05:45:59 -07:00
fa2aba1ef2 Fix inconsistency bugs 2021-12-30 17:28:45 -08:00
2d8ab65b6d Fix: duplicate tag 2021-12-21 00:22:53 -08:00
02e997f314 Add: more CTCP configuration options 2021-12-21 00:20:21 -08:00
6ca2202d58 Add: missing codebook entries 2021-11-28 19:06:06 -08:00
20ec8a60e6 update README.md 2021-11-27 23:10:57 -08:00
4276b04ba7 Loosen: tests, Fix: serveropts, Add: CTCP handlers 2021-11-27 22:50:02 -08:00
5e2fd661f3 Settle with less hellfire, more safety 2021-11-26 08:13:54 -08:00
36151a10b5 Fix: concurrent map read/write 2021-11-26 07:49:56 -08:00
22cd701da8 Testing: add additional tests 2021-11-26 03:25:01 -08:00
90d8c80bcd Bloat: add network name to channels and users 2021-11-26 03:18:47 -08:00
923b7b1bb1 Refine: better user dataset & reduce redundancy 2021-11-26 03:08:28 -08:00
5ea3252b9b New: handle RPL_CREATIONTIME 2021-11-26 00:53:20 -08:00
1eacc3e108 New: IRC Numeric Code decoder, More: hellfire 2021-11-25 23:25:22 -08:00
4beeca47ab Reformed: cannot escape from hellfire 2021-11-19 14:41:43 -08:00
a999837230 Chaotic Versioning 2021-11-19 09:41:48 -08:00
e0299a3766 Update README.md (reformed af) 2021-11-19 09:39:33 -08:00
3db5c2c0ab
Create go.yml 2021-11-19 09:35:12 -08:00
3629c0de73 Merge branch 'lrstanley/master' 2021-11-19 09:32:29 -08:00
7f85567f20 delete .travis.yml 2021-11-19 09:27:48 -08:00
87a2ab50c1 Reform: bring back tests + fix server opts 2021-11-19 09:26:51 -08:00
58c1d27f2c Enhance: passive tracking 2021-11-15 19:43:56 -08:00
baf2e0dedf Enhance: keep user info after we part channels 2021-11-14 21:55:47 -08:00
79e2583dda Fix: nil pointer dereference in IsConnected 2021-11-14 21:13:43 -08:00
ce2eeb8072 Refactor: slightly less hellfire 2021-11-09 14:07:47 -08:00
ff65f6fa03 Fix: panic when spawning 10,000 bots :^) 2021-11-09 08:14:35 -08:00
b9856ab64e re-implemenet network detection 2021-10-28 12:06:57 -07:00
Liam Stanley
147f0ff775 add support for disabling nick collide corrections; closes #48 2021-10-23 19:37:35 -04:00
7f101f3656 More: hellfire 2021-10-17 17:16:59 -07:00
410e8686ab Fix: broken spinlock attempt 2021-10-17 15:05:14 -07:00
ea8bd7c774 Merge branch 'master' of github.com:yunginnanet/girc-atomic 2021-10-17 11:46:00 -07:00
f17812708d Multiclient: attach network name to events 2021-10-17 11:45:46 -07:00
e809c349d0
Create README.md 2021-10-09 10:12:55 -07:00
53bcfb4ee5 Fix: ircd info enumeration 2021-10-09 10:06:42 -07:00
d32dd5e8a0 Fix nil compiled time and lint 2021-10-09 08:50:48 -07:00
c46e976ff3 Fix: blank source panic 2021-10-09 06:54:41 -07:00
9724515d2b Fix: event parsing bug 2021-10-09 06:37:51 -07:00
b84d822ec5 Fix: go module and event bug 2021-10-09 06:25:17 -07:00
c4ff5a6022 Update: README.md 2021-10-09 02:51:27 -07:00
6ef31ffdcb Update: README.md 2021-10-09 02:45:04 -07:00
606b5a452c I'm slightly drunk and I don't know what I'm doing. 2021-10-08 00:30:04 -07:00
e50801a78e Update README.md 2021-10-05 05:49:19 -07:00
9065b0d6ab New: store data bout the connected IRC server 2021-10-03 15:28:29 -07:00
1556e364c0 I'll need to rewrite tests later. Safety last. 2021-10-03 05:19:12 -07:00
59d0b7398e fix go-mod path 2021-10-03 05:16:21 -07:00
4c1d73dcbd add util.go 2021-10-03 05:06:48 -07:00
01ce96b07a Ever cook bacon on your CPU cooler? 2021-10-01 06:15:34 -07:00
Liam Stanley
771323f162 colon should only cause prefix if the param starts with a colon 2021-06-11 17:32:46 -04:00
Liam Stanley
4219526e1d mirror ContainsAny for Len() check as well 2021-06-11 17:16:42 -04:00
Liam Stanley
f97c533ce1 add addl tests 2021-06-11 17:16:26 -04:00
Liam Stanley
b7e90b27e4 Merge branch 'master' into msg-with-colon-but-no-space 2021-06-11 16:47:58 -04:00
nmeum
28ef073485
Fix conversion errors (#46) 2021-06-11 15:56:21 -04:00
Sören Tempel
8929b1a531 event: Fix serialization of messages with colon but no space
For messages that contain either a colon **or** a space character, a
messagePrefix needs to be added to the serialized messages. Previously,
a message prefix was only added for messages containing a space
character. This causes messages which only contain a colon, but not a
space to be serialized incorrectly.

For example, prior to this commit a command like `PRIVMSG #foo ::)`,
which should send the message `:)` to the channel `#foo`, was
serialized as `PRIVMSG #foo :)` which only causes a `)` character to be
sent to the channel `#foo`.
2021-06-04 21:30:28 +02:00
nmeum
2cb73c3772
event: Fix PRIVMSG/NOTICE format string (#45)
Fixes a minor typo introduced in #44.
2020-12-30 05:53:53 -05:00
nmeum
288d953b28
event: Use different source format for PRIVMSG and NOTICE (#44)
This allows distinguishing the two commands in the output of the
Pretty() function. This is useful for IRC client which use the output of
the Pretty() function for formating IRC messages.
2020-12-29 19:19:53 -05:00
35 changed files with 2098 additions and 1434 deletions

25
.github/workflows/go.yml vendored Normal file

@ -0,0 +1,25 @@
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 ./...

4
.gitignore vendored Normal file

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

@ -1,25 +0,0 @@
language: go
go:
- 1.11.x
- tip
before_install:
- go get -v golang.org/x/lint/golint
script:
- $HOME/gopath/bin/golint -min_confidence 0.9 -set_exit_status
- GORACE="exitcode=1 halt_on_error=1" go test -v -coverprofile=coverage.txt -race -timeout 3m -count 3 -cpu 1,4
- go vet -v .
after_success:
- bash <(curl -s https://codecov.io/bash)
branches:
only:
- master
notifications:
irc:
channels:
- irc.byteirc.org#/dev/null
template:
- "%{repository} #%{build_number} %{branch}/%{commit}: %{author} -- %{message}
%{build_url}"
on_success: change
on_failure: change
skip_join: false

@ -2,31 +2,24 @@
## 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,6 +1,7 @@
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,67 +1,59 @@
<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://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://travis-ci.org/lrstanley/girc"><img src="https://travis-ci.org/lrstanley/girc.svg?branch=master" alt="Build 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://godoc.org/github.com/lrstanley/girc"><img src="https://godoc.org/github.com/lrstanley/girc?status.png" 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://byteirc.org/channel/%23%2Fdev%2Fnull"><img src="https://img.shields.io/badge/ByteIRC-%23%2Fdev%2Fnull-blue.svg" alt="IRC Chat"></a>
<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>
</p>
## Fork changes
[Click here to see the changes in girc-atomic vs girc](https://github.com/lrstanley/girc/compare/master...yunginnanet:master)
## Status
**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**
### ₜₕₑ ₛₖy ᵢₛ 𝆑ₐₗₗᵢₙg ʇɥǝ sʞʎ ᴉs ⅎɐʅʅᴉuƃ
### 𝚝𝚑𝚎𝚢 𝚜𝚑𝚘𝚞𝚕𝚍 𝚑𝚊𝚟𝚎 𝚕𝚒𝚜𝚝𝚎𝚗𝚎𝚍
### ʇɥǝ sʞʎ ᴉs ⅎɐʅʅᴉuƃ ₜₕₑ ₛₖy ᵢₛ 𝆑ₐₗₗᵢₙg
## Features
- 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.
- 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.
- 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/lrstanley/girc/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/yunginnanet/girc-atomic/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/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.)
- 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.)
- 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/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!
- 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!
## Contributing
Please review the [CONTRIBUTING](CONTRIBUTING.md) doc for submitting issues/a guide
on submitting pull requests and helping out.
~~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**
## 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
@ -81,7 +73,9 @@ 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.
girc artwork licensed under [CC 3.0](http://creativecommons.org/licenses/by/3.0/) based on Renee French under Creative Commons 3.0 Attributions
...and then later defiled by [some idiot](https://github.com/yunginnanet).
## References

@ -5,75 +5,88 @@
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))
c.Handlers.mu.Unlock()
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
}
// handleConnect is a helper function which lets the client know that enough
@ -85,30 +98,46 @@ 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.Lock()
c.state.nick = e.Params[0]
c.state.Unlock()
c.state.nick.Store(e.Params[0])
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
}
c.Cmd.Nick(c.Config.HandleNickCollide(c.GetNick()))
newNick := c.Config.HandleNickCollide(c.GetNick())
if newNick != "" {
c.Cmd.Nick(newNick)
}
}
// handlePING helps respond to ping requests from the server.
@ -116,10 +145,9 @@ func handlePING(c *Client, e Event) {
c.Cmd.Pong(e.Last())
}
//goland:noinspection GoUnusedParameter
func handlePONG(c *Client, e Event) {
c.conn.mu.Lock()
c.conn.lastPong = time.Now()
c.conn.mu.Unlock()
c.conn.lastPong.Store(time.Now())
}
// handleJOIN ensures that the state has updated users and channels.
@ -130,12 +158,9 @@ 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
}
@ -144,8 +169,7 @@ func handleJOIN(c *Client, e Event) {
user := c.state.lookupUser(e.Source.Name)
if user == nil {
if ok := c.state.createUser(e.Source); !ok {
c.state.Unlock()
if _, ok := c.state.createUser(e.Source); !ok {
return
}
user = c.state.lookupUser(e.Source.Name)
@ -153,8 +177,8 @@ func handleJOIN(c *Client, e Event) {
defer c.state.notify(c, UPDATE_STATE)
channel.addUser(user.Nick)
user.addChannel(channel.Name)
channel.addUser(user.Nick.Load().(string), user)
user.addChannel(channel.Name, channel)
// Assume extended-join (ircv3).
if len(e.Params) >= 2 {
@ -166,7 +190,6 @@ 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
@ -178,10 +201,8 @@ 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.Lock()
c.state.ident = e.Source.Ident
c.state.host = e.Source.Host
c.state.Unlock()
c.state.ident.Store(e.Source.Ident)
c.state.host.Store(e.Source.Host)
return
}
@ -195,7 +216,11 @@ 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]
@ -205,16 +230,42 @@ 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())
c.state.Unlock()
}
// handleCREATIONTIME handles incoming TOPIC events and keeps channel tracking info
// updated with the latest channel topic.
func handleCREATIONTIME(c *Client, e Event) {
var created string
var name string
switch len(e.Params) {
case 0, 1, 2:
return
default:
name = e.Params[1]
created = e.Params[2]
break
}
channel := c.state.lookupChannel(name)
if channel == nil {
return
}
channel.Created = created
c.state.notify(c, UPDATE_STATE)
}
// handleTOPIC handles incoming TOPIC events and keeps channel tracking info
@ -230,15 +281,14 @@ 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)
}
@ -283,22 +333,35 @@ func handleWHO(c *Client, e Event) {
}
}
c.state.Lock()
user := c.state.lookupUser(nick)
if user == nil {
c.state.Unlock()
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)
return
}
user.Host = host
user.Ident = ident
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.Extras.Name = realname
if account != "0" {
user.Extras.Account = account
}
c.state.Unlock()
c.state.notify(c, UPDATE_STATE)
}
@ -313,16 +376,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
@ -332,12 +395,10 @@ 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)
}
@ -351,32 +412,101 @@ 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)
}
// 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 {
func handleGLOBALUSERS(c *Client, e Event) {
cusers, err := strconv.Atoi(e.Params[0])
if err != nil {
return
}
musers, err := strconv.Atoi(e.Params[1])
if err != nil {
return
}
c.IRCd.UserCount = cusers
c.IRCd.MaxUserCount = musers
}
c.state.Lock()
c.state.serverOptions["SERVER"] = e.Params[1]
c.state.serverOptions["VERSION"] = e.Params[2]
c.state.Unlock()
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.notify(c, UPDATE_GENERAL)
}
// handleISUPPORT handles incoming RPL_ISUPPORT (also known as RPL_PROTOCTL)
// events. These commonly contain the server capabilities and limitations.
// For example, things like max channel name length, or nickname length.
// events. This commonly contains the date of the daemon's compilation.
func handleISUPPORT(c *Client, e Event) {
// Must be a ISUPPORT-based message.
@ -390,45 +520,46 @@ 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 := 1; i < len(e.Params)-1; i++ {
j := strings.IndexByte(e.Params[i], '=')
for i := range e.Params {
split := strings.Split(e.Params[i], "=")
if j < 1 || (j+1) == len(e.Params[i]) {
c.state.serverOptions[e.Params[i]] = ""
if len(split) != 2 {
c.state.serverOptions.Set(e.Params[i], "")
continue
}
name := e.Params[i][0:j]
val := e.Params[i][j+1:]
c.state.serverOptions[name] = val
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])
}
c.state.Unlock()
c.state.notify(c, UPDATE_GENERAL)
}
// 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 len(c.state.motd) != 0 {
if c.state.motd != "" {
c.state.motd += "\n"
}
c.state.motd += e.Last()
c.state.Unlock()
}
// handleNAMES handles incoming NAMES queries, of which lists all users in
@ -448,9 +579,8 @@ func handleNAMES(c *Client, e Event) {
var modes, nick string
var ok bool
s := &Source{}
var s *Source
c.state.Lock()
for i := 0; i < len(parts); i++ {
modes, nick, ok = parseUserPrefix(parts[i])
if !ok {
@ -480,15 +610,14 @@ func handleNAMES(c *Client, e Event) {
continue
}
user.addChannel(channel.Name)
channel.addUser(s.ID())
user.addChannel(channel.Name, channel)
channel.addUser(s.ID(), user)
// 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)
}
@ -501,15 +630,11 @@ 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,9 +49,7 @@ var possibleCap = map[string][]string{
const capServerTimeFormat = "2006-01-02T15:04:05.999Z"
func (c *Client) listCAP() {
if !c.Config.disableTracking {
c.write(&Event{Command: CAP, Params: []string{CAP_LS, "302"}})
}
c.write(&Event{Command: CAP, Params: []string{CAP_LS, "302"}})
}
func possibleCapList(c *Client) map[string][]string {
@ -64,7 +62,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 negotation).
// sts negotiation).
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 {
@ -106,7 +104,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 : len(option)]
out[parts[i][:val]][option[:j]] = option[j+1:]
}
}
}
@ -123,9 +121,8 @@ func handleCAP(c *Client, e Event) {
if len(e.Params) >= 2 && e.Params[1] == CAP_DEL {
caps := parseCap(e.Last())
for cap := range caps {
// TODO: test the deletion.
delete(c.state.enabledCap, cap)
for capab := range caps {
c.state.enabledCap.Remove(capab)
}
return
}
@ -194,12 +191,12 @@ func handleCAP(c *Client, e Event) {
if len(e.Params) == 3 && e.Params[1] == CAP_ACK {
enabled := strings.Split(e.Last(), " ")
for _, cap := range enabled {
if val, ok := c.state.tmpCap[cap]; ok {
c.state.enabledCap[cap] = val
} else {
c.state.enabledCap[cap] = nil
for _, capab := range enabled {
if val, ok := c.state.tmpCap[capab]; ok {
c.state.enabledCap.Set(capab, val)
continue
}
c.state.enabledCap.Remove(capab)
}
// Anything client side that needs to be setup post-capability-acknowledgement,
@ -207,9 +204,8 @@ 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["sts"]; sok && !c.Config.DisableSTS {
if sts, sok := c.state.enabledCap.Get("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
@ -288,7 +284,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["sasl"]; ok && c.Config.SASL != nil {
if _, ok := c.state.enabledCap.Get("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
@ -309,25 +305,24 @@ func handleCHGHOST(c *Client, e Event) {
return
}
c.state.Lock()
user := c.state.lookupUser(e.Source.Name)
if user != nil {
user.Ident = e.Params[0]
user.Ident.Store(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)
}
@ -345,11 +340,9 @@ 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,7 +120,6 @@ func handleSASL(c *Client, e Event) {
break
}
}
return
}
func handleSASLError(c *Client, e Event) {

@ -24,12 +24,11 @@ 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)
}
@ -52,9 +51,12 @@ 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.
@ -250,10 +252,6 @@ 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,6 +16,7 @@ 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
@ -33,26 +34,36 @@ func TestCapSupported(t *testing.T) {
}
}
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"},
},
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 FuzzParseCap(f *testing.F) {
for _, tc := range testsParseCap {
f.Add(tc.in)
}
for _, tt := range tests {
f.Fuzz(func(t *testing.T, orig string) {
_ = parseCap(orig)
})
}
func TestParseCap(t *testing.T) {
for _, tt := range testsParseCap {
got := parseCap(tt.in)
if !reflect.DeepEqual(got, tt.want) {
@ -104,7 +115,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"+string(0x08)); err == nil {
if err := e.Tags.Set("key", "invalid-value\b"); err == nil {
t.Fatal("tag set of invalid value should have returned error")
}
}

299
client.go

@ -7,10 +7,10 @@ package girc
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"os"
@ -19,7 +19,10 @@ 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
@ -47,6 +50,10 @@ 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
@ -57,6 +64,32 @@ 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
@ -146,9 +179,20 @@ 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, if default CTCP replies have not been
// overwritten or a VERSION handler was already supplied.
// response to a CTCP VERSION. A default message will be sent otherwise.
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
@ -168,6 +212,9 @@ 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)
}
@ -177,13 +224,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
@ -229,10 +276,10 @@ func (conf *Config) isValid() error {
}
if !IsValidNick(conf.Nick) {
return &ErrInvalidConfig{Conf: *conf, err: errors.New("bad nickname specified")}
return &ErrInvalidConfig{Conf: *conf, err: fmt.Errorf("bad nickname specified: %s", conf.Nick)}
}
if !IsValidUser(conf.User) {
return &ErrInvalidConfig{Conf: *conf, err: errors.New("bad user/ident specified")}
return &ErrInvalidConfig{Conf: *conf, err: fmt.Errorf("bad user/ident specified: %s", conf.Nick)}
}
return nil
@ -252,6 +299,15 @@ 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) {
@ -265,7 +321,7 @@ func New(config Config) *Client {
if envDebug {
c.debug = log.New(os.Stderr, "debug:", log.Ltime|log.Lshortfile)
} else {
c.debug = log.New(ioutil.Discard, "", 0)
c.debug = log.New(io.Discard, "", 0)
}
} else {
if envDebug {
@ -277,18 +333,26 @@ 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.debug)
c.Handlers = newCaller(c, c.debug)
// Give ourselves a new state.
c.state = &state{}
c.state = &state{
channels: cmap.New[*Channel](),
users: cmap.New[*User](),
enabledCap: cmap.New[map[string]string](),
serverOptions: cmap.New[string](),
}
c.state.RWMutex = &sync.RWMutex{}
c.state.reset(true)
c.state.client = c
// Register builtin handlers.
c.registerBuiltins()
@ -313,16 +377,11 @@ 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
}
c.conn.mu.RLock()
defer c.conn.mu.RUnlock()
if !c.conn.connected {
if !c.conn.connected.Load().(bool) {
return nil, ErrNotConnected
}
@ -343,12 +402,10 @@ 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
@ -378,10 +435,12 @@ func (e *ErrEvent) Error() string {
return e.Event.Last()
}
func (c *Client) execLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
func (c *Client) execLoop(ctx context.Context, errs chan error, working *int32) {
c.debug.Print("starting execLoop")
defer c.debug.Print("closing execLoop")
defer atomic.AddInt32(working, -1)
var event *Event
for {
@ -401,7 +460,6 @@ func (c *Client) execLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
}
done:
wg.Done()
return
case event = <-c.rx:
if event != nil && event.Command == ERROR {
@ -432,9 +490,7 @@ func (c *Client) DisableTracking() {
c.Config.disableTracking = true
c.Handlers.clearInternal()
c.state.Lock()
c.state.channels = nil
c.state.Unlock()
c.state.channels.Clear()
c.state.notify(c, UPDATE_STATE)
c.registerBuiltins()
@ -442,9 +498,6 @@ 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()
}
@ -465,16 +518,12 @@ 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 nil, ErrNotConnected
return time.Now(), ErrNotConnected
}
c.mu.RLock()
c.conn.mu.RLock()
up = c.conn.connTime
c.conn.mu.RUnlock()
c.mu.RUnlock()
up = c.conn.connTime.Load().(time.Time)
return up, nil
}
@ -486,43 +535,40 @@ func (c *Client) ConnSince() (since *time.Duration, err error) {
return nil, ErrNotConnected
}
c.mu.RLock()
c.conn.mu.RLock()
timeSince := time.Since(*c.conn.connTime)
c.conn.mu.RUnlock()
c.mu.RUnlock()
timeSince := time.Since(c.conn.connTime.Load().(time.Time))
return &timeSince, nil
}
// IsConnected returns true if the client is connected to the server.
func (c *Client) IsConnected() bool {
c.mu.RLock()
if c.conn == nil {
c.mu.RUnlock()
if c == nil {
return false
}
if c.conn == nil {
return false
}
if c.conn.connected.Load() == nil {
c.conn.connected.Store(false)
}
c.conn.mu.RLock()
connected := c.conn.connected
c.conn.mu.RUnlock()
c.mu.RUnlock()
return connected
return c.conn.connected.Load().(bool)
}
// 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)
c.state.RLock()
defer c.state.RUnlock()
if c.state.nick == "" {
if len(n) < 1 {
return c.Config.Nick
}
return c.state.nick
return n
}
// GetID returns an RFC1459 compliant version of the current nickname. Panics
@ -537,13 +583,10 @@ func (c *Client) GetID() string {
func (c *Client) GetIdent() string {
c.panicIfNotTracking()
c.state.RLock()
defer c.state.RUnlock()
if c.state.ident == "" {
if c.state.ident.Load().(string) == "" {
return c.Config.User
}
return c.state.ident
return c.state.ident.Load().(string)
}
// GetHost returns the current host of the active connection. Panics if
@ -552,9 +595,8 @@ func (c *Client) GetIdent() string {
func (c *Client) GetHost() (host string) {
c.panicIfNotTracking()
c.state.RLock()
host = c.state.host
c.state.RUnlock()
host = c.state.host.Load().(string)
return host
}
@ -563,12 +605,15 @@ func (c *Client) GetHost() (host string) {
func (c *Client) ChannelList() []string {
c.panicIfNotTracking()
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)
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.RUnlock()
sort.Strings(channels)
return channels
}
@ -578,12 +623,11 @@ func (c *Client) ChannelList() []string {
func (c *Client) Channels() []*Channel {
c.panicIfNotTracking()
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())
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.RUnlock()
sort.Slice(channels, func(i, j int) bool {
return channels[i].Name < channels[j].Name
@ -596,12 +640,15 @@ func (c *Client) Channels() []*Channel {
func (c *Client) UserList() []string {
c.panicIfNotTracking()
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)
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.RUnlock()
sort.Strings(users)
return users
}
@ -611,15 +658,14 @@ func (c *Client) UserList() []string {
func (c *Client) Users() []*User {
c.panicIfNotTracking()
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())
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.RUnlock()
sort.Slice(users, func(i, j int) bool {
return users[i].Nick < users[j].Nick
return users[i].Nick.Load().(string) < users[j].Nick.Load().(string)
})
return users
}
@ -632,9 +678,8 @@ func (c *Client) LookupChannel(name string) (channel *Channel) {
return nil
}
c.state.RLock()
channel = c.state.lookupChannel(name).Copy()
c.state.RUnlock()
return channel
}
@ -646,45 +691,71 @@ 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()
c.state.RLock()
_, in = c.state.channels[ToRFC1459(channel)]
c.state.RUnlock()
_, in = c.state.channels.Get(ToRFC1459(channel))
return in
}
// GetServerOption retrieves a server capability setting that was retrieved
// GetServerOpt 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 := GetServerOption("MAXNICKLEN")
//
func (c *Client) GetServerOption(key string) (result string, ok bool) {
// nickLen, success := GetServerOpt("MAXNICKLEN")
func (c *Client) GetServerOpt(key string) (result string, ok bool) {
c.panicIfNotTracking()
c.state.RLock()
result, ok = c.state.serverOptions[key]
c.state.RUnlock()
result, ok = c.state.serverOptions.Get(key)
if !ok {
return "", ok
}
if len(result) > 0 {
ok = true
}
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
}
jcytes, _ := json.Marshal(o)
return jcytes
}
// NetworkName returns the network identifier. E.g. "EsperNet", "ByteIRC".
// May be empty if the server does not support RPL_ISUPPORT (or RPL_PROTOCTL).
// 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
}
@ -695,7 +766,7 @@ func (c *Client) NetworkName() (name string) {
func (c *Client) ServerVersion() (version string) {
c.panicIfNotTracking()
version, _ = c.GetServerOption("VERSION")
version, _ = c.GetServerOpt("VERSION")
return version
}
@ -704,21 +775,14 @@ func (c *Client) ServerVersion() (version string) {
func (c *Client) ServerMOTD() (motd string) {
c.panicIfNotTracking()
c.state.RLock()
motd = c.state.motd
c.state.RUnlock()
return motd
return c.state.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) {
c.mu.RLock()
c.conn.mu.RLock()
delta = c.conn.lastPong.Sub(c.conn.lastPing)
c.conn.mu.RUnlock()
c.mu.RUnlock()
delta = c.conn.lastPong.Load().(time.Time).Sub(c.conn.lastPing.Load().(time.Time))
if delta < 0 {
return 0
@ -739,15 +803,13 @@ func (c *Client) HasCapability(name string) (has bool) {
name = strings.ToLower(name)
c.state.RLock()
for key := range c.state.enabledCap {
key = strings.ToLower(key)
for capab := range c.state.enabledCap.IterBuffered() {
key := strings.ToLower(capab.Key)
if key == name {
has = true
break
}
}
c.state.RUnlock()
return has
}
@ -756,6 +818,9 @@ func (c *Client) HasCapability(name string) (has bool) {
// 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
}
@ -783,8 +848,10 @@ 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,19 +19,16 @@ func TestDisableTracking(t *testing.T) {
Name: "Testing123",
})
if len(client.Handlers.internal) < 1 {
if client.Handlers.internal.len() < 1 {
t.Fatal("Client.Handlers empty, though just initialized")
}
client.DisableTracking()
if _, ok := client.Handlers.internal[CAP]; ok {
if _, ok := client.Handlers.internal.cm.Get(CAP); ok {
t.Fatal("Client.Handlers contains capability tracking handlers, though disabled")
}
client.state.Lock()
defer client.state.Unlock()
if client.state.channels != nil {
if len(client.state.channels.Keys()) > 0 {
t.Fatal("Client.DisableTracking() called but channel state still exists")
}
}
@ -96,14 +93,24 @@ func TestClientLifetime(t *testing.T) {
func TestClientUptime(t *testing.T) {
c, conn, server := genMockConn()
defer conn.Close()
defer server.Close()
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)
}
}()
go mockReadBuffer(conn)
done := make(chan struct{}, 1)
c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) })
go c.MockConnect(server)
go func() {
if err := c.MockConnect(server); err != nil {
t.Errorf("failed to connect: %s", err)
}
}()
defer c.Close()
select {
@ -117,7 +124,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)
@ -141,14 +148,24 @@ func TestClientUptime(t *testing.T) {
func TestClientGet(t *testing.T) {
c, conn, server := genMockConn()
defer conn.Close()
defer server.Close()
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)
}
}()
go mockReadBuffer(conn)
done := make(chan struct{}, 1)
c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) })
go c.MockConnect(server)
go func() {
if err := c.MockConnect(server); err != nil {
t.Errorf("failed to connect: %s", err)
}
}()
defer c.Close()
select {
@ -172,8 +189,14 @@ func TestClientGet(t *testing.T) {
func TestClientClose(t *testing.T) {
c, conn, server := genMockConn()
defer server.Close()
defer conn.Close()
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)
}
}()
go mockReadBuffer(conn)
errchan := make(chan error, 1)

@ -1,199 +0,0 @@
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)
}

230
codebook.go Normal file

@ -0,0 +1,230 @@
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",
}

@ -36,7 +36,7 @@ func (cmd *Commands) Join(channels ...string) {
continue
}
if len(buffer) == 0 {
if buffer == "" {
buffer = channels[i]
} else {
buffer += "," + channels[i]
@ -111,7 +111,8 @@ 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{}) {
cmd.Message(target, fmt.Sprintf(format, a...))
message := fmt.Sprintf(format, a...)
cmd.Message(target, Fmt(message))
}
// ErrInvalidSource is returned when a method needs to know the origin of an
@ -119,52 +120,83 @@ 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) {
func (cmd *Commands) Reply(event Event, message string) error {
if event.Source == nil {
panic(ErrInvalidSource)
return ErrInvalidSource
}
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Message(event.Params[0], message)
return
return nil
}
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.
func (cmd *Commands) Replyf(event Event, format string, a ...interface{}) {
cmd.Reply(event, fmt.Sprintf(format, a...))
// 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))
}
// 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) {
func (cmd *Commands) ReplyTo(event Event, message string) error {
if event.Source == nil {
panic(ErrInvalidSource)
return ErrInvalidSource
}
if len(event.Params) > 0 && IsValidChannel(event.Params[0]) {
cmd.Message(event.Params[0], event.Source.Name+", "+message)
return
} else {
cmd.Message(event.Source.Name, message)
}
cmd.Message(event.Source.Name, message)
return nil
}
// 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.
func (cmd *Commands) ReplyTof(event Event, format string, a ...interface{}) {
cmd.ReplyTo(event, fmt.Sprintf(format, a...))
// 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))
}
// Action sends a PRIVMSG ACTION (/me) to target (either channel, service,
@ -188,9 +220,9 @@ func (cmd *Commands) Notice(target, message string) {
}
// Noticef sends a formated NOTICE to target (either channel, service, or
// user).
// user). Formatted means both in the sense of Sprintf as well as girc styling codes.
func (cmd *Commands) Noticef(target, format string, a ...interface{}) {
cmd.Notice(target, fmt.Sprintf(format, a...))
cmd.Notice(target, Fmt(fmt.Sprintf(format, a...)))
}
// SendRaw sends a raw string (or multiple) to the server, without carriage
@ -212,9 +244,9 @@ func (cmd *Commands) SendRaw(raw ...string) error {
}
// SendRawf sends a formated string back to the server, without carriage
// returns or newlines.
// returns or newlines. Formatted means both in the sense of Sprintf as well as girc style macros.
func (cmd *Commands) SendRawf(format string, a ...interface{}) error {
return cmd.SendRaw(fmt.Sprintf(format, a...))
return cmd.SendRaw(Fmt(fmt.Sprintf(format, a...)))
}
// Topic sets the topic of channel to message. Does not verify the length
@ -223,13 +255,19 @@ 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,2" per default. Do not use "1" as this will conflict with
// girc's builtin tracking functionality.
// sends "%tcuhnr,1" per default.
func (cmd *Commands) Who(users ...string) {
for i := 0; i < len(users); i++ {
cmd.c.Send(&Event{Command: WHO, Params: []string{users[i], "%tcuhnr,2"}})
cmd.c.Send(&Event{Command: WHO, Params: []string{users[i], "%tacuhnr,1"}})
}
}
@ -260,6 +298,22 @@ 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.
@ -267,7 +321,6 @@ 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}})
}
@ -340,7 +393,7 @@ func (cmd *Commands) List(channels ...string) {
continue
}
if len(buffer) == 0 {
if buffer == "" {
buffer = channels[i]
} else {
buffer += "," + channels[i]
@ -356,7 +409,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, string(amount)}})
cmd.c.Send(&Event{Command: WHOWAS, Params: []string{user, fmt.Sprintf("%d", amount)}})
}
// Monitor sends a MONITOR query to the server. The results of the query

194
conn.go

@ -10,8 +10,10 @@ import (
"crypto/tls"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"git.tcp.direct/kayos/common/pool"
)
// Messages are delimited with CR and LF line endings, we're using the last
@ -26,25 +28,23 @@ 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 time.Time
lastWrite atomic.Value
// lastActive is the last time the client was interacting with the server,
// excluding a few background commands (PING, PONG, WHO, etc).
lastActive time.Time
lastActive atomic.Value
// writeDelay is used to keep track of rate limiting of events sent to
// the server.
writeDelay time.Duration
writeDelay atomic.Value
// connected is true if we're actively connected to a server.
connected bool
connected atomic.Value
// connTime is the time at which the client has connected to a server.
connTime *time.Time
connTime atomic.Value
// lastPing is the last time that we pinged the server.
lastPing time.Time
lastPing atomic.Value
// lastPong is the last successful time that we pinged the server and
// received a successful pong back.
lastPong time.Time
pingDelay time.Duration
lastPong atomic.Value
}
// Dialer is an interface implementation of net.Dialer. Use this if you would
@ -57,6 +57,8 @@ 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 {
@ -71,7 +73,11 @@ func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ir
if conf.Bind != "" {
var local *net.TCPAddr
local, err = net.ResolveTCPAddr("tcp", conf.Bind+":0")
s := strs.Get()
s.MustWriteString(conf.Bind)
s.MustWriteString(":0")
local, err = net.ResolveTCPAddr("tcp", s.String())
strs.MustPut(s)
if err != nil {
return nil, err
}
@ -112,25 +118,27 @@ func newConn(conf Config, dialer Dialer, addr string, sts *strictTransport) (*ir
conn = tlsConn
}
ctime := time.Now()
c := &ircConn{
sock: conn,
connTime: &ctime,
connected: true,
connTime: atomic.Value{},
connected: atomic.Value{},
}
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: &ctime,
connected: true,
connTime: atomic.Value{},
connected: atomic.Value{},
}
c.connTime.Store(time.Now())
c.connected.Store(true)
c.newReadWriter()
return c
@ -143,6 +151,17 @@ 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 {
@ -156,17 +175,6 @@ 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))
}
@ -262,9 +270,6 @@ 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")
}
@ -276,7 +281,7 @@ startConn:
if mock == nil {
// Validate info, and actually make the connection.
c.debug.Printf("connecting to %s... (sts: %v, config-ssl: %v)", addr, c.state.sts.enabled(), c.Config.SSL)
c.debug.Printf("(%s) connecting to %s... (sts: %v, config-ssl: %v)", c.Config.Nick, 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 {
@ -284,7 +289,6 @@ startConn:
c.RunHandlers(&Event{Command: STS_ERR_FALLBACK})
}
}
c.mu.Unlock()
return err
}
@ -295,17 +299,16 @@ startConn:
var ctx context.Context
ctx, c.stop = context.WithCancel(context.Background())
c.mu.Unlock()
errs := make(chan error, 4)
var wg sync.WaitGroup
var working int32
// 4 being the number of goroutines we need to finish when this function
// returns.
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)
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)
// Passwords first.
@ -326,7 +329,9 @@ 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 == "" {
@ -347,40 +352,41 @@ startConn:
}
c.RunHandlers(&Event{Command: CLOSED, Params: []string{addr}})
case err := <-errs:
c.debug.Printf("received error, beginning cleanup: %v", err)
c.debug.Printf("(%s) received error, beginning cleanup: %v", c.Config.Nick, err)
result = err
}
// Make sure that the connection is closed if not already.
c.mu.RLock()
if c.stop != nil {
c.stop()
}
c.conn.mu.Lock()
c.conn.connected = false
c.conn.connected.Store(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
}
@ -388,14 +394,13 @@ 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, wg *sync.WaitGroup) {
func (c *Client) readLoop(ctx context.Context, errs chan error, working *int32) {
c.debug.Print("starting readLoop")
defer c.debug.Print("closing readLoop")
@ -405,17 +410,19 @@ func (c *Client) readLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
for {
select {
case <-ctx.Done():
wg.Done()
atomic.AddInt32(working, -1)
return
default:
_ = c.conn.sock.SetReadDeadline(time.Now().Add(300 * time.Second))
event, err = c.conn.decode()
if err != nil {
errs <- err
wg.Done()
atomic.AddInt32(working, -1)
return
}
event.Network = c.NetworkName()
// Check if it's an echo-message.
if !c.Config.disableTracking {
event.Echo = (event.Command == PRIVMSG || event.Command == NOTICE) &&
@ -432,21 +439,17 @@ func (c *Client) readLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
func (c *Client) Send(event *Event) {
var delay time.Duration
if !c.Config.AllowFlood {
c.mu.RLock()
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)
c.mu.RUnlock()
return
}
c.conn.mu.Lock()
delay = c.conn.rate(event.Len())
c.conn.mu.Unlock()
c.mu.RUnlock()
}
if c.Config.GlobalFormat && len(event.Params) > 0 && event.Params[len(event.Params)-1] != "" &&
@ -461,9 +464,6 @@ func (c *Client) Send(event *Event) {
// 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,21 +477,30 @@ 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 += _time - time.Now().Sub(c.lastWrite); c.writeDelay < 0 {
c.writeDelay = 0
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 > (8 * time.Second) {
if c.writeDelay.Load().(time.Duration) > (8 * time.Second) {
return _time
}
return 0
}
func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) {
func (c *Client) sendLoop(ctx context.Context, errs chan error, working *int32) {
c.debug.Print("starting sendLoop")
defer c.debug.Print("closing sendLoop")
defer atomic.AddInt32(working, -1)
var err error
for {
@ -500,15 +509,13 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
// 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 < len(c.state.enabledCap); i++ {
if _, ok := c.state.enabledCap["message-tags"]; ok {
for i := 0; i < c.state.enabledCap.Count(); i++ {
if _, ok := c.state.enabledCap.Get("message-tags"); ok {
in = true
break
}
}
c.state.RUnlock()
if !in {
event.Tags = Tags{}
@ -517,13 +524,11 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
c.debugLogEvent(event, false)
c.conn.mu.Lock()
c.conn.lastWrite = time.Now()
c.conn.lastWrite.Store(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())
@ -538,17 +543,14 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
if event.Command == QUIT {
c.Close()
wg.Done()
return
}
if err != nil {
errs <- err
wg.Done()
return
}
case <-ctx.Done():
wg.Done()
return
}
}
@ -569,26 +571,25 @@ 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, wg *sync.WaitGroup) {
func (c *Client) pingLoop(ctx context.Context, errs chan error, working *int32) {
defer atomic.AddInt32(working, -1)
// 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.mu.Lock()
c.conn.lastPing = time.Now()
c.conn.lastPong = time.Now()
c.conn.mu.Unlock()
c.conn.lastPing.Store(time.Now())
c.conn.lastPong.Store(time.Now())
tick := time.NewTicker(c.Config.PingDelay)
defer tick.Stop()
started := time.Now()
past := false
pingSent := false
for {
select {
@ -603,30 +604,27 @@ func (c *Client) pingLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
past = true
}
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,
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),
Delay: c.Config.PingDelay,
}
wg.Done()
c.conn.mu.RUnlock()
go func() {
errs <- err
}()
return
}
c.conn.mu.RUnlock()
c.conn.mu.Lock()
c.conn.lastPing = time.Now()
c.conn.mu.Unlock()
c.conn.lastPing.Store(time.Now())
c.Cmd.Ping(fmt.Sprintf("%d", time.Now().UnixNano()))
pingSent = true
case <-ctx.Done():
wg.Done()
return
}
}

@ -8,16 +8,18 @@ import (
"bufio"
"bytes"
"net"
"os"
"sync/atomic"
"testing"
"time"
)
func mockBuffers() (in *bytes.Buffer, out *bytes.Buffer, irc *ircConn) {
func mockBuffers() (in, out *bytes.Buffer, irc *ircConn) {
in = &bytes.Buffer{}
out = &bytes.Buffer{}
irc = &ircConn{
io: bufio.NewReadWriter(bufio.NewReader(in), bufio.NewWriter(out)),
connected: true,
connected: atomic.Value{},
}
return in, out, irc
@ -46,8 +48,6 @@ 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,13 +70,11 @@ 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 = time.Now()
c.lastWrite.Store(time.Now())
if delay := c.rate(100); delay > time.Second {
t.Fatal("first instance of rate is > second")
}
@ -88,17 +86,16 @@ 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 net.Conn, serverConn net.Conn) {
func genMockConn() (client *Client, clientConn, serverConn net.Conn) {
client = New(Config{
Server: "dummy.int",
Port: 6667,
Nick: "test",
User: "test",
Name: "Testing123",
Debug: os.Stdout,
})
conn1, conn2 := net.Pipe()
@ -110,7 +107,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,6 +5,8 @@
package girc
// Standard CTCP based constants.
//
//goland:noinspection ALL
const (
CTCP_ACTION = "ACTION"
CTCP_PING = "PING"
@ -20,6 +22,8 @@ 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.
@ -33,6 +37,8 @@ const (
)
// User/channel prefixes :: RFC1459.
//
//goland:noinspection ALL
const (
DefaultPrefixes = "(ov)@+" // the most common default prefixes
ModeAddPrefix = "+" // modes are being added
@ -48,6 +54,8 @@ const (
)
// User modes :: RFC1459; section 4.2.3.2.
//
//goland:noinspection ALL
const (
UserModeInvisible = "i" // invisible
UserModeOperator = "o" // server operator
@ -56,6 +64,8 @@ const (
)
// Channel modes :: RFC1459; section 4.2.3.1.
//
//goland:noinspection ALL
const (
ModeDefaults = "beI,k,l,imnpst" // the most common default modes
@ -75,6 +85,8 @@ const (
)
// IRC commands :: RFC2812; section 3 :: RFC2813; section 4.
//
//goland:noinspection ALL
const (
ADMIN = "ADMIN"
AWAY = "AWAY"
@ -127,6 +139,8 @@ const (
)
// Numeric IRC reply mapping :: RFC2812; section 5.
//
//goland:noinspection ALL
const (
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
@ -270,6 +284,8 @@ const (
)
// IRCv3 commands and extensions :: http://ircv3.net/irc/.
//
//goland:noinspection ALL
const (
AUTHENTICATE = "AUTHENTICATE"
MONITOR = "MONITOR"
@ -293,6 +309,8 @@ const (
)
// Numeric IRC reply mapping for ircv3 :: http://ircv3.net/irc/.
//
//goland:noinspection ALL
const (
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
@ -313,6 +331,8 @@ const (
)
// Numeric IRC event mapping :: RFC2812; section 5.3.
//
//goland:noinspection ALL
const (
RPL_STATSCLINE = "213"
RPL_STATSNLINE = "214"
@ -341,10 +361,22 @@ 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,11 +5,12 @@
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.
@ -104,8 +105,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 len(cmd) <= 0 {
return ""
if cmd == "" {
return cmd
}
out = string(ctcpDelim) + cmd
@ -123,45 +124,31 @@ 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 map[string]CTCPHandler
handlers cmap.ConcurrentMap[string, CTCPHandler]
}
// newCTCP returns a new clean CTCP handler.
func newCTCP() *CTCP {
return &CTCP{handlers: map[string]CTCPHandler{}}
return &CTCP{handlers: cmap.New[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 _, ok := c.handlers["*"]; ok {
c.handlers["*"](client, *event)
if val, ok := c.handlers.Get("*"); ok && val != nil {
val(client, *event)
}
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")
}
val, ok := c.handlers.Get(event.Command)
if !ok || val == nil || event.Command == CTCP_ACTION {
return
}
c.handlers[event.Command](client, *event)
val(client, *event)
}
// parseCMD parses a CTCP command/tag, ensuring it's valid. If not, an empty
@ -193,10 +180,7 @@ func (c *CTCP) Set(cmd string, handler func(client *Client, ctcp CTCPEvent)) {
if cmd = c.parseCMD(cmd); cmd == "" {
return
}
c.mu.Lock()
c.handlers[cmd] = CTCPHandler(handler)
c.mu.Unlock()
c.handlers.Set(cmd, handler)
}
// SetBg is much like Set, however the handler is executed in the background,
@ -213,18 +197,12 @@ func (c *CTCP) Clear(cmd string) {
if cmd = c.parseCMD(cmd); cmd == "" {
return
}
c.mu.Lock()
delete(c.handlers, cmd)
c.mu.Unlock()
c.handlers.Remove(cmd)
}
// ClearAll removes all currently setup and re-sets the default handlers.
func (c *CTCP) ClearAll() {
c.mu.Lock()
c.handlers = map[string]CTCPHandler{}
c.mu.Unlock()
c.handlers = cmap.New[CTCPHandler]()
// Register necessary handlers.
c.addDefaultHandlers()
}
@ -239,6 +217,8 @@ 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)
}
@ -260,8 +240,7 @@ func handleCTCPPong(client *Client, ctcp CTCPEvent) {
}
// handleCTCPVersion replies with the name of the client, Go version, as well
// as the os type (darwin, linux, windows, etc) and architecture type (x86,
// arm, etc).
// as the os type if not overridden by client configuration.
func handleCTCPVersion(client *Client, ctcp CTCPEvent) {
if client.Config.Version != "" {
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_VERSION, client.Config.Version)
@ -270,14 +249,34 @@ func handleCTCPVersion(client *Client, ctcp CTCPEvent) {
client.Cmd.SendCTCPReplyf(
ctcp.Source.ID(), CTCP_VERSION,
"girc (github.com/lrstanley/girc) using %s (%s, %s)",
runtime.Version(), runtime.GOOS, runtime.GOARCH,
"girc-atomic %s (%s, %s)",
Version, runtime.GOOS, runtime.GOARCH,
)
}
// handleCTCPSource replies with the public git location of this library.
// 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.
func handleCTCPSource(client *Client, ctcp CTCPEvent) {
client.Cmd.SendCTCPReply(ctcp.Source.ID(), CTCP_SOURCE, "https://github.com/lrstanley/girc")
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")
}
// handleCTCPTime replies with a RFC 1123 (Z) formatted version of Go's
@ -289,9 +288,11 @@ 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) {
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)))
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)))
}

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

@ -13,7 +13,7 @@ import (
const (
eventSpace byte = ' ' // Separator.
maxLength = 510 // Maximum length is 510 (2 for line endings).
maxLength int = 510 // Maximum length is 510 (2 for line endings).
)
// cutCRFunc is used to trim CR characters from prefixes/messages.
@ -52,7 +52,7 @@ func ParseEvent(raw string) (e *Event) {
i = 0
}
if raw[0] == messagePrefix {
if raw != "" && raw[0] == messagePrefix {
// Prefix ends with a space.
i = strings.IndexByte(raw, eventSpace)
@ -155,8 +155,10 @@ 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"`
// If the event is an echo-message response.
// Echo is 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.
@ -167,6 +169,16 @@ 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.
@ -248,7 +260,8 @@ 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], " ") || e.Params[i] == "") {
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") ||
strings.HasPrefix(e.Params[i], ":") || e.Params[i] == "") {
length++
}
}
@ -268,7 +281,9 @@ func (e *Event) Bytes() []byte {
// Tags.
if e.Tags != nil {
e.Tags.writeTo(buffer)
if _, err := e.Tags.writeTo(buffer); err != nil {
return nil
}
}
// Event prefix.
@ -284,9 +299,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], " ") || e.Params[i] == "") {
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || strings.HasPrefix(e.Params[i], ":") || e.Params[i] == "") {
buffer.WriteString(string(eventSpace) + string(messagePrefix) + e.Params[i])
continue
}
@ -299,7 +313,9 @@ func (e *Event) Bytes() []byte {
buffer.Truncate(maxLength)
}
out := buffer.Bytes()
// If we truncated in the middle of a utf8 character, we need to remove
// the other (now invalid) bytes.
out := bytes.ToValidUTF8(buffer.Bytes(), nil)
// Strip newlines and carriage returns.
for i := 0; i < len(out); i++ {
@ -363,7 +379,15 @@ func (e *Event) Pretty() (out string, ok bool) {
return fmt.Sprintf("[*] CTCP query from %s: %s%s", ctcp.Source.Name, ctcp.Command, " "+ctcp.Text), true
}
return fmt.Sprintf("[%s] (%s) %s", strings.Join(e.Params[0:len(e.Params)-1], ","), e.Source.Name, e.Last()), true
var source string
if e.Command == PRIVMSG {
source = fmt.Sprintf("(%s)", e.Source.Name)
} else { // NOTICE
source = fmt.Sprintf("--%s--", e.Source.Name)
}
return fmt.Sprintf("[%s] %s %s", strings.Join(e.Params[0:len(e.Params)-1], ","), source, e.Last()), true
}
if e.Command == RPL_MOTD || e.Command == RPL_MOTDSTART ||
@ -596,14 +620,16 @@ func (s *Source) Bytes() []byte {
// String returns a string representation of source.
func (s *Source) String() (out string) {
out = s.Name
out = ""
if len(s.Name) > 0 {
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
}
@ -614,7 +640,7 @@ func (s *Source) IsHostmask() bool {
// IsServer returns true if this source looks like a server name.
func (s *Source) IsServer() bool {
return len(s.Ident) <= 0 && len(s.Host) <= 0
return s.Ident == "" && s.Host == ""
}
// writeTo is an utility function to write the source to the bytes.Buffer
@ -629,6 +655,4 @@ func (s *Source) writeTo(buffer *bytes.Buffer) {
buffer.WriteByte(prefixHost)
buffer.WriteString(s.Host)
}
return
}

@ -84,7 +84,10 @@ 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"},
@ -108,7 +111,7 @@ func TestParseEvent(t *testing.T) {
}
if got == nil {
t.Errorf("ParseEvent: got nil, want: %s", tt.want)
t.Fatalf("ParseEvent: got nil, want: %s", tt.want)
}
if got.String() != tt.want {
@ -130,6 +133,7 @@ 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/lrstanley/girc"
"github.com/yunginnanet/girc-atomic"
)
func ExampleNew() {
@ -35,7 +35,6 @@ func Example_bare() {
Port: 6667,
Nick: "test",
User: "user",
Debug: os.Stdout,
})
if err := client.Connect(); err != nil {
@ -52,7 +51,6 @@ 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) {
@ -61,7 +59,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
}
@ -101,7 +99,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
}

@ -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.Replace(text, string(fmtOpenChar)+color+string(fmtCloseChar), "", -1)
text = strings.ReplaceAll(text, string(fmtOpenChar)+color+string(fmtCloseChar), "")
}
for code := range fmtCodes {
text = strings.Replace(text, string(fmtOpenChar)+code+string(fmtCloseChar), "", -1)
text = strings.ReplaceAll(text, string(fmtOpenChar)+code+string(fmtCloseChar), "")
}
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]?[0-9](,[019]?[0-9])?)?`)
var reStripColor = regexp.MustCompile(`\x03([019]?\d(,[019]?\d)?)?`)
// 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.Replace(text, code, "", -1)
text = strings.ReplaceAll(text, code, "")
}
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 len(nick) <= 0 {
if nick == "" {
return false
}
@ -253,10 +253,11 @@ 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 len(name) <= 0 {
if name == "" {
return false
}
@ -324,7 +325,7 @@ func Glob(input, match string) bool {
if len(parts) == 1 {
// No globs, test for equality.
return input == match
return strings.EqualFold(input, match)
}
leadingGlob, trailingGlob := strings.HasPrefix(match, globChar), strings.HasSuffix(match, globChar)

@ -7,38 +7,31 @@ 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) {
@ -46,8 +39,6 @@ func BenchmarkStripRaw(b *testing.B) {
for i := 0; i < b.N; i++ {
StripRaw(text)
}
return
}
func BenchmarkStripRawLong(b *testing.B) {
@ -55,202 +46,287 @@ func BenchmarkStripRawLong(b *testing.B) {
for i := 0; i < b.N; i++ {
StripRaw(text)
}
}
return
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)
}
}
})
}
func TestFormat(t *testing.T) {
type args struct {
text string
}
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"},
}
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)
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)
}
f.Fuzz(func(t *testing.T, orig string) {
got := TrimFmt(orig)
got2 := TrimFmt(got)
if utf8.ValidString(orig) {
if !utf8.ValidString(got) {
t.Errorf("produced invalid UTF-8 string %q", got)
}
if !utf8.ValidString(got2) {
t.Errorf("produced invalid UTF-8 string %q", got2)
}
}
})
}
func TestStripFormat(t *testing.T) {
type args struct {
text string
}
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."},
}
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)
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)
}
f.Fuzz(func(t *testing.T, orig string) {
got := StripRaw(orig)
got2 := StripRaw(got)
if utf8.ValidString(orig) {
if !utf8.ValidString(got) {
t.Errorf("produced invalid UTF-8 string %q", got)
}
if !utf8.ValidString(got2) {
t.Errorf("produced invalid UTF-8 string %q", got2)
}
}
})
}
func TestStripRaw(t *testing.T) {
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)
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)
}
}
}
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) {
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)
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)
}
}
}
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) {
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)
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)
}
}
}
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) {
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)
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)
}
}
}
func TestToRFC1459(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", ""},
{"a", "a"},
{"abcd", "abcd"},
{"AbcD", "abcd"},
{"!@#$%^&*()_+-=", "!@#$%~&*()_+-="},
{"Abcd[]", "abcd{}"},
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)
}
for _, tt := range cases {
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 {
if got := ToRFC1459(tt.in); got != tt.want {
t.Errorf("ToRFC1459() = %q, want %q", got, tt.want)
}
}
return
}
func BenchmarkGlob(b *testing.B) {
@ -259,31 +335,23 @@ 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) {
@ -324,37 +392,43 @@ func TestEmptySubject(t *testing.T) {
for _, pattern := range cases {
testGlobNoMatch(t, pattern, "")
}
return
}
func TestPatternWithoutGlobs(t *testing.T) {
testGlobMatch(t, "test", "test")
}
return
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)
})
}
func TestGlob(t *testing.T) {
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 {
for _, pattern := range testsGlob {
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.
@ -368,6 +442,4 @@ func TestGlob(t *testing.T) {
for _, pattern := range cases {
testGlobNoMatch(t, "this is a test", pattern)
}
return
}

10
go.mod

@ -1,3 +1,9 @@
module github.com/lrstanley/girc
module github.com/yunginnanet/girc-atomic
go 1.12
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
)

19
go.sum

@ -0,0 +1,19 @@
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,24 +12,31 @@ 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.
prefix := "< "
s.MustWriteString("< ")
if event.Echo {
prefix += "[echo-message] "
s.MustWriteString("[echo-message] ")
}
c.debug.Print(prefix + StripRaw(event.String()))
s.MustWriteString(event.String())
c.debug.Print(s.String())
strs.MustPut(s)
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))
}
}
@ -41,15 +48,16 @@ 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
@ -67,29 +75,87 @@ 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
mu *sync.RWMutex
parent *Client
// 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 map[string]map[string]Handler
external *nestedHandlers
// external map[string]map[string]Handler
// internal is a map of internally used handlers for the client.
internal map[string]map[string]Handler
internal *nestedHandlers
// debug is the clients logger used for debugging.
debug *log.Logger
}
// newCaller creates and initializes a new handler.
func newCaller(debugOut *log.Logger) *Caller {
func newCaller(parent *Client, debugOut *log.Logger) *Caller {
c := &Caller{
external: map[string]map[string]Handler{},
internal: map[string]map[string]Handler{},
external: newNestedHandlers(),
internal: newNestedHandlers(),
debug: debugOut,
parent: parent,
mu: &sync.RWMutex{},
}
return c
@ -97,45 +163,18 @@ func newCaller(debugOut *log.Logger) *Caller {
// Len returns the total amount of user-entered registered handlers.
func (c *Caller) Len() int {
var total int
c.mu.RLock()
for command := range c.external {
total += len(c.external[command])
}
c.mu.RUnlock()
return total
return c.external.len()
}
// 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)
c.mu.RLock()
for command := range c.external {
if command == cmd {
total += len(c.external[command])
}
}
c.mu.RUnlock()
return total
return c.external.lenFor(cmd)
}
func (c *Caller) String() string {
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)
return fmt.Sprintf("<Caller external:%d internal:%d>", c.Len(), c.internal.len())
}
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
@ -173,88 +212,90 @@ 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.
if _, ok := c.internal[command]; ok {
for cuid := range c.internal[command] {
hmap, iok := c.internal.cm.Get(command)
if iok {
for assigned := range hmap.IterBuffered() {
cuid := assigned.Key
if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) {
continue
}
stack = append(stack, execStack{c.internal[command][cuid], cuid})
hndlr, _ := hmap.Get(cuid)
stack = append(stack, execStack{hndlr, cuid})
}
}
// Then external handlers.
if _, ok := c.external[command]; ok {
for cuid := range c.external[command] {
hmap, eok := c.external.cm.Get(command)
if eok {
for _, cuid := range hmap.Keys() {
if (strings.HasSuffix(cuid, ":bg") && !bg) || (!strings.HasSuffix(cuid, ":bg") && bg) {
continue
}
stack = append(stack, execStack{c.external[command][cuid], cuid})
hndlr, _ := hmap.Get(cuid)
stack = append(stack, execStack{hndlr, 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 wg sync.WaitGroup
wg.Add(len(stack))
var working int32
atomic.AddInt32(&working, int32(len(stack)))
// c.debug.Printf("starting %d jobs", atomic.LoadInt32(&working))
for i := 0; i < len(stack); i++ {
go func(index int) {
defer wg.Done()
c.debug.Printf("[%d/%d] exec %s => %s", index+1, len(stack), stack[index].cuid, command)
start := time.Now()
// c.debug.Printf("(%s) [%d/%d] exec %s => %s", c.parent.Config.Nick,
// 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].Execute(client, *event)
c.debug.Printf("[%d/%d] done %s == %s", index+1, len(stack), stack[index].cuid, time.Since(start))
stack[index].Handler.Execute(client, *event)
// c.debug.Printf("(%s) done %s == %s", c.parent.Config.Nick,
// 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].Execute(client, *event)
c.debug.Printf("[%d/%d] done %s == %s", index+1, len(stack), stack[index].cuid, time.Since(start))
stack[index].Handler.Execute(client, *event)
// c.debug.Printf("(%s) done %s == %s", c.parent.Config.Nick, stack[index].cuid, time.Since(start))
}(i)
}
// Wait for all of the handlers to complete. Not doing this may cause
// new events from becoming ahead of older handlers.
wg.Wait()
// 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
}
}
}
// ClearAll clears all external handlers currently setup within the client.
// This ignores internal handlers.
func (c *Caller) ClearAll() {
c.mu.Lock()
c.external = map[string]map[string]Handler{}
c.mu.Unlock()
c.external.cm.Clear()
c.debug.Print("cleared all external handlers")
}
// clearInternal clears all internal handlers currently setup within the
// client.
func (c *Caller) clearInternal() {
c.mu.Lock()
c.internal = map[string]map[string]Handler{}
c.mu.Unlock()
c.internal.cm.Clear()
c.debug.Print("cleared all internal handlers")
}
@ -262,46 +303,39 @@ func (c *Caller) clearInternal() {
// This ignores internal handlers.
func (c *Caller) Clear(cmd string) {
cmd = strings.ToUpper(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)
c.external.cm.Remove(cmd)
c.debug.Printf("(%s) cleared external handlers for %s", c.parent.Config.Nick, 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.mu.Lock()
success = c.remove(cuid)
c.mu.Unlock()
return success
c.remove(cuid)
return true
}
// remove is much like Remove, however is NOT concurrency safe. Lock Caller.mu
// on your own.
func (c *Caller) remove(cuid string) (success bool) {
func (c *Caller) remove(cuid string) (ok 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.
if _, ok := c.external[cmd]; !ok {
return false
var hs cmap.ConcurrentMap[string, Handler]
hs, ok = c.external.cm.Get(cmd)
if !ok {
return
}
// Check to see if it's actually a registered handler.
if _, ok := c.external[cmd][uid]; !ok {
return false
if _, ok = hs.Get(cuid); !ok {
return
}
delete(c.external[cmd], uid)
hs.Remove(uid)
c.debug.Printf("removed handler %s", cuid)
// Assume success.
@ -312,9 +346,8 @@ func (c *Caller) remove(cuid string) (success 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
}
@ -331,22 +364,29 @@ 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 {
if _, ok := c.internal[cmd]; !ok {
c.internal[cmd] = map[string]Handler{}
}
c.internal[cmd][uid] = handler
parent = c.internal
} else {
if _, ok := c.external[cmd]; !ok {
c.external[cmd] = map[string]Handler{}
}
c.external[cmd][uid] = handler
parent = c.external
}
_, file, line, _ := runtime.Caller(3)
chandlers, ok = parent.cm.Get(cmd)
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
@ -458,7 +498,6 @@ 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
@ -495,6 +534,8 @@ 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())

30
handler_test.go Normal file

@ -0,0 +1,30 @@
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,7 +7,8 @@ package girc
import (
"encoding/json"
"strings"
"sync"
cmap "github.com/orcaman/concurrent-map/v2"
)
// CMode represents a single step of a given mode change.
@ -118,13 +119,14 @@ 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
@ -157,7 +159,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 new []CMode
var newcm []CMode
for j := 0; j < len(c.modes); j++ {
isin := false
@ -166,14 +168,14 @@ func (c *CModes) Apply(modes []CMode) {
continue
}
if c.modes[j].name == modes[i].name && modes[i].add {
new = append(new, modes[i])
newcm = append(newcm, modes[i])
isin = true
break
}
}
if !isin {
new = append(new, c.modes[j])
newcm = append(newcm, c.modes[j])
}
}
@ -183,19 +185,19 @@ func (c *CModes) Apply(modes []CMode) {
}
isin := false
for j := 0; j < len(new); j++ {
if modes[i].name == new[j].name {
for j := 0; j < len(newcm); j++ {
if modes[i].name == newcm[j].name {
isin = true
break
}
}
if !isin {
new = append(new, modes[i])
newcm = append(newcm, modes[i])
}
}
c.modes = new
c.modes = newcm
}
// Parse parses a set of flags and args, returning the necessary list of
@ -333,10 +335,9 @@ func handleMODE(c *Client, e Event) {
return
}
c.state.RLock()
channel := c.state.lookupChannel(e.Params[0])
if channel == nil {
c.state.RUnlock()
return
}
@ -363,17 +364,15 @@ 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 modes, ok := s.serverOptions["CHANMODES"]; ok && IsValidChannelMode(modes) {
return modes
if validmodes, ok := s.serverOptions.Get("CHANMODES"); ok && IsValidChannelMode(validmodes) {
return validmodes
}
return ModeDefaults
}
@ -381,64 +380,47 @@ 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["PREFIX"]; ok && isValidUserPrefix(prefix) {
if prefix, ok := s.serverOptions.Get("PREFIX"); ok && isValidUserPrefix(prefix) {
return prefix
}
return DefaultPrefixes
}
// UserPerms contains all of the permissions for each channel the user is
// in.
type UserPerms struct {
mu sync.RWMutex
channels map[string]Perms
channels cmap.ConcurrentMap[string, *Perms]
}
// Copy returns a deep copy of the channel permissions.
func (p *UserPerms) Copy() (perms *UserPerms) {
np := &UserPerms{
channels: make(map[string]Perms),
channels: cmap.New[*Perms](),
}
p.mu.RLock()
for key := range p.channels {
np.channels[key] = p.channels[key]
for tuple := range p.channels.IterBuffered() {
np.channels.Set(tuple.Key, tuple.Val)
}
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) {
p.mu.RLock()
perms, ok = p.channels[ToRFC1459(channel)]
p.mu.RUnlock()
return perms, ok
func (p *UserPerms) Lookup(channel string) (perms *Perms, ok bool) {
return p.channels.Get(ToRFC1459(channel))
}
func (p *UserPerms) set(channel string, perms Perms) {
p.mu.Lock()
p.channels[ToRFC1459(channel)] = perms
p.mu.Unlock()
func (p *UserPerms) set(channel string, perms *Perms) {
p.channels.Set(ToRFC1459(channel), perms)
}
func (p *UserPerms) remove(channel string) {
p.mu.Lock()
delete(p.channels, ToRFC1459(channel))
p.mu.Unlock()
p.channels.Remove(ToRFC1459(channel))
}
// Perms contains all channel-based user permissions. The minimum op, and
@ -464,7 +446,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
}
@ -474,7 +456,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
}

355
state.go

@ -6,24 +6,29 @@ 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 string
nick, ident, host atomic.Value
// channels represents all channels we're active in.
channels map[string]*Channel
// channels map[string]*Channel
channels cmap.ConcurrentMap[string, *Channel]
// users represents all of users that we're tracking.
users map[string]*User
// users map[string]*User
users cmap.ConcurrentMap[string, *User]
// enabledCap are the capabilities which are enabled for this connection.
enabledCap map[string]map[string]string
// enabledCap map[string]map[string]string
enabledCap cmap.ConcurrentMap[string, map[string]string]
// tmpCap are the capabilties which we share with the server during the
// last capability check. These will get sent once we have received the
// last capability list command from the server.
@ -31,10 +36,18 @@ type state struct {
// 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 map[string]string
serverOptions cmap.ConcurrentMap[string, string]
// network is an alternative way to store and retrieve the NETWORK server option.
network atomic.Value
// motd is the servers message of the day.
motd string
// client is a useful pointer to the state's related Client instance.
client *Client
// sts are strict transport security configurations, if specified by the
// server.
//
@ -43,44 +56,69 @@ 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.Lock()
s.nick = ""
s.ident = ""
s.host = ""
s.channels = make(map[string]*Channel)
s.users = make(map[string]*User)
s.serverOptions = make(map[string]string)
s.enabledCap = make(map[string]map[string]string)
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.tmpCap = make(map[string]map[string]string)
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 string `json:"nick"`
Nick *MarshalableAtomicValue `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 string `json:"ident"`
Ident *MarshalableAtomicValue `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.
ChannelList []string `json:"channels"`
//
// NOTE: If the ChannelList is empty for the user, then the user's info could be out of date.
// turns out Concurrent-Map implements json.Marhsal!
// https://github.com/orcaman/concurrent-map/v2/blob/893feb299719d9cbb2cfbe08b6dd4eb567d8039d/concurrent_map.go#L305
ChannelList cmap.ConcurrentMap[string, *Channel] `json:"channels"`
// FirstSeen represents the first time that the user was seen by the
// client for the given channel. Only usable if from state, not in past.
@ -94,6 +132,8 @@ 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 {
@ -114,24 +154,26 @@ type User struct {
} `json:"extras"`
}
// 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 {
// Channels returns a slice of pointers to Channel types that the client knows the user is in.
func (u *User) Channels(c *Client) []*Channel {
if c == nil {
panic("nil Client provided")
}
channels := []*Channel{}
var channels []*Channel
c.state.RLock()
for i := 0; i < len(u.ChannelList); i++ {
ch := c.state.lookupChannel(u.ChannelList[i])
for listed := range u.ChannelList.IterBuffered() {
chn := listed.Val
if chn != nil {
channels = append(channels, chn)
continue
}
ch := c.state.lookupChannel(listed.Key)
if ch != nil {
u.ChannelList.Set(listed.Key, ch)
channels = append(channels, ch)
}
}
c.state.RUnlock()
return channels
}
@ -147,53 +189,45 @@ func (u *User) Copy() *User {
*nu = *u
nu.Perms = u.Perms.Copy()
_ = copy(nu.ChannelList, u.ChannelList)
for ch := range u.ChannelList.IterBuffered() {
nu.ChannelList.Set(ch.Key, ch.Val)
}
return nu
}
// addChannel adds the channel to the users channel list.
func (u *User) addChannel(name string) {
func (u *User) addChannel(name string, chn *Channel) {
name = ToRFC1459(name)
if u.InChannel(name) {
return
}
u.ChannelList = append(u.ChannelList, ToRFC1459(name))
sort.Strings(u.ChannelList)
if u.ChannelList.Has(name) {
return
}
u.Perms.set(name, Perms{})
u.ChannelList.Set(name, chn)
u.Perms.set(name, &Perms{})
}
// deleteChannel removes an existing channel from the users channel list.
func (u *User) deleteChannel(name string) {
name = ToRFC1459(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.ChannelList.Remove(name)
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)
for i := 0; i < len(u.ChannelList); i++ {
if u.ChannelList[i] == name {
return true
}
}
return false
return u.ChannelList.Has(name)
}
// Lifetime represents the amount of time that has passed since we have first
@ -219,10 +253,16 @@ 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 the nickname, and is rfc1459 compliant.
UserList []string `json:"user_list"`
// 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"`
// 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.
@ -231,37 +271,35 @@ 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")
}
users := []*User{}
var users []*User
c.state.RLock()
for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
for listed := range ch.UserList.IterBuffered() {
user := c.state.lookupUser(listed.Key)
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")
}
users := []*User{}
var users []*User
c.state.RLock()
for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
for listed := range ch.UserList.IterBuffered() {
user := c.state.lookupUser(listed.Key)
if user == nil {
continue
}
@ -271,7 +309,6 @@ func (ch Channel) Trusted(c *Client) []*User {
users = append(users, user)
}
}
c.state.RUnlock()
return users
}
@ -279,55 +316,44 @@ 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")
}
users := []*User{}
var users []*User
c.state.RLock()
for i := 0; i < len(ch.UserList); i++ {
user := c.state.lookupUser(ch.UserList[i])
if user == nil {
continue
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)
}
perms, ok := user.Perms.Lookup(ch.Name)
perms, ok := ui.Perms.Lookup(ch.Name)
if ok && perms.IsAdmin() {
users = append(users, user)
users = append(users, ui)
}
}
c.state.RUnlock()
return users
}
// addUser adds a user to the users list.
func (ch *Channel) addUser(nick string) {
func (ch *Channel) addUser(nick string, usr *User) {
if ch.UserIn(nick) {
return
}
ch.UserList = append(ch.UserList, ToRFC1459(nick))
sort.Strings(ch.UserList)
ch.UserList.Set(ToRFC1459(nick), usr)
}
// deleteUser removes an existing user from the users list.
func (ch *Channel) deleteUser(nick string) {
nick = ToRFC1459(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:]...)
}
ch.UserList.Remove(nick)
}
// Copy returns a deep copy of a given channel.
@ -339,7 +365,9 @@ func (ch *Channel) Copy() *Channel {
nc := &Channel{}
*nc = *ch
_ = copy(nc.UserList, ch.UserList)
for v := range ch.UserList.IterBuffered() {
nc.UserList.Set(v.Val.Nick.Load().(string), v.Val)
}
// And modes.
nc.Modes = ch.Modes.Copy()
@ -349,20 +377,13 @@ func (ch *Channel) Copy() *Channel {
// Len returns the count of users in a given channel.
func (ch *Channel) Len() int {
return len(ch.UserList)
return ch.UserList.Count()
}
// UserIn checks to see if a given user is in a channel.
func (ch *Channel) UserIn(name string) bool {
name = ToRFC1459(name)
for i := 0; i < len(ch.UserList); i++ {
if ch.UserList[i] == name {
return true
}
}
return false
return ch.UserList.Has(name)
}
// Lifetime represents the amount of time that has passed since we have first
@ -373,19 +394,21 @@ 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[ToRFC1459(name)]; ok {
if _, ok := s.channels.Get(ToRFC1459(name)); ok {
return false
}
s.channels[ToRFC1459(name)] = &Channel{
s.channels.Set(ToRFC1459(name), &Channel{
Name: name,
UserList: []string{},
UserList: cmap.New[*User](),
Joined: time.Now(),
Network: s.client.NetworkName(),
Modes: NewCModes(supported, prefixes),
}
})
return true
}
@ -394,69 +417,92 @@ func (s *state) createChannel(name string) (ok bool) {
func (s *state) deleteChannel(name string) {
name = ToRFC1459(name)
_, ok := s.channels[name]
chn, ok := s.channels.Get(name)
if !ok {
return
}
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)
for listed := range chn.UserList.IterBuffered() {
usr, uok := s.users.Get(listed.Key)
if uok {
usr.deleteChannel(name)
}
}
delete(s.channels, name)
s.channels.Remove(name)
}
// lookupChannel returns a reference to a channel, nil returned if no results
// found.
func (s *state) lookupChannel(name string) *Channel {
return s.channels[ToRFC1459(name)]
ci, cok := s.channels.Get(ToRFC1459(name))
if ci == nil || !cok {
return nil
}
return ci
}
// lookupUser returns a reference to a user, nil returned if no results
// found.
func (s *state) lookupUser(name string) *User {
return s.users[ToRFC1459(name)]
usr, uok := s.users.Get(ToRFC1459(name))
if usr == nil || !uok {
return nil
}
return usr
}
// 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 {
func (s *state) createUser(src *Source) (u *User, ok bool) {
if u, ok = s.users.Get(src.ID()); ok {
// User already exists.
return false
return u, false
}
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)},
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)
}
return true
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
}
// 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 == "" {
for i := 0; i < len(user.ChannelList); i++ {
s.channels[user.ChannelList[i]].deleteUser(nick)
}
delete(s.users, ToRFC1459(nick))
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
return
}
@ -467,12 +513,8 @@ func (s *state) deleteUser(channelName, nick string) {
user.deleteChannel(channelName)
channel.deleteUser(nick)
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))
if user.ChannelList.Count() == 0 {
user.Stale = true
}
}
@ -481,29 +523,32 @@ func (s *state) renameUser(from, to string) {
from = ToRFC1459(from)
// Update our nickname.
if from == ToRFC1459(s.nick) {
s.nick = to
if from == ToRFC1459(s.nick.Load().(string)) {
s.nick.Store(to)
}
user := s.lookupUser(from)
if user == nil {
old, oldok := s.users.Pop(from)
if !oldok && user == nil {
return
}
delete(s.users, from)
if old != nil && user == nil {
user = old
}
user.Nick = to
user.Nick.Store(to)
user.LastActive = time.Now()
s.users[ToRFC1459(to)] = user
s.users.Set(ToRFC1459(to), user)
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
}
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)
}
}
}

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

29
value.go Normal file

@ -0,0 +1,29 @@
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}
}

4
version.go Normal file

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