Repository: insomniacslk/irc-slack
Branch: master
Commit: c8acefd1f7e4
Files: 30
Total size: 116.8 KB
Directory structure:
gitextract_xhv39y9c/
├── .dockerignore
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── docker.yml
│ └── tests.yml
├── .gitignore
├── .stickler.yml
├── Dockerfile
├── Dockerfile.autotoken
├── LICENSE
├── README.md
├── cmd/
│ └── irc-slack/
│ ├── Makefile
│ └── main.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── pkg/
│ └── ircslack/
│ ├── channel.go
│ ├── channels.go
│ ├── channels_test.go
│ ├── event_handler.go
│ ├── file_handler.go
│ ├── irc_context.go
│ ├── irc_server.go
│ ├── logger.go
│ ├── server.go
│ ├── users.go
│ ├── users_test.go
│ ├── wordwrap.go
│ └── wordwrap_test.go
└── tools/
├── autotoken/
│ └── main.go
└── slackapp/
└── main.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
Dockerfile
.travis
.dockerignore
coverage.txt
================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
================================================
FILE: .github/workflows/docker.yml
================================================
name: Docker push
on:
push:
branches:
- master
jobs:
docker_publish:
runs-on: 1.25
env:
DOCKER_USER: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASS: ${{ secrets.DOCKERHUB_PASSWORD }}
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Login to Docker hub
run: docker login -u $DOCKER_USER -p $DOCKER_PASS
- name: Build image
run: |
pwd
ls -l
docker build -t ${{ github.repository }} -f Dockerfile .
- name: Publish image
run: |
docker tag ${{ github.repository }} ${{ github.repository }}:latest
docker push ${{ github.repository }}
================================================
FILE: .github/workflows/tests.yml
================================================
name: Tests
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go: ['1.25']
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 2
# clone in the gopath
path: src/github.com/${{ github.repository }}
- uses: actions/setup-go@v2
with:
stable: false
go-version: ${{ matrix.go }}
- run: |
echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV
- run: |
cd $GITHUB_WORKSPACE/src/github.com/${{ github.repository }}/cmd/irc-slack
make
./irc-slack --version
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: ['1.25']
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 2
# clone in the gopath
path: src/github.com/${{ github.repository }}
- uses: actions/setup-go@v2
with:
stable: false
go-version: ${{ matrix.go }}
- run: |
echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV
- run: |
cd $GITHUB_WORKSPACE/src/github.com/${{ github.repository }}
go get -v -t ./...
echo "" > coverage.txt
for d in $(go list ./...); do
go test -v -race -coverprofile=profile.out -covermode=atomic "${d}"
if [ -f profile.out ]; then
cat profile.out >> coverage.txt
rm profile.out
fi
done
bash <(curl -s https://codecov.io/bash) -c -f coverage.txt -F unittest
================================================
FILE: .gitignore
================================================
coverage.txt
cmd/irc-slack/irc-slack
================================================
FILE: .stickler.yml
================================================
linters:
golint:
fixer: true
fixers:
enable: true
================================================
FILE: Dockerfile
================================================
############################
# STEP 1 build executable binary
############################
FROM golang:1.23-alpine AS builder
LABEL BUILD="docker build -t insomniacslk/irc-slack -f Dockerfile ."
LABEL RUN="docker run --rm -p 6666:6666 -it insomniacslk/irc-slack"
# Install git.
# Git is required for fetching the dependencies.
RUN apk update && apk add --no-cache git bash make
COPY . $GOPATH/src/github.com/insomniacslk/irc-slack
ENV GO111MODULE=on
WORKDIR $GOPATH/src/github.com/insomniacslk/irc-slack/cmd/irc-slack
# Build the binary.
RUN make
RUN cp irc-slack /go/bin
############################
# STEP 2 build a small image
############################
FROM scratch
# Copy the ssl certs so we can talk to slack
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# Copy our static executable.
COPY --from=builder /go/bin/irc-slack /go/bin/irc-slack
ENV PATH="/go/bin:$PATH"
# Run the irc-slack binary.
CMD ["/go/bin/irc-slack", "-H", "0.0.0.0"]
================================================
FILE: Dockerfile.autotoken
================================================
############################
# STEP 1 build executable binary
############################
FROM golang:1.16-alpine AS builder
LABEL BUILD="docker build -t insomniacslk/irc-slack/tools-autotoken -f Dockerfile.autotoken ."
LABEL RUN="docker run --rm -it insomniacslk/irc-slack/tools-autotoken"
# Install git.
# Git is required for fetching the dependencies.
RUN apk update && apk add --no-cache --purge git bash chromium
COPY . $GOPATH/src/github.com/insomniacslk/irc-slack/
ENV GO111MODULE=on
WORKDIR $GOPATH/src/github.com/insomniacslk/irc-slack/tools/autotoken
# Build the binary.
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /go/bin/autotoken
ENV PATH="/go/bin:$PATH"
WORKDIR /tmp
USER guest
# Run the autotoken binary.
CMD ["/go/bin/autotoken", "-h"]
================================================
FILE: LICENSE
================================================
BSD 3-Clause License
Copyright (c) 2018, Andrea Barberio
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: README.md
================================================
# IRC-to-Slack gateway
`irc-slack` is an IRC-to-Slack gateway. It is an IRC server that lets you
connect to your Slack teams with your IRC client.
[](https://xkcd.com/1782/)
(That guy is me)
Slack has ended support for IRC and XMPP gateway on the 15th of May 2018. So
what's left to do for people like me, who want to still be able to log in via
IRC? Either you use [wee-slack](https://github.com/wee-slack/wee-slack) (~~but I
don't use WeeChat~~), or you implement your own stuff.
NOTE: after Slack turned down their IRC gateway I got a lot of contacts from users of irc-slack asking me to fix and improve it. I didn't expect people to actually use it, but thanks to your feedback I'm now actively developing it again :-)
Please keep reporting bugs and sending PRs!
## How to use it
```
cd cmd/irc-slack
make # use `make` instead of `go build` to include build information when running with `-v`
./irc-slack # by default on port 6666
```
Then configure your IRC client to connect to localhost:6666 and use one of the methods in the Tokens section to set the connection password.
You can also [run it with Docker](#run-it-with-docker).
## Feature matrix
| | public channel | private channel | multiparty IM | IM |
| --- | --- | --- | --- | --- |
| from me | works | works | doesn't work ([#168](https://github.com/insomniacslk/irc-slack/issues/168)) | works |
| to me | works | works | works | works |
| thread from me | doesn't work ([#168](https://github.com/insomniacslk/irc-slack/issues/168)) | doesn't work ([#168](https://github.com/insomniacslk/irc-slack/issues/168)) | untested | doesn't work ([#166](https://github.com/insomniacslk/irc-slack/issues/166)) |
| thread to me | works | works | untested | works but sends in the IM chat ([#167](https://github.com/insomniacslk/irc-slack/issues/167)) |
## Encryption
`irc-slack` by default does not use encryption when communicating with your IRC
client (but the communication between `irc-slack` and the Slack servers is
encrypted).
If you want to use TLS, you can use the `-key` and `-cert` command line
parameters, and point them to a TLS certificate that you own.
This is useful if you plan to connect to to `irc-slack` over the internet.
For example, you can generate a valid certificate with LetsEncrypt (adjust the relevant
fields of course):
```
sudo certbot certonly \
-n \
-d your.domain.example.com \
--test-cert \
--standalone \
-m your@email.example.com \
--agree-tos
```
Then your key and certificate will be generated under
`/etc/letsencrypt/live/your.domain.example.com`
with the names `privkey.pem` and `cert.pem` respectively.
## Authentication
To connect to Slack via `irc-slack` you need an authentication string. There are
three possible methods:
* User tokens with auth cookies (recommended)
* Slack app tokens (if you can install apps on your slack team)
* legacy tokens (soon to be deprecated)
These options are discussed in more detail below.
Then just add `-key <path/to/privkey.pem> -cert <path/to/cert.pem>` to enable
TLS on `irc-slack`, and enable TLS on your IRC client.
### User tokens with auth cookie
This approach does not require legacy tokens nor installing any app, but in order to
get the token there are a few manual steps to execute.
This type of token starts with `xoxc-`, and requires an auth cookie to be paired
to it in order to work.
There are two possible procedures, an entirely manual one, using the browser
console, and a semi-automated one, which requires Chrome or Chromium in headless
mode.
**manual procedure via browser**
This is the same procedure as described in two similar projects, see:
* https://github.com/adsr/irslackd/wiki/IRC-Client-Config#xoxc-tokens
* https://github.com/ltworf/localslackirc/#obtain-a-token
But in short, log via browser on the Slack team, open the browser's network tab
in the developer tools, and look for an XHR transaction. Then look for
* the token (it starts with `xoxc-`) in the request data
* the auth cookie, contained in the `d` key-value in the request cookies (it looks like `d=XXXX;`)
Then concatenate the token and the auth cookie using a `|` character, like this:
```
xoxc-XXXX|d=XXXX;
```
and use the above as your IRC password.
**semi-automated procedure using Chrome/Chromium in headless mode**
See [autotoken](tools/autotoken). Just build it with `go build` and run with
`./autotoken -h` to see the usage help.
If you prefer to run `autotoken` via Docker, you can test your luck with:
```
docker build -t insomniacslk/irc-slack/tools-autotoken -f Dockerfile.autotoken .
docker run --rm -it -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix insomniacslk/irc-slack/tools-autotoken autotoken -h
```
### Slack App tokens
As an alternative, you can install the irc-slack app on your workspace, and use the token that it returns after you authorize it.
In order to run the application, you need to do the following steps:
* create a Slack app using their v1 OauthV2 API (note: not their v2 version) at https://api.slack.com/apps
* configure the redirect URL to your endpoint (in this case
https://my-server/irc-slack/auth/)
* run the web app under [slackapp](tools/slackapp/) passing your app client ID and client secret, you can find them in the Basic Information tab at the link at the previous step
The token starts with `xoxp-`, and you can use it as your IRC password when
connecting to `irc-slack`.
This is a Slack app with full user permissions, that is used to generate a Slack user token.
Note that you need to install this app on every workspace you want to use it
for, and the workspace owners may reject it.
This app exchanges your temporary authentication code with a permanent token.
### Legacy tokens
This is the easiest method, but it's deprecated and Slack will soon disable it.
Slack has announced that they will stop issuing legacy tokens starting the 4th
of May 2020, so this section will stay here for historical reasons.
Get you Slack legacy token at https://api.slack.com/custom-integrations/legacy-tokens ,
and set it as your IRC password when connecting to `irc-slack`.
## Run it with Docker
Thanks to [halkeye](https://github.com/halkeye) you can run `irc-slack` via
Docker. The `Dockerfile` is published on
https://hub.docker.com/r/insomniacslk/irc-slack and will by default listen on
`0.0.0.0:6666`. You can pull and run it with:
```
docker run --rm -p 6666:6666 insomniacslk/irc-slack
```
If you want to build it locally, just run:
```
docker build -f Dockerfile . -t insomniacslk/irc-slack
```
### Connecting with irssi
```
/network add yourteam.slack.com
/server add -auto -network yourteam.slack.com localhost 6666 xoxp-<your-slack-token>
/connect yourteam.slack.com
```
Remember to add `-tls` to the `/connect` command if you're running `irc-slack`
with TLS.
Also remember to replace `localhost` with the name of the host you're connecting to,
if different.
### Connecting with WeeChat
```
/server add yourteam.slack.com localhost/6666
/set irc.server.yourteam.slack.com.password xoxp-<your-slack-token>
/connect yourteam.slack.com
```
To enable TLS, also run the following before the `/connect` command:
```
/set irc.server.yourteam.slack.com.ssl on
/set irc.server.yourteam.slack.com.ssl_verify on
```
Also remember to replace `localhost` with the name of the host you're connecting to,
if different.
## Gateway usage
There are a few options that you can pass to the server, e.g. to change the listener port, or the server name:
```
$ ./irc-slack -h
Usage of ./irc-slack:
-c, --cert string TLS certificate for HTTPS server. Requires -key
-C, --chunk int Maximum size of a line to send to the client. Only works for certain reply types (default 512)
-D, --debug Enable debug logging of the Slack API
-d, --download string If set will download attachments to this location
-l, --fileprefix string If set will overwrite urls to attachments with this prefix and local file name inside the path set with -d
-H, --host string IP address to listen on (default "127.0.0.1")
-k, --key string TLS key for HTTPS server. Requires -cert
-L, --loglevel string Log level. One of [none debug info warning error fatal] (default "info")
-P, --pagination int Pagination value for API calls. If 0 or unspecified, use the recommended default (currently 200). Larger values can help on large Slack teams
-p, --port int Local port to listen on (default 6666)
-s, --server string IRC server name (i.e. the host name to send to clients)
pflag: help requested
exit status 2
```
## Deploying with Puppet
You can use the [irc-slack module for Puppet](https://github.com/b4ldr/puppet-irc_slack) by [John Bond](https://github.com/b4ldr).
## TODO
A lot of things. Want to help? Grep "TODO", "FIXME" and "XXX" in the code and send me a PR :)
This currently "works for me", but I published it in the hope that someone would use it so we can find and fix bugs.
## BUGS
Plenty of them. I wrote this project while on a plane (like many other projects of mine) so this is hack-level quality - no proper design, no RFC compliance, no testing. I just fired up an IRC client until I could reasonably chat on a few Slack teams. Please report all the bugs you find on the Github issue tracker, or privately to me.
## Authors
* [Andrea Barberio](https://insomniac.slackware.it)
* [Josip Janzic](https://github.com/janza)
## Thanks
Special thanks to
* Stefan Stasik for helping me find, fix and troubleshoot a zillion of bugs :)
* [Mauro Codella](https://github.com/codella) for patiently reading and replying for two hours in a private conversation that I used to test the fix at [pull/23](https://github.com/insomniacslk/irc-slack/pull/23) :D
================================================
FILE: cmd/irc-slack/Makefile
================================================
CMD=irc-slack
REVISION := $(shell git rev-parse --short HEAD)
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
all: build
build: $(wildcard *.go)
CGO_ENABLED=0 go build -ldflags "-X main.Version=git-$(REVISION)_$(BRANCH)" -o $(CMD)
================================================
FILE: cmd/irc-slack/main.go
================================================
package main
import (
"crypto/tls"
"fmt"
"io/ioutil"
"net"
"os"
"github.com/insomniacslk/irc-slack/pkg/ircslack"
"github.com/coredhcp/coredhcp/logger"
"github.com/sirupsen/logrus"
flag "github.com/spf13/pflag"
)
// Version information. Will be populated with the git revision and branch
// information when running `make`.
var (
ProgramName = "irc-slack"
Version string = "unknown (please build with `make`)"
)
// To authenticate, the IRC client has to send a PASS command with a Slack
// legacy token for the desired team. See README.md for details.
var (
port = flag.IntP("port", "p", 6666, "Local port to listen on")
host = flag.StringP("host", "H", "127.0.0.1", "IP address to listen on")
serverName = flag.StringP("server", "s", "", "IRC server name (i.e. the host name to send to clients)")
chunkSize = flag.IntP("chunk", "C", 512, "Maximum size of a line to send to the client. Only works for certain reply types")
fileDownloadLocation = flag.StringP("download", "d", "", "If set will download attachments to this location")
fileProxyPrefix = flag.StringP("fileprefix", "l", "", "If set will overwrite urls to attachments with this prefix and local file name inside the path set with -d")
logLevel = flag.StringP("loglevel", "L", "info", fmt.Sprintf("Log level. One of %v", getLogLevels()))
flagSlackDebug = flag.BoolP("debug", "D", false, "Enable debug logging of the Slack API")
flagPagination = flag.IntP("pagination", "P", 0, "Pagination value for API calls. If 0 or unspecified, use the recommended default (currently 200). Larger values can help on large Slack teams")
flagKey = flag.StringP("key", "k", "", "TLS key for HTTPS server. Requires -cert")
flagCert = flag.StringP("cert", "c", "", "TLS certificate for HTTPS server. Requires -key")
flagVersion = flag.BoolP("version", "v", false, "Print version and exit")
)
var log = logger.GetLogger("main")
var logLevels = map[string]func(*logrus.Logger){
"none": func(l *logrus.Logger) { l.SetOutput(ioutil.Discard) },
"debug": func(l *logrus.Logger) { l.SetLevel(logrus.DebugLevel) },
"info": func(l *logrus.Logger) { l.SetLevel(logrus.InfoLevel) },
"warning": func(l *logrus.Logger) { l.SetLevel(logrus.WarnLevel) },
"error": func(l *logrus.Logger) { l.SetLevel(logrus.ErrorLevel) },
"fatal": func(l *logrus.Logger) { l.SetLevel(logrus.FatalLevel) },
}
func getLogLevels() []string {
var levels []string
for k := range logLevels {
levels = append(levels, k)
}
return levels
}
func main() {
flag.CommandLine.SortFlags = false
flag.Parse()
if *flagVersion {
fmt.Printf("%s version %s\n", ProgramName, Version)
os.Exit(0)
}
fn, ok := logLevels[*logLevel]
if !ok {
log.Fatalf("Invalid log level '%s'. Valid log levels are %v", *logLevel, getLogLevels())
}
fn(log.Logger)
log.Infof("Setting log level to '%s'", *logLevel)
var sName string
if *serverName == "" {
sName = "localhost"
} else {
sName = *serverName
}
localAddr := net.TCPAddr{Port: *port}
ip := net.ParseIP(*host)
if ip == nil {
log.Fatalf("Invalid IP address to listen on: '%s'", *host)
}
localAddr.IP = ip
log.Printf("Starting server on %v", localAddr.String())
if *fileDownloadLocation != "" {
dInfo, err := os.Stat(*fileDownloadLocation)
if err != nil || !dInfo.IsDir() {
log.Fatalf("Missing or invalid download directory: %s", *fileDownloadLocation)
}
}
doTLS := false
if *flagKey != "" && *flagCert != "" {
doTLS = true
}
var tlsConfig *tls.Config
if doTLS {
if *flagKey == "" || *flagCert == "" {
log.Fatalf("-key and -cert must be specified together")
}
cert, err := tls.LoadX509KeyPair(*flagCert, *flagKey)
if err != nil {
log.Fatalf("Failed to load TLS key/cert: %v", err)
}
tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
}
server := ircslack.Server{
LocalAddr: &localAddr,
Name: sName,
ChunkSize: *chunkSize,
FileDownloadLocation: *fileDownloadLocation,
FileProxyPrefix: *fileProxyPrefix,
SlackDebug: *flagSlackDebug,
Pagination: *flagPagination,
TLSConfig: tlsConfig,
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
}
================================================
FILE: docker-compose.yml
================================================
services:
irc-slack:
build:
context: .
dockerfile: Dockerfile
ports:
- 6666:6666
================================================
FILE: go.mod
================================================
module github.com/insomniacslk/irc-slack
go 1.25
require (
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/coredhcp/coredhcp v0.0.0-20250806070228-f7e98e4e350b
github.com/sirupsen/logrus v1.9.4
github.com/slack-go/slack v0.19.0
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
)
require (
github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb h1:aZTKxMminKeQWHtzJBbV8TttfTxzdJ+7iEJFE6FmUzg=
github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb/go.mod h1:xzXc1S/L+64uglB3pw54o8kqyM6KFYpTeC9Q6+qZIu8=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/coredhcp/coredhcp v0.0.0-20250806070228-f7e98e4e350b h1:v+UeSM6ffD0hXGpaggA86L7rzysxJSProX2YZKWufvA=
github.com/coredhcp/coredhcp v0.0.0-20250806070228-f7e98e4e350b/go.mod h1:A2iJXPupXJVeJZFSrP1Vx/tK6cZ7oci0V/8egMjp2LI=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 h1:02WINGfSX5w0Mn+F28UyRoSt9uvMhKguwWMlOAh6U/0=
github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 h1:mZHayPoR0lNmnHyvtYjDeq0zlVHn9K/ZXoy17ylucdo=
github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5HgEKCvEIIrSpFI3ozzG5xOKA2DVlEX/gGnewM=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44=
github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: pkg/ircslack/channel.go
================================================
package ircslack
import (
"fmt"
"strings"
"time"
"github.com/slack-go/slack"
)
// Constants for public, private, and multi-party conversation prefixes.
// Channel threads are prefixed with "+" but they are not conversation types
// so they do not belong here. A thread is just a message whose destination
// is within another message in a public, private, or multi-party conversation.
const (
ChannelPrefixPublicChannel = "#"
ChannelPrefixPrivateChannel = "@"
ChannelPrefixMpIM = "&"
// NOTE: a thread is not a channel type
ChannelPrefixThread = "+"
)
// HasChannelPrefix returns true if the channel name starts with one of the
// supproted channel prefixes.
func HasChannelPrefix(name string) bool {
if len(name) == 0 {
return false
}
switch string(name[0]) {
case ChannelPrefixPublicChannel, ChannelPrefixPrivateChannel, ChannelPrefixMpIM, ChannelPrefixThread:
return true
default:
return false
}
}
// StripChannelPrefix returns a channel name without its channel prefix. If no
// channel prefix is present, the string is returned unchanged.
func StripChannelPrefix(name string) string {
if HasChannelPrefix(name) {
return name[1:]
}
return name
}
// ChannelMembers returns a list of users in the given conversation.
func ChannelMembers(ctx *IrcContext, channelID string) ([]slack.User, error) {
var (
members, m []string
nextCursor string
err error
page int
)
for {
attempt := 0
for {
// retry if rate-limited, no more than MaxSlackAPIAttempts times
if attempt >= MaxSlackAPIAttempts {
return nil, fmt.Errorf("ChannelMembers: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts)
}
log.Debugf("ChannelMembers: page %d attempt #%d nextCursor=%s", page, attempt, nextCursor)
m, nextCursor, err = ctx.SlackClient.GetUsersInConversation(&slack.GetUsersInConversationParameters{ChannelID: channelID, Cursor: nextCursor, Limit: 1000})
if err != nil {
log.Errorf("Failed to get users in conversation '%s': %v", channelID, err)
if rlErr, ok := err.(*slack.RateLimitedError); ok {
// we were rate-limited. Let's wait as much as Slack
// instructs us to do
log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter)
time.Sleep(rlErr.RetryAfter)
attempt++
continue
}
return nil, fmt.Errorf("Cannot get member list for conversation %s: %v", channelID, err)
}
break
}
members = append(members, m...)
log.Debugf("Fetched %d user IDs for channel %s (fetched so far: %d)", len(m), channelID, len(members))
// TODO call ctx.Users.FetchByID here in a goroutine to see if this
// speeds up
if nextCursor == "" {
break
}
page++
}
log.Debugf("Retrieving user information for %d users", len(members))
users, err := ctx.Users.FetchByIDs(ctx.SlackClient, false, members...)
if err != nil {
return nil, fmt.Errorf("Failed to fetch users by their IDs: %v", err)
}
return users, nil
}
// Channel wraps a Slack conversation with a few utility functions.
type Channel slack.Channel
// IsPublicChannel returns true if the channel is public.
func (c *Channel) IsPublicChannel() bool {
return c.IsChannel && !c.IsPrivate
}
// IsPrivateChannel returns true if the channel is private.
func (c *Channel) IsPrivateChannel() bool {
return c.IsGroup && c.IsPrivate
}
// IsMP returns true if it is a multi-party conversation.
func (c *Channel) IsMP() bool {
return c.IsMpIM
}
// IRCName returns the channel name as it would appear on IRC.
// Examples:
// * #channel for public groups
// * @channel for private groups
// * &Gxxxx|nick1-nick2-nick3 for multi-party IMs
func (c *Channel) IRCName() string {
switch {
case c.IsPublicChannel():
return ChannelPrefixPublicChannel + c.Name
case c.IsPrivateChannel():
return ChannelPrefixPrivateChannel + c.Name
case c.IsMP():
name := ChannelPrefixMpIM + c.ID + "|" + c.Name
name = strings.Replace(name, "mpdm-", "", -1)
name = strings.Replace(name, "--", "-", -1)
if len(name) >= 30 {
return name[:29] + "…"
}
return name
default:
log.Warningf("Unknown channel type for channel %+v", c)
return "<unknow-channel-type>"
}
}
// SlackName returns the slack.Channel.Name field.
func (c *Channel) SlackName() string {
return c.Name
}
================================================
FILE: pkg/ircslack/channels.go
================================================
package ircslack
import (
"context"
"fmt"
"sync"
"time"
"github.com/slack-go/slack"
)
// Channels wraps the channel list with convenient operations and cache.
type Channels struct {
channels map[string]Channel
Pagination int
mu sync.Mutex
}
// NewChannels creates a new Channels object.
func NewChannels(pagination int) *Channels {
return &Channels{
channels: make(map[string]Channel),
Pagination: pagination,
}
}
// SupportedChannelPrefixes returns a list of supported channel prefixes.
func SupportedChannelPrefixes() []string {
return []string{
ChannelPrefixPublicChannel,
ChannelPrefixPrivateChannel,
ChannelPrefixMpIM,
ChannelPrefixThread,
}
}
// AsMap returns the channels as a map of name -> channel. The map is copied to
// avoid data races
func (c *Channels) AsMap() map[string]Channel {
c.mu.Lock()
defer c.mu.Unlock()
ret := make(map[string]Channel, len(c.channels))
for k, v := range c.channels {
ret[k] = v
}
return ret
}
// FetchByIDs fetches the channels with the specified IDs and updates the
// internal channel mapping.
func (c *Channels) FetchByIDs(client *slack.Client, skipCache bool, channelIDs ...string) ([]Channel, error) {
var (
toRetrieve []string
alreadyRetrieved []Channel
)
if !skipCache {
c.mu.Lock()
for _, cid := range channelIDs {
if ch, ok := c.channels[cid]; !ok {
toRetrieve = append(toRetrieve, cid)
} else {
alreadyRetrieved = append(alreadyRetrieved, ch)
}
}
c.mu.Unlock()
log.Debugf("Fetching information for %d channels out of %d (%d already in cache)", len(toRetrieve), len(channelIDs), len(channelIDs)-len(toRetrieve))
} else {
toRetrieve = channelIDs
}
allFetchedChannels := make([]Channel, 0, len(channelIDs))
for i := 0; i < len(toRetrieve); i++ {
for {
attempt := 0
if attempt >= MaxSlackAPIAttempts {
return nil, fmt.Errorf("Channels.FetchByIDs: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts)
}
log.Debugf("Fetching %d channels of %d, attempt %d of %d", len(toRetrieve), len(channelIDs), attempt+1, MaxSlackAPIAttempts)
slackChannel, err := client.GetConversationInfo(&slack.GetConversationInfoInput{ChannelID: toRetrieve[i], IncludeLocale: true, IncludeNumMembers: true})
if err != nil {
if rlErr, ok := err.(*slack.RateLimitedError); ok {
// we were rate-limited. Let's wait the recommended delay
log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter)
time.Sleep(rlErr.RetryAfter)
attempt++
continue
}
return nil, err
}
ch := Channel(*slackChannel)
allFetchedChannels = append(allFetchedChannels, ch)
// also update the local users map
c.mu.Lock()
c.channels[ch.ID] = ch
c.mu.Unlock()
break
}
}
allChannels := append(alreadyRetrieved, allFetchedChannels...)
if len(channelIDs) != len(allChannels) {
return allFetchedChannels, fmt.Errorf("Found %d users but %d were requested", len(allChannels), len(channelIDs))
}
return allChannels, nil
}
// Fetch retrieves all the channels on a given Slack team. The Slack client has
// to be valid and connected.
func (c *Channels) Fetch(client *slack.Client) error {
log.Infof("Fetching all channels, might take a while on large Slack teams")
// currently slack-go does not expose a way to change channel pagination as
// it does for the users API.
var (
err error
ctx = context.Background()
channels = make(map[string]Channel)
)
start := time.Now()
params := slack.GetConversationsParameters{
Types: []string{"public_channel", "private_channel"},
Limit: c.Pagination,
}
for err == nil {
chans, nextCursor, err := client.GetConversationsContext(ctx, ¶ms)
if err == nil {
log.Debugf("Retrieved %d channels (current total is %d)", len(chans), len(channels))
for _, sch := range chans {
// WARNING WARNING WARNING: channels are internally mapped by
// the Slack name, while users are mapped by Slack ID.
ch := Channel(sch)
channels[ch.SlackName()] = ch
}
} else if rateLimitedError, ok := err.(*slack.RateLimitedError); ok {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(rateLimitedError.RetryAfter):
err = nil
}
}
if nextCursor == "" {
break
}
params.Cursor = nextCursor
}
log.Infof("Retrieved %d channels in %s", len(channels), time.Since(start))
c.mu.Lock()
c.channels = channels
for name, ch := range channels {
log.Debugf("Retrieved channel: %s -> %+v", name, ch)
}
c.mu.Unlock()
return nil
}
// Count returns the number of channels. This method must be called after
// `Fetch`.
func (c *Channels) Count() int {
return len(c.channels)
}
// ByID retrieves a channel by its Slack ID.
func (c *Channels) ByID(id string) *Channel {
c.mu.Lock()
defer c.mu.Unlock()
for _, c := range c.channels {
if c.ID == id {
return &c
}
}
return nil
}
// ByName retrieves a channel by its Slack or IRC name.
func (c *Channels) ByName(name string) *Channel {
if HasChannelPrefix(name) {
// without prefix, the channel now has the form of a Slack name
name = name[1:]
}
c.mu.Lock()
defer c.mu.Unlock()
if ch, ok := c.channels[name]; ok {
return &ch
}
return nil
}
================================================
FILE: pkg/ircslack/channels_test.go
================================================
package ircslack
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"testing"
"github.com/slack-go/slack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelsNewChannels(t *testing.T) {
u := NewChannels(100)
require.NotNil(t, u)
assert.NotNil(t, u.channels)
}
type fakeErrorChannelsPaginationComplete struct{}
type fakeChannelsResponse struct {
Members []slack.Channel
Channel slack.Channel
}
func (f fakeErrorChannelsPaginationComplete) Error() string {
return "pagination complete"
}
type fakeSlackHTTPClientChannels struct{}
func (c fakeSlackHTTPClientChannels) Do(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/api/conversations.list":
// reply as per https://api.slack.com/methods/channels.list
data := []byte(`{"channels": [{"id": "1234", "name": "general", "is_channel": true}], "response_metadata": {"next_cursor": ""}}`)
return &http.Response{
Status: "200 OK",
StatusCode: 200,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Body: ioutil.NopCloser(bytes.NewBuffer(data)),
}, nil
default:
return nil, fmt.Errorf("testing: http client URL not supported: %s", req.URL)
}
}
func TestChannelsFetch(t *testing.T) {
client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClientChannels{}))
channels := NewChannels(100)
err := channels.Fetch(client)
require.NoError(t, err)
assert.Equal(t, 1, channels.Count())
}
func TestChannelsById(t *testing.T) {
client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClientChannels{}))
channels := NewChannels(100)
err := channels.Fetch(client)
require.NoError(t, err)
u := channels.ByID("1234")
require.NotNil(t, u)
assert.Equal(t, "1234", u.ID)
assert.Equal(t, "general", u.Name)
}
func TestChannelsByName(t *testing.T) {
client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClientChannels{}))
channels := NewChannels(100)
err := channels.Fetch(client)
require.NoError(t, err)
u := channels.ByName("general")
require.NotNil(t, u)
assert.Equal(t, "1234", u.ID)
assert.Equal(t, "general", u.Name)
}
================================================
FILE: pkg/ircslack/event_handler.go
================================================
package ircslack
import (
"fmt"
"math"
"strings"
"github.com/slack-go/slack"
)
func joinText(first string, second string, separator string) string {
if first == "" {
return second
}
if second == "" {
return first
}
return first + separator + second
}
func formatThreadChannelName(threadTimestamp string, channel *Channel) string {
return ChannelPrefixThread + channel.Name + "-" + threadTimestamp
}
func resolveChannelName(ctx *IrcContext, msgChannel, threadTimestamp string) string {
if strings.HasPrefix(msgChannel, "C") || strings.HasPrefix(msgChannel, "G") {
// Channel message
channel := ctx.Channels.ByID(msgChannel)
if channel == nil {
// try fetching it, in case it's a new channel
channels, err := ctx.Channels.FetchByIDs(ctx.SlackClient, false, msgChannel)
if err != nil || len(channels) == 0 {
ctx.SendUnknownError("Failed to fetch channel with ID `%s`: %v", msgChannel, err)
return ""
}
channel = &channels[0]
}
if channel == nil {
ctx.SendUnknownError("Unknown channel ID `%s` when resolving channel name", msgChannel)
return ""
} else if threadTimestamp != "" {
channame := formatThreadChannelName(threadTimestamp, channel)
openingText, err := ctx.GetThreadOpener(msgChannel, threadTimestamp)
if err != nil {
ctx.SendUnknownError("Failed to get thread opener for `%s`: %v", msgChannel, err)
return ""
}
IrcSendChanInfoAfterJoinCustom(
ctx,
channame,
msgChannel,
openingText.Text,
[]slack.User{},
)
privmsg := fmt.Sprintf(":%v!%v@%v PRIVMSG %v :%s%s%s\r\n",
channame, openingText.User, ctx.ServerName,
channame, "", openingText.Text, "",
)
if _, err := ctx.Conn.Write([]byte(privmsg)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return channame
} else if channel.IsMpIM {
if ctx.Channels.ByName(channel.IRCName()) == nil {
members, err := ChannelMembers(ctx, channel.ID)
if err != nil {
log.Warningf("Failed to fetch channel members for `%s`: %v", channel.Name, err)
} else {
IrcSendChanInfoAfterJoin(ctx, channel, members)
}
}
return channel.IRCName()
}
return channel.IRCName()
} else if strings.HasPrefix(msgChannel, "D") {
// Direct message to me
channel := ctx.Channels.ByID(msgChannel)
if channel == nil {
// not found locally, try to get it via Slack API
channels, err := ctx.Channels.FetchByIDs(ctx.SlackClient, false, msgChannel)
if err != nil || len(channels) == 0 {
ctx.SendUnknownError("Failed to fetch IM chat with ID `%s`: %v", msgChannel, err)
return ""
}
channel = &channels[0]
}
members, err := ChannelMembers(ctx, channel.ID)
if err != nil {
ctx.SendUnknownError("Failed to fetch channel members for `%s`: %v", channel.Name, err)
return ""
}
// we expect only two members in a direct message. Raise an
// error if not.
if len(members) == 0 || len(members) > 2 {
ctx.SendUnknownError("Want 1 or 2 users in conversation, got %d (conversation ID: %s)", len(members), msgChannel)
return ""
}
// of the two users, one is me. Otherwise fail
if ctx.UserID() == "" {
ctx.SendUnknownError("Cannot get my own user ID")
return ""
}
user1 := members[0]
var user2 slack.User
if len(members) == 2 {
user2 = members[1]
} else {
// len is 1. Sending a message to myself
user2 = user1
}
if user1.ID != ctx.UserID() && user2.ID != ctx.UserID() {
ctx.SendUnknownError("Got a direct message where I am not part of the members list (conversation: %s)", msgChannel)
return ""
}
var recipientID string
if user1.ID == ctx.UserID() {
// then it's the other user
recipientID = user2.ID
} else {
recipientID = user1.ID
}
// now resolve the ID to the user's nickname
nickname := ctx.GetUserInfo(recipientID)
if nickname == nil {
// ERR_UNKNOWNERROR
ctx.SendUnknownError("Unknown destination user ID %s for direct message %s", recipientID, msgChannel)
return ""
}
return nickname.Name
}
log.Warningf("Unknown recipient ID: %s", msgChannel)
return ""
}
func appendIfNotMoreThan(slice []slack.Msg, msg slack.Msg) []slack.Msg {
if len(slice) == 100 {
return append(slice[1:], msg)
}
return append(slice, msg)
}
func getConversationDetails(
ctx *IrcContext,
channelID string,
timestamp string,
) (slack.Message, error) {
message, err := ctx.SlackClient.GetConversationHistory(&slack.GetConversationHistoryParameters{
ChannelID: channelID,
Latest: timestamp,
Limit: 1,
Inclusive: true,
})
if err != nil {
return slack.Message{}, err
}
if len(message.Messages) > 0 {
return message.Messages[0], nil
}
return slack.Message{}, fmt.Errorf("No such message found")
}
func replacePermalinkWithText(ctx *IrcContext, text string) string {
matches := rxSlackArchiveURL.FindStringSubmatch(text)
if len(matches) != 4 {
return text
}
channel := matches[1]
timestamp := matches[2] + "." + matches[3]
message, err := getConversationDetails(ctx, channel, timestamp)
if err != nil {
log.Printf("could not get message details from permalink %s %s %s %v", matches[0], channel, timestamp, err)
return text
}
return text + "\n> " + message.Text
}
func printMessage(ctx *IrcContext, message slack.Msg, prefix string) {
user := ctx.GetUserInfo(message.User)
name := ""
if user == nil {
if message.User != "" {
log.Warningf("Failed to get user info for %v %s", message.User, message.Username)
name = message.User
} else {
name = strings.ReplaceAll(message.Username, " ", "_")
}
} else {
name = user.Name
}
// get channel or other recipient (e.g. recipient of a direct message)
channame := resolveChannelName(ctx, message.Channel, message.ThreadTimestamp)
text := message.Text
for _, attachment := range message.Attachments {
text = joinText(text, attachment.Pretext, "\n")
text = joinText(text, attachment.Title, "\n")
if attachment.Text != "" {
text = joinText(text, attachment.Text, "\n")
} else {
text = joinText(text, attachment.Fallback, "\n")
}
text = joinText(text, attachment.ImageURL, "\n")
}
for _, file := range message.Files {
text = joinText(text, ctx.FileHandler.Download(file), " ")
}
log.Debugf("SLACK msg from %v (%v) on %v: %v",
message.User,
name,
message.Channel,
text,
)
if name == "" && text == "" {
log.Warningf("Empty username and message: %+v", message)
return
}
text = replacePermalinkWithText(ctx, text)
text = ctx.ExpandUserIds(text)
text = ExpandText(text)
text = joinText(prefix, text, " ")
if name == ctx.Nick() {
botID := message.BotID
if (ctx.usingLegacyToken && user != nil && botID != user.Profile.BotID) ||
(!ctx.usingLegacyToken && message.ClientMsgID == "") {
// Don't print my own messages.
// When using legacy tokens, we distinguish our own messages sent
// from other clients by checking the bot ID.
// With new style tokens, we check the client message ID.
log.Debugf("Skipping message sent by me")
return
}
}
// handle multi-line messages
var linePrefix, lineSuffix string
if message.SubType == "me_message" {
// handle /me messages
linePrefix = "\x01ACTION "
lineSuffix = "\x01"
}
for _, line := range strings.Split(text, "\n") {
privmsg := fmt.Sprintf(":%v!%v@%v PRIVMSG %v :%s%s%s\r\n",
name, message.User, ctx.ServerName,
channame, linePrefix, line, lineSuffix,
)
log.Debug(privmsg)
if _, err := ctx.Conn.Write([]byte(privmsg)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
}
func eventHandler(ctx *IrcContext, rtm *slack.RTM) {
log.Info("Started Slack event listener")
for msg := range rtm.IncomingEvents {
switch ev := msg.Data.(type) {
case *slack.MessageEvent:
// https://api.slack.com/events/message
message := ev.Msg
if message.Hidden {
continue
}
switch message.SubType {
case "message_changed":
// https://api.slack.com/events/message/message_changed
editedMessage, err := getConversationDetails(ctx, message.Channel, message.Timestamp)
if err != nil {
fmt.Printf("could not get changed conversation details %s", err)
continue
}
log.Printf("edited msg chan %v", editedMessage.Msg.Channel)
editedMessage.Msg.Channel = message.Channel
printMessage(ctx, editedMessage.Msg, "(edited)")
continue
case "channel_topic":
// https://api.slack.com/events/message/channel_topic
// Send out new topic
channel := ctx.Channels.ByID(message.Channel)
if channel == nil {
log.Warningf("Cannot get channel name for %v", message.Channel)
} else {
newTopic := fmt.Sprintf(":%v TOPIC %s :%v\r\n", ctx.Mask(), channel.IRCName(), message.Topic)
log.Infof("Got new topic: %v", newTopic)
if _, err := ctx.Conn.Write([]byte(newTopic)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
case "channel_join", "channel_leave":
// https://api.slack.com/events/message/channel_join
// https://api.slack.com/events/message/channel_leave
// Note: this is handled by slack.MemberJoinedChannelEvent
// and slack.MemberLeftChannelEvent.
default:
printMessage(ctx, message, "")
}
case *slack.ConnectedEvent:
log.Info("Connected to Slack")
ctx.SlackConnected = true
case *slack.DisconnectedEvent:
de := msg.Data.(*slack.DisconnectedEvent)
log.Warningf("Disconnected from Slack (intentional: %v, cause: %v)", de.Intentional, de.Cause)
ctx.SlackConnected = false
ctx.Conn.Close()
ctx.Users, ctx.Channels = nil, nil
return
case *slack.MemberJoinedChannelEvent:
// This is the currently preferred way to notify when a user joins a
// channel, see https://api.slack.com/changelog/2017-05-rethinking-channel-entrance-and-exit-events-and-messages
// https://api.slack.com/events/member_joined_channel
log.Infof("Event: Member Joined Channel: %+v", ev)
ch := ctx.Channels.ByID(ev.Channel)
if ch == nil {
log.Warningf("Unknown channel: %s", ev.Channel)
continue
}
if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%s JOIN %s\r\n", ctx.Mask(), ch.IRCName()))); err != nil {
log.Warningf("Failed to send IRC JOIN message for `%s`: %v", ch.IRCName(), err)
}
case *slack.MemberLeftChannelEvent:
// This is the currently preferred way to notify when a user leaves a
// channel, see https://api.slack.com/changelog/2017-05-rethinking-channel-entrance-and-exit-events-and-messages
// https://api.slack.com/events/member_left_channel
log.Infof("Event: Member Left Channel: %+v", ev)
ch := ctx.Channels.ByID(ev.Channel)
if ch == nil {
log.Warningf("Unknown channel: %s", ev.Channel)
continue
}
if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%v PART %s\r\n", ctx.Mask(), ch.IRCName()))); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
case *slack.TeamJoinEvent:
// https://api.slack.com/events/team_join
// update the users list
if _, err := ctx.Users.FetchByIDs(ctx.SlackClient, false, ev.User.ID); err != nil {
log.Warningf("Failed to fetch users: %v", err)
}
case *slack.UserChangeEvent:
// https://api.slack.com/events/user_change
// update the user list
if _, err := ctx.Users.FetchByIDs(ctx.SlackClient, false, ev.User.ID); err != nil {
log.Warningf("Failed to fetch users: %v", err)
}
case *slack.ChannelJoinedEvent, *slack.ChannelLeftEvent:
// https://api.slack.com/events/channel_joined
// Note: this is handled by slack.MemberJoinedChannelEvent
// and slack.MemberLeftChannelEvent.
case *slack.ReactionAddedEvent:
// https://api.slack.com/events/reaction_added
channame := resolveChannelName(ctx, ev.Item.Channel, "")
user := ctx.GetUserInfo(ev.User)
name := ""
if user == nil {
log.Warningf("Error getting user info for %v", ev.User)
name = ev.User
} else {
name = user.Name
}
msg, err := getConversationDetails(ctx, ev.Item.Channel, ev.Item.Timestamp)
if err != nil {
fmt.Printf("could not get Conversation details %s", err)
continue
}
msgText := msg.Text
msgText = ctx.ExpandUserIds(msgText)
msgText = ExpandText(msgText)
msgText = strings.Split(msgText, "\n")[0]
msgText = msgText[:int(math.Min(float64(len(msgText)), 100))]
privmsg := fmt.Sprintf(":%v!%v@%v PRIVMSG %v :\x01ACTION reacted with %s to: \x0315%s\x03\x01\r\n",
name, ev.User, ctx.ServerName,
channame, ev.Reaction, msgText,
)
log.Debug(privmsg)
if _, err := ctx.Conn.Write([]byte(privmsg)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
case *slack.UserTypingEvent:
// https://api.slack.com/events/user_typing
u := ctx.GetUserInfo(ev.User)
username := "<unknown>"
if u != nil {
username = u.Name
}
c, err := ctx.GetConversationInfo(ev.Channel)
channame := "<unknown or IM chat>"
if err == nil {
channame = c.Name
}
log.Infof("User %s (%s) is typing on channel %s (%s)", ev.User, username, ev.Channel, channame)
case *slack.DesktopNotificationEvent:
// TODO implement actions on notifications
log.Infof("Event: Desktop notification: %+v", ev)
case *slack.LatencyReport:
log.Infof("Current Slack latency: %v", ev.Value)
case *slack.RTMError:
log.Warningf("Slack RTM error: %v", ev.Error())
case *slack.InvalidAuthEvent:
log.Warningf("Invalid slack credentials")
default:
log.Debugf("SLACK event: %v: %+v", msg.Type, msg.Data)
}
}
}
================================================
FILE: pkg/ircslack/file_handler.go
================================================
package ircslack
import (
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/slack-go/slack"
)
const (
maxHTTPAttempts = 3
retryInterval = time.Second
)
// FileHandler downloads files from slack
type FileHandler struct {
SlackAPIKey string
FileDownloadLocation string
ProxyPrefix string
}
func retryableNetError(err error) bool {
if err == nil {
return false
}
switch err := err.(type) {
case net.Error:
if err.Timeout() {
return true
}
}
return false
}
func retryableHTTPError(resp *http.Response) bool {
if resp == nil {
return false
}
if resp.StatusCode == 500 || resp.StatusCode == 502 {
return true
}
return false
}
// Download downloads url contents to a local file and returns a url to either
// the file on slack's server or a downloaded file
func (handler *FileHandler) Download(file slack.File) string {
fileURL := file.URLPrivate
if handler.FileDownloadLocation == "" || file.IsExternal || handler.SlackAPIKey == "" {
return fileURL
}
localFileName := fmt.Sprintf("%s_%s", file.ID, file.Title)
if !strings.HasSuffix(localFileName, file.Filetype) {
localFileName += "." + file.Filetype
}
localFilePath := filepath.Join(handler.FileDownloadLocation, localFileName)
go func() {
out, err := os.Create(localFilePath)
if err != nil {
log.Warningf("Could not create file for download %s: %v", localFilePath, err)
return
}
defer out.Close()
request, _ := http.NewRequest("GET", fileURL, nil)
request.Header.Add("Authorization", "Bearer "+handler.SlackAPIKey)
var client = &http.Client{}
var resp *http.Response
for attempt := 0; attempt < maxHTTPAttempts; attempt++ {
resp, err = client.Do(request)
if err != nil && retryableNetError(err) || retryableHTTPError(resp) {
time.Sleep(retryInterval * time.Duration(math.Pow(float64(attempt), 2)))
continue
}
if err == nil {
break
}
log.Warningf("Error downloading %s: %v", fileURL, err)
return
}
if resp.StatusCode != http.StatusOK {
log.Debugf("Got %d while downloading %s", resp.StatusCode, fileURL)
return
}
defer resp.Body.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
log.Warningf("Error writing %s: %v", fileURL, err)
}
}()
if handler.ProxyPrefix != "" {
return handler.ProxyPrefix + url.PathEscape(localFileName)
}
return fileURL
}
================================================
FILE: pkg/ircslack/irc_context.go
================================================
package ircslack
import (
"fmt"
"net"
"strings"
"time"
"github.com/slack-go/slack"
)
// SlackPostMessage represents a message sent to slack api
type SlackPostMessage struct {
Target string
TargetTs string
Text string
}
// IrcContext holds the client context information
type IrcContext struct {
Conn net.Conn
User *slack.User
// TODO make RealName a function
RealName string
OrigName string
SlackClient *slack.Client
SlackRTM *slack.RTM
SlackAPIKey string
SlackDebug bool
SlackConnected bool
ServerName string
Channels *Channels
Users *Users
ChunkSize int
postMessage chan SlackPostMessage
conversationCache map[string]*slack.Channel
FileHandler *FileHandler
// set to `true` if we are using a deprecated legacy token, false otherwise
usingLegacyToken bool
}
// Nick returns the nickname of the user, if known
func (ic *IrcContext) Nick() string {
if ic.User == nil {
return "<unknown>"
}
return ic.User.Name
}
// UserName returns the user's name. Currently this is equivalent to the user's
// Slack ID
func (ic *IrcContext) UserName() string {
if ic.User == nil {
return "<unknown>"
}
return ic.User.ID
}
// GetThreadOpener returns text of the first message in a thread that provided message belongs to
func (ic *IrcContext) GetThreadOpener(channel string, threadTimestamp string) (slack.Message, error) {
msgs, _, _, err := ic.SlackClient.GetConversationReplies(&slack.GetConversationRepliesParameters{
ChannelID: channel,
Timestamp: threadTimestamp,
})
if err != nil || len(msgs) == 0 {
return slack.Message{}, err
}
return msgs[0], nil
}
// ExpandUserIds will convert slack user tags with user's nicknames
func (ic *IrcContext) ExpandUserIds(text string) string {
return rxSlackUser.ReplaceAllStringFunc(text, func(subs string) string {
uid := subs[2 : len(subs)-1]
user := ic.GetUserInfo(uid)
if user == nil {
return subs
}
return fmt.Sprintf("@%s", user.Name)
})
}
// Start handles batching of messages to slack
func (ic *IrcContext) Start() {
textBuffer := make(map[string]string)
timer := time.NewTimer(time.Second)
var message SlackPostMessage
for {
select {
case message = <-ic.postMessage:
log.Debugf("Got new message %v", message)
textBuffer[message.Target] += message.Text + "\n"
timer.Reset(time.Second)
case <-timer.C:
for target, text := range textBuffer {
opts := []slack.MsgOption{}
opts = append(opts, slack.MsgOptionAsUser(true))
opts = append(opts, slack.MsgOptionText(strings.TrimSpace(text), false))
if message.TargetTs != "" {
opts = append(opts, slack.MsgOptionTS(message.TargetTs))
}
if _, _, err := ic.SlackClient.PostMessage(target, opts...); err != nil {
log.Warningf("Failed to post message to Slack to target %s: %v", target, err)
}
}
textBuffer = make(map[string]string)
}
}
}
// PostTextMessage batches all messages that should be posted to slack
func (ic *IrcContext) PostTextMessage(target, text, targetTs string) {
ic.postMessage <- SlackPostMessage{
Target: target,
TargetTs: targetTs,
Text: text,
}
}
// GetUserInfo returns a slack.User instance from a given user ID, or nil if
// no user with that ID was found
func (ic *IrcContext) GetUserInfo(userID string) *slack.User {
u := ic.Users.ByID(userID)
if u == nil {
log.Warningf("GetUserInfo: unknown user ID '%s'", userID)
}
return u
}
// GetUserInfoByName returns a slack.User instance from a given user name, or
// nil if no user with that name was found
func (ic *IrcContext) GetUserInfoByName(username string) *slack.User {
u := ic.Users.ByName(username)
if u == nil {
log.Warningf("GetUserInfoByName: unknown user name '%s'", username)
}
return u
}
// UserID returns the user's Slack ID
func (ic IrcContext) UserID() string {
if ic.User == nil {
return "<unknown>"
}
return ic.User.ID
}
// Mask returns the IRC mask for the current user
func (ic IrcContext) Mask() string {
return fmt.Sprintf("%v!%v@%v", ic.Nick(), ic.UserName(), ic.Conn.RemoteAddr().(*net.TCPAddr).IP)
}
// GetConversationInfo is cached version of slack.GetConversationInfo
func (ic IrcContext) GetConversationInfo(conversation string) (*slack.Channel, error) {
c, ok := ic.conversationCache[conversation]
if ok {
return c, nil
}
c, err := ic.SlackClient.GetConversationInfo(&slack.GetConversationInfoInput{ChannelID: conversation, IncludeLocale: true, IncludeNumMembers: true})
if err != nil {
return c, err
}
ic.conversationCache[conversation] = c
return c, nil
}
// Maps of user contexts and nicknames
var (
UserContexts = map[net.Addr]*IrcContext{}
)
// SendUnknownError sends an IRC 400 (ERR_UNKNOWNERROR) message to the client
// and prints a warning about it.
func (ic *IrcContext) SendUnknownError(fmtstr string, args ...interface{}) {
msg := fmt.Sprintf(fmtstr, args...)
log.Warningf("Sending ERR_UNKNOWNERROR (400) to client with message: %s", msg)
if err := SendIrcNumeric(ic, 400, ic.Nick(), msg); err != nil {
log.Warningf("Failed to send ERR_UNKNOWNERROR (400) to client: %v", err)
}
}
================================================
FILE: pkg/ircslack/irc_server.go
================================================
package ircslack
import (
"errors"
"fmt"
"html"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/coredhcp/coredhcp/logger"
"github.com/sirupsen/logrus"
"github.com/slack-go/slack"
)
// Project constants
const (
ProjectAuthor = "Andrea Barberio"
ProjectAuthorEmail = "insomniac@slackware.it"
ProjectURL = "https://github.com/insomniacslk/irc-slack"
MaxSlackAPIAttempts = 3
)
// IrcCommandHandler is the prototype that every IRC command handler has to implement
type IrcCommandHandler func(*IrcContext, string, string, []string, string)
// IrcCommandHandlers maps each IRC command to its handler function
var IrcCommandHandlers = map[string]IrcCommandHandler{
"CAP": IrcCapHandler,
"NICK": IrcNickHandler,
"USER": IrcUserHandler,
"PING": IrcPingHandler,
"PRIVMSG": IrcPrivMsgHandler,
"QUIT": IrcQuitHandler,
"MODE": IrcModeHandler,
"PASS": IrcPassHandler,
"WHOIS": IrcWhoisHandler,
"WHO": IrcWhoHandler,
"JOIN": IrcJoinHandler,
"PART": IrcPartHandler,
"TOPIC": IrcTopicHandler,
"NAMES": IrcNamesHandler,
}
// IrcNumericsSafeToChunk is a list of IRC numeric replies that are safe
// to chunk. As per RFC2182, the maximum message size is 512, including
// newlines. Sending longer lines breaks some clients like ZNC. See
// https://github.com/insomniacslk/irc-slack/issues/38 for background.
// This list is meant to grow if we find more IRC numerics that are safe
// to split.
// Being safe to split doesn't mean that it *will* be split. The actual
// behaviour depends on the IrcContext.ChunkSize value.
var IrcNumericsSafeToChunk = []int{
// RPL_WHOREPLY
352,
// RPL_NAMREPLY
353,
}
// SplitReply will split a reply message if necessary. See
// IrcNumericSafeToChunk for background on why splitting.
// The function will return a list of chunks to be sent
// separately.
// The first argument is the entire message to be split.
// The second argument is the chunk size to use to determine
// whether the message should be split. Any value equal or above
// 512 will cause splitting. Any other value will return the
// unmodified string as only item of the list.
func SplitReply(preamble, msg string, chunksize int) []string {
if chunksize < 512 || chunksize >= len(preamble)+len(msg)+2 {
// return the whole string as one chunk
return []string{preamble + msg + "\r\n"}
}
log.Debugf("Splitting reply in %d-byte chunks", chunksize)
// Split and build a string until it's long enough to fit the
// chunk. Splitting ignores multiple contiguous white-spaces.
// We assume this is safe (unless we find out it's not).
// Additionally, squeezing multiple contiguous spaces could
// render the final reply shorter than the chunk size, but we
// don't care here.
maxLen := chunksize - len(preamble) - 2
lines := WordWrap(strings.Fields(msg), maxLen)
reply := make([]string, len(lines))
for idx, line := range lines {
reply[idx] = preamble + line + "\r\n"
}
return reply
}
var (
rxSlackUrls = regexp.MustCompile(`<[^>]+>?`)
rxSlackUser = regexp.MustCompile(`<@[UW][A-Z0-9]+>`)
rxSlackArchiveURL = regexp.MustCompile(`https?:\\/\\/[a-z0-9\\-]+\\.slack\\.com\\/archives\\/([a-zA-Z0-9]+)\\/p([0-9]{10})([0-9]{6})`)
)
// ExpandText expands and unquotes text and URLs from Slack's messages. Slack
// quotes the text and URLS, and the latter are enclosed in < and >. It also
// translates potential URLs into actual URLs (e.g. when you type "example.com"),
// so you will get something like <http://example.com|example.com>. This
// function tries to detect them and unquote and expand them for a better
// visualization on IRC.
func ExpandText(text string) string {
text = rxSlackUrls.ReplaceAllStringFunc(text, func(subs string) string {
if !strings.HasPrefix(subs, "<") && !strings.HasSuffix(subs, ">") {
return subs
}
// Slack URLs may contain an URL followed by a "|", followed by the
// original message. Detect the pipe and only parse the URL.
var (
slackURL = subs[1 : len(subs)-1]
slackMsg string
)
idx := strings.LastIndex(slackURL, "|")
if idx >= 0 {
slackMsg = slackURL[idx+1:]
slackURL = slackURL[:idx]
}
u, err := url.Parse(slackURL)
if err != nil {
return subs
}
// Slack escapes the URLs passed by the users, let's undo that
//u.RawQuery = html.UnescapeString(u.RawQuery)
if slackMsg == "" {
return u.String()
}
return fmt.Sprintf("%s (%s)", slackMsg, u.String())
})
text = html.UnescapeString(text)
return text
}
// SendIrcNumeric sends a numeric code message to the recipient
func SendIrcNumeric(ctx *IrcContext, code int, args, desc string) error {
preamble := fmt.Sprintf(":%s %03d %s :", ctx.ServerName, code, args)
//reply := fmt.Sprintf(":%s %03d %s :%s\r\n", ctx.ServerName, code, args, desc)
chunks := SplitReply(preamble, desc, ctx.ChunkSize)
for _, chunk := range chunks {
log.Debugf("Sending numeric reply: %s", chunk)
_, err := ctx.Conn.Write([]byte(chunk))
if err != nil {
return err
}
}
return nil
}
// IrcSendChanInfoAfterJoin sends channel information to the user about a joined
// channel.
func IrcSendChanInfoAfterJoin(ctx *IrcContext, ch *Channel, members []slack.User) {
IrcSendChanInfoAfterJoinCustom(ctx, ch.IRCName(), ch.ID, ch.Purpose.Value, members)
}
// IrcSendChanInfoAfterJoinCustom sends channel information to the user about a joined
// channel. It can be used as an alternative to IrcSendChanInfoAfterJoin when
// you need to specify custom chan name, id, and topic.
func IrcSendChanInfoAfterJoinCustom(ctx *IrcContext, chanName, chanID, topic string, members []slack.User) {
memberNames := make([]string, 0, len(members))
for _, m := range members {
memberNames = append(memberNames, m.Name)
}
// TODO wrap all these Conn.Write into a function
if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%s JOIN %s\r\n", ctx.Mask(), chanName))); err != nil {
log.Warningf("Failed to send IRC JOIN message: %v", err)
}
// RPL_TOPIC
if err := SendIrcNumeric(ctx, 332, fmt.Sprintf("%s %s", ctx.Nick(), chanName), topic); err != nil {
log.Warningf("Failed to send IRC TOPIC message: %v", err)
}
// RPL_NAMREPLY
if len(members) > 0 {
if err := SendIrcNumeric(ctx, 353, fmt.Sprintf("%s = %s", ctx.Nick(), chanName), strings.Join(memberNames, " ")); err != nil {
log.Warningf("Failed to send IRC NAMREPLY message: %v", err)
}
}
// RPL_ENDOFNAMES
if err := SendIrcNumeric(ctx, 366, fmt.Sprintf("%s %s", ctx.Nick(), chanName), "End of NAMES list"); err != nil {
log.Warningf("Failed to send IRC ENDOFNAMES message: %v", err)
}
log.Infof("Joined channel %s", chanName)
}
// joinChannel will join the channel with the given ID, name and topic, and send back a
// response to the IRC client
func joinChannel(ctx *IrcContext, ch *Channel) error {
log.Infof("%s topic=%s members=%d", ch.IRCName(), ch.Purpose.Value, ch.NumMembers)
// the channels are already joined, notify the IRC client of their
// existence
members, err := ChannelMembers(ctx, ch.ID)
if err != nil {
jErr := fmt.Errorf("Failed to fetch users in channel `%s (channel ID: %s): %v", ch.Name, ch.ID, err)
ctx.SendUnknownError("%s", jErr.Error())
return jErr
}
go IrcSendChanInfoAfterJoin(ctx, ch, members)
return nil
}
// joinChannels gets all the available Slack channels and sends an IRC JOIN message
// for each of the joined channels on Slack
func joinChannels(ctx *IrcContext) error {
for _, sch := range ctx.Channels.AsMap() {
ch := Channel(sch)
if !ch.IsPublicChannel() && !ch.IsPrivateChannel() {
continue
}
if ch.IsMember {
if err := joinChannel(ctx, &ch); err != nil {
return err
}
}
}
return nil
}
// IrcAfterLoggingIn is called once the user has successfully logged on IRC
func IrcAfterLoggingIn(ctx *IrcContext, rtm *slack.RTM) error {
if ctx.OrigName != ctx.Nick() {
// Force the user into the Slack nick
if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%s NICK %s\r\n", ctx.OrigName, ctx.Nick()))); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
// Send a welcome to the user, to let the client knows that it's connected
// RPL_WELCOME
if err := SendIrcNumeric(ctx, 1, ctx.Nick(), fmt.Sprintf("Welcome to the %s IRC chat, %s!", ctx.ServerName, ctx.Nick())); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
// RPL_MOTDSTART
if err := SendIrcNumeric(ctx, 375, ctx.Nick(), ""); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
// RPL_MOTD
motd := func(s string) {
if err := SendIrcNumeric(ctx, 372, ctx.Nick(), s); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
// RPL_ISUPPORT
if err := SendIrcNumeric(ctx, 005, ctx.Nick(), "CHANTYPES="+strings.Join(SupportedChannelPrefixes(), "")); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
motd(fmt.Sprintf("This is an IRC-to-Slack gateway, written by %s <%s>.", ProjectAuthor, ProjectAuthorEmail))
motd(fmt.Sprintf("More information at %s.", ProjectURL))
motd(fmt.Sprintf("Slack team name: %s", ctx.SlackRTM.GetInfo().Team.Name))
motd(fmt.Sprintf("Your user info: "))
motd(fmt.Sprintf(" Name : %s", ctx.User.Name))
motd(fmt.Sprintf(" ID : %s", ctx.User.ID))
motd(fmt.Sprintf(" RealName : %s", ctx.User.RealName))
// RPL_ENDOFMOTD
if err := SendIrcNumeric(ctx, 376, ctx.Nick(), ""); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
// get channels
if err := joinChannels(ctx); err != nil {
return err
}
go eventHandler(ctx, rtm)
return nil
}
// IrcCapHandler is called when a CAP command is sent
func IrcCapHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
if len(args) > 1 {
if args[0] == "LS" {
reply := fmt.Sprintf(":%s CAP * LS :\r\n", ctx.ServerName)
if _, err := ctx.Conn.Write([]byte(reply)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
} else {
log.Debugf("Got CAP %v", args)
}
}
}
// parseMentions parses mentions and converts them to the syntax that
// Slack will parse, i.e. <@nickname>
func parseMentions(text string) string {
tokens := strings.Split(text, " ")
for idx, token := range tokens {
if token == "@here" {
tokens[idx] = "<!here>"
} else if token == "@channel" {
tokens[idx] = "<!channel>"
} else if token == "@everyone" {
tokens[idx] = "<!everyone>"
} else if strings.HasPrefix(token, "@") {
tokens[idx] = "<" + token + ">"
}
}
return strings.Join(tokens, " ")
}
func getTargetTs(channelName string) string {
if !strings.HasPrefix(channelName, "+") {
return ""
}
chanNameSplit := strings.Split(channelName, "-")
return chanNameSplit[len(chanNameSplit)-1]
}
// IrcPrivMsgHandler is called when a PRIVMSG command is sent
func IrcPrivMsgHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
var channelParameter, text string
switch len(args) {
case 1:
channelParameter = args[0]
text = trailing
case 2:
channelParameter = args[0]
text = args[1]
default:
log.Warningf("Invalid number of parameters for PRIVMSG, want 1 or 2, got %d", len(args))
}
if channelParameter == "" || text == "" {
log.Warningf("Invalid PRIVMSG command args: %v %v", args, trailing)
return
}
channel := ctx.Channels.ByName(channelParameter)
target := ""
if channel != nil {
// known channel
target = channel.SlackName()
} else {
// assume private message
target = "@" + channelParameter
}
if strings.HasPrefix(text, "\x01ACTION ") && strings.HasSuffix(text, "\x01") {
// The Slack API has a bug, where a chat.meMessage is
// documented to accept a channel name or ID, but actually
// only the channel ID will work. So until this is fixed,
// resolve the channel ID for chat.meMessage .
// TODO revert this when the bug in the Slack API is fixed
key := target
ch := ctx.Channels.ByName(key)
if ch == nil {
log.Warningf("Unknown channel ID for %s", key)
return
}
target = ch.SlackName()
// this is a MeMessage
// strip off the ACTION and \x01 wrapper
text = text[len("\x01ACTION ") : len(text)-1]
/*
* workaround: I believe that there is an issue with the
* slack API for the method chat.meMessage . Until this
* is clarified, I will emulate a "me message" using a
* simple italic formatting for the message.
* See https://github.com/insomniacslk/irc-slack/pull/39
*/
// TODO once clarified the issue, restore the
// MsgOptionMeMessage, remove the MsgOptionAsUser,
// and remove the italic text
//opts = append(opts, slack.MsgOptionMeMessage())
text = "_" + text + "_"
}
ctx.PostTextMessage(
target,
parseMentions(text),
getTargetTs(channelParameter),
)
}
// wrapped logger that satisfies the slack.logger interface
type loggerWrapper struct {
*logrus.Entry
}
func (l *loggerWrapper) Output(calldepth int, s string) error {
l.Print(s)
return nil
}
// custom HTTP client used to set the auth cookie if requested, and only over
// TLS.
type httpClient struct {
c http.Client
cookie string
}
func (hc httpClient) Do(req *http.Request) (*http.Response, error) {
if hc.cookie != "" {
log.Debugf("Setting auth cookie")
if strings.ToLower(req.URL.Scheme) == "https" {
req.Header.Add("Cookie", hc.cookie)
} else {
log.Warning("Cookie is set but connection is not HTTPS, skipping")
}
}
return hc.c.Do(req)
}
// passwordToTokenAndCookie parses the password specified by the user into a
// Slack token and optionally a cookie Auth cookies can be specified by
// appending a "|" symbol and the base64-encoded auth cookie to the Slack token.
func passwordToTokenAndCookie(p string) (string, string, error) {
parts := strings.Split(p, "|")
switch len(parts) {
case 1:
// XXX should check that the token starts with xoxp- ?
return parts[0], "", nil
case 2:
if !strings.HasPrefix(parts[0], "xoxc-") {
return "", "", errors.New("auth cookie is set, but token does not start with xoxc-")
}
if parts[1] == "" {
return "", "", errors.New("auth cookie is empty")
}
if !strings.HasPrefix(parts[1], "d=") || !strings.HasSuffix(parts[1], ";") {
return "", "", errors.New("auth cookie must have the format 'd=XXX;'")
}
return parts[0], parts[1], nil
default:
return "", "", fmt.Errorf("failed to parse password into token and cookie, got %d components, want 1 or 2", len(parts))
}
}
func connectToSlack(ctx *IrcContext) error {
token, cookie, err := passwordToTokenAndCookie(ctx.SlackAPIKey)
if err != nil {
return err
}
ctx.SlackClient = slack.New(
token,
slack.OptionDebug(ctx.SlackDebug),
slack.OptionLog(&loggerWrapper{logger.GetLogger("slack-api")}),
slack.OptionHTTPClient(&httpClient{cookie: cookie}),
)
if cookie == "" {
// legacy token
ctx.usingLegacyToken = true
}
rtm := ctx.SlackClient.NewRTM()
ctx.SlackRTM = rtm
go rtm.ManageConnection()
log.Info("Starting Slack client")
// Wait until the websocket is connected, then print client info
var info *slack.Info
// FIXME tune the timeout to a value that makes sense
timeout := 10 * time.Second
start := time.Now()
for {
if info = rtm.GetInfo(); info != nil {
break
}
if time.Now().After(start.Add(timeout)) {
return fmt.Errorf("Connection to Slack timed out after %v", timeout)
}
time.Sleep(100 * time.Millisecond)
}
log.Info("CLIENT INFO:")
log.Infof(" URL : %s", info.URL)
log.Infof(" User : %+v", *info.User)
log.Infof(" Team : %+v", *info.Team)
// the users cache is not yet populated at this point, so we call the Slack
// API directly.
user, err := ctx.SlackClient.GetUserInfo(info.User.ID)
if err != nil {
return fmt.Errorf("Cannot get info for user %s (ID: %s): %v", info.User.Name, info.User.ID, err)
}
ctx.User = user
ctx.RealName = user.RealName
// do not fetch users here, they will be fetched later upon joining channels
if err := ctx.Channels.Fetch(ctx.SlackClient); err != nil {
ctx.Conn.Close()
return fmt.Errorf("Failed to fetch channels: %v", err)
}
return IrcAfterLoggingIn(ctx, rtm)
}
// IrcNickHandler is called when a NICK command is sent
func IrcNickHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
nick := trailing
if len(args) == 1 {
nick = args[0]
}
if nick == "" {
log.Warningf("Invalid NICK command args: %v %v", args, trailing)
return
}
if ctx.SlackClient != nil {
if nick != ctx.Nick() {
// You cannot change nick, so force it back
if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%s NICK %s\r\n", nick, ctx.Nick()))); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
return
}
// We need the original nick later to change it
ctx.OrigName = nick
// If we're ready, connect
if ctx.RealName != "" && ctx.SlackAPIKey != "" {
if err := connectToSlack(ctx); err != nil {
log.Warningf("Cannot connect to Slack: %v", err)
// close the IRC connection to the client
ctx.Conn.Close()
}
}
}
// IrcUserHandler is called when a USER command is sent
func IrcUserHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
// ignore the user-specified username. Will use the Slack ID instead
// TODO get user info and set the real name with that info
ctx.RealName = trailing
// If we're ready, connect
if ctx.SlackClient == nil && ctx.SlackAPIKey != "" && ctx.OrigName != "" {
if err := connectToSlack(ctx); err != nil {
log.Warningf("Cannot connect to Slack: %v", err)
// close the IRC connection to the client
ctx.Conn.Close()
}
}
}
// IrcPingHandler is called when a PING command is sent
func IrcPingHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
msg := fmt.Sprintf("PONG %s", strings.Join(args, " "))
if trailing != "" {
msg += " :" + trailing
}
if _, err := ctx.Conn.Write([]byte(msg + "\r\n")); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
// IrcQuitHandler is called when a QUIT command is sent
func IrcQuitHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
ctx.Conn.Close()
}
// IrcModeHandler is called when a MODE command is sent
func IrcModeHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
switch len(args) {
case 0:
log.Warningf("Invalid call to MODE handler: no arguments passed")
case 1:
// get mode request. Always no mode (for now)
mode := "+"
// RPL_CHANNELMODEIS
if err := SendIrcNumeric(ctx, 324, fmt.Sprintf("%s %s %s", ctx.Nick(), args[0], mode), ""); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
default:
// more than 1
// set mode request. Not handled yet
// TODO handle mode set
// ERR_UMODEUNKNOWNFLAG
if err := SendIrcNumeric(ctx, 501, args[0], fmt.Sprintf("Unknown MODE flags %s", strings.Join(args[1:], " "))); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
}
// IrcPassHandler is called when a PASS command is sent
func IrcPassHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
if len(args) != 1 {
log.Warningf("Invalid PASS arguments. Arguments are not shown for this method because they may contain Slack tokens or cookies")
// ERR_PASSWDMISMATCH
if err := SendIrcNumeric(ctx, 464, "", "Invalid password"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return
}
ctx.SlackAPIKey = args[0]
ctx.FileHandler.SlackAPIKey = ctx.SlackAPIKey
// If we're ready, connect
if ctx.SlackClient == nil && ctx.RealName != "" && ctx.OrigName != "" {
if err := connectToSlack(ctx); err != nil {
log.Warningf("Cannot connect to Slack: %v", err)
// close the IRC connection to the client
ctx.Conn.Close()
}
}
}
// IrcWhoHandler is called when a WHO command is sent
func IrcWhoHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
sendErr := func() {
ctx.SendUnknownError("Invalid WHO command. Syntax: WHO <nickname|channel>")
}
if len(args) != 1 && len(args) != 2 {
sendErr()
return
}
target := args[0]
var rargs, desc string
if HasChannelPrefix(target) {
ch := ctx.Channels.ByName(target)
if ch == nil {
// ERR_NOSUCHCHANNEL
if err := SendIrcNumeric(ctx, 403, ctx.Nick(), fmt.Sprintf("No such channel %s", target)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return
}
for _, un := range ch.Members {
// FIXME can we use the cached users?
u := ctx.Users.ByID(un)
if u == nil {
log.Warningf("Failed to get info for user name '%s'", un)
continue
}
log.Infof("%+v", u.Name)
rargs = fmt.Sprintf("%s %s %s %s %s %s *", ctx.Nick(), target, u.ID, ctx.ServerName, ctx.ServerName, u.Name)
desc = fmt.Sprintf("0 %s", u.RealName)
// RPL_WHOREPLY
// "<channel> <user> <host> <server> <nick> \
// <H|G>[*][@|+] :<hopcount> <real name>"
if err := SendIrcNumeric(ctx, 352, rargs, desc); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
// RPL_ENDOFWHO
// "<name> :End of /WHO list"
if err := SendIrcNumeric(ctx, 315, fmt.Sprintf("%s %s", ctx.Nick(), target), "End of WHO list"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return
}
user := ctx.GetUserInfoByName(target)
if user == nil {
// ERR_NOSUCHNICK
if err := SendIrcNumeric(ctx, 401, ctx.Nick(), fmt.Sprintf("No such nick %s", target)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return
}
// FIXME get channel
rargs = fmt.Sprintf("#general %s %s %s %s %s *", ctx.Nick(), user.ID, ctx.ServerName, ctx.ServerName, user.Name)
desc = fmt.Sprintf("0 %s", user.RealName)
// RPL_WHOREPLY
// "<channel> <user> <host> <server> <nick> \
// <H|G>[*][@|+] :<hopcount> <real name>"
if err := SendIrcNumeric(ctx, 352, rargs, desc); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
// IrcWhoisHandler is called when a WHOIS command is sent
func IrcWhoisHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
if len(args) != 1 && len(args) != 2 {
ctx.SendUnknownError("Invalid WHOIS command. Syntax: WHOIS <username>")
return
}
username := args[0]
// if the second argument is the same as the first, it's a request of WHOIS
// with idle time
withIdleTime := false
if len(args) == 2 && args[0] == args[1] {
withIdleTime = true
}
user := ctx.GetUserInfoByName(username)
if user == nil {
// ERR_NOSUCHNICK
if err := SendIrcNumeric(ctx, 401, ctx.Nick(), fmt.Sprintf("No such nick %s", username)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
} else {
// RPL_WHOISUSER
// "<nick> <user> <host> * :<real name>"
if err := SendIrcNumeric(ctx, 311, fmt.Sprintf("%s %s %s %s *", ctx.Nick(), username, user.ID, ctx.ServerName), user.RealName); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
// RPL_WHOISSERVER
// "<nick> <server> :<server info>"
if err := SendIrcNumeric(ctx, 312, fmt.Sprintf("%s %s %s", ctx.Nick(), username, ctx.ServerName), "irc-slack, https://github.com/insomniacslk/irc-slack"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
// Send additional user status information, abusing the RPL_WHOISSERVER
// reply. If there is a better method, please let us know!
if user.Profile.StatusText != "" || user.Profile.StatusEmoji != "" {
userStatus := fmt.Sprintf("user status: '%s' %s", user.Profile.StatusText, user.Profile.StatusEmoji)
if user.Profile.StatusExpiration != 0 {
userStatus += " until " + time.Unix(int64(user.Profile.StatusExpiration), 0).String()
}
if err := SendIrcNumeric(ctx, 312, fmt.Sprintf("%s %s %s", ctx.Nick(), username, ctx.ServerName), userStatus); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
// RPL_WHOISCHANNELS
// "<nick> :{[@|+]<channel><space>}"
var channels []string
for chname, ch := range ctx.Channels.AsMap() {
for _, u := range ch.Members {
if u == user.ID {
channels = append(channels, chname)
}
}
}
if err := SendIrcNumeric(ctx, 319, fmt.Sprintf("%s %s", ctx.Nick(), username), strings.Join(channels, " ")); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
if withIdleTime {
// TODO send RPL_WHOISIDLE (317)
// "<nick> <integer> :seconds idle"
}
// RPL_ENDOFWHOIS
// "<nick> :End of /WHOIS list"
if err := SendIrcNumeric(ctx, 318, fmt.Sprintf("%s %s", ctx.Nick(), username), ":End of /WHOIS list"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
}
// IrcJoinHandler is called when a JOIN command is sent
func IrcJoinHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
if len(args) != 1 {
ctx.SendUnknownError("Invalid JOIN command")
return
}
// Because it is possible for an IRC Client to join multiple channels
// via a multi join (e.g. /join #chan1,#chan2,#chan3) the argument
// needs to be splitted by commas and each channel needs to be joined
// separately.
channames := strings.Split(args[0], ",")
for _, channame := range channames {
if strings.HasPrefix(channame, ChannelPrefixMpIM) || strings.HasPrefix(channame, ChannelPrefixThread) {
log.Debugf("JOIN: ignoring channel `%s`, cannot join multi-party IMs or threads", channame)
continue
}
sch, _, _, err := ctx.SlackClient.JoinConversation(channame)
if err != nil {
log.Warningf("Cannot join channel %s: %v", channame, err)
continue
}
log.Infof("Joined channel %s", channame)
ch := Channel(*sch)
if err := joinChannel(ctx, &ch); err != nil {
log.Warningf("Failed to join channel `%s`: %v", ch.Name, err)
continue
}
}
}
// IrcPartHandler is called when a PART command is sent
func IrcPartHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
if len(args) != 1 {
ctx.SendUnknownError("Invalid PART command")
return
}
channame := StripChannelPrefix(args[0])
// Slack needs the channel ID to leave it, not the channel name. The only
// way to get the channel ID from the name is retrieving the whole channel
// list and finding the one whose name is the one we want to leave
if err := ctx.Channels.Fetch(ctx.SlackClient); err != nil {
log.Warningf("Cannot leave channel %s: %v", channame, err)
ctx.SendUnknownError("Cannot leave channel: %v", err)
return
}
var chanID string
for _, ch := range ctx.Channels.AsMap() {
if ch.Name == channame {
chanID = ch.ID
log.Debugf("Trying to leave channel: %+v", ch)
break
}
}
if chanID == "" {
// ERR_USERNOTINCHANNEL
if err := SendIrcNumeric(ctx, 441, ctx.Nick(), fmt.Sprintf("User is not in channel %s", channame)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
return
}
notInChan, err := ctx.SlackClient.LeaveConversation(chanID)
if err != nil {
log.Warningf("Cannot leave channel %s (id: %s): %v", channame, chanID, err)
return
}
if notInChan {
// ERR_USERNOTINCHANNEL
if err := SendIrcNumeric(ctx, 441, ctx.Nick(), fmt.Sprintf("User is not in channel %s", channame)); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return
}
log.Debugf("Left channel %s", channame)
if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%v PART #%v\r\n", ctx.Mask(), channame))); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
}
// IrcTopicHandler is called when a TOPIC command is sent
func IrcTopicHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
if len(args) < 1 {
// ERR_NEEDMOREPARAMS
if err := SendIrcNumeric(ctx, 461, ctx.Nick(), "TOPIC :Not enough parameters"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return
}
channame := args[0]
topic := trailing
channel := ctx.Channels.ByName(channame)
if channel == nil {
log.Warningf("IrcTopicHandler: unknown channel %s", channame)
return
}
newTopic, err := ctx.SlackClient.SetPurposeOfConversation(channel.ID, topic)
if err != nil {
ctx.SendUnknownError("%s :Cannot set topic: %v", channame, err)
return
}
// RPL_TOPIC
if err := SendIrcNumeric(ctx, 332, fmt.Sprintf("%s :%s", ctx.Nick(), channame), newTopic.Purpose.Value); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
}
// IrcNamesHandler is called when a NAMES command is sent
func IrcNamesHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) {
if len(args) < 1 {
// ERR_NEEDMOREPARAMS
if err := SendIrcNumeric(ctx, 461, ctx.Nick(), "NAMES :Not enough parameters"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
return
}
ch := ctx.Channels.ByName(args[0])
if ch == nil {
ctx.SendUnknownError("Channel `%s` not found", args[0])
return
}
members, err := ChannelMembers(ctx, ch.ID)
if err != nil {
jErr := fmt.Errorf("Failed to fetch users in channel `%s (channel ID: %s): %v", ch.Name, ch.ID, err)
ctx.SendUnknownError("%s", jErr.Error())
return
}
memberNames := make([]string, 0, len(members))
for _, m := range members {
memberNames = append(memberNames, m.Name)
}
log.Printf("Found %d members in %s: %v", len(memberNames), ch.IRCName(), memberNames)
// RPL_NAMREPLY
if len(members) > 0 {
if err := SendIrcNumeric(ctx, 353, fmt.Sprintf("%s = %s", ctx.Nick(), ch.IRCName()), strings.Join(memberNames, " ")); err != nil {
log.Warningf("Failed to send IRC NAMREPLY message: %v", err)
}
}
// RPL_ENDOFNAMES
if err := SendIrcNumeric(ctx, 366, fmt.Sprintf("%s %s", ctx.Nick(), ch.IRCName()), "End of NAMES list"); err != nil {
log.Warningf("Failed to send IRC ENDOFNAMES message: %v", err)
}
}
================================================
FILE: pkg/ircslack/logger.go
================================================
package ircslack
import "github.com/coredhcp/coredhcp/logger"
var log = logger.GetLogger("ircslack")
================================================
FILE: pkg/ircslack/server.go
================================================
package ircslack
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"strings"
"github.com/slack-go/slack"
)
// Server is the server object that exposes the Slack API with an IRC interface.
type Server struct {
Name string
LocalAddr net.Addr
Listener net.Listener
SlackAPIKey string
SlackDebug bool
ChunkSize int
FileDownloadLocation string
FileProxyPrefix string
Pagination int
TLSConfig *tls.Config
}
// Start runs the IRC server
func (s Server) Start() error {
var err error
if s.TLSConfig != nil {
s.Listener, err = tls.Listen("tcp", s.LocalAddr.String(), s.TLSConfig)
} else {
s.Listener, err = net.Listen("tcp", s.LocalAddr.String())
}
if err != nil {
return err
}
defer s.Listener.Close()
log.Infof("Listening on %v", s.LocalAddr)
for {
conn, err := s.Listener.Accept()
if err != nil {
return fmt.Errorf("Error accepting: %v", err)
}
go s.HandleRequest(conn)
}
}
// HandleRequest handle IRC client connections
func (s Server) HandleRequest(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
// clean up this client's state
delete(UserContexts, conn.RemoteAddr())
if err == io.EOF {
log.Warningf("Client %v disconnected", conn.RemoteAddr())
break
}
log.Warningf("Error handling connection from %v: %v", conn.RemoteAddr(), err)
break
}
s.HandleMsg(conn, string(line))
}
}
// HandleMsg handles raw IRC messages
func (s *Server) HandleMsg(conn net.Conn, msg string) {
if strings.HasPrefix(msg, "PASS ") {
log.Debugf("%v: PASS ***** (redacted for privacy)", conn.RemoteAddr())
} else {
log.Debugf("%v: %v", conn.RemoteAddr(), msg)
}
if len(msg) < 1 {
log.Warningf("Invalid message: '%v'", msg)
return
}
var (
prefix, data string
)
if msg[0] == ':' {
prefix = strings.SplitN(msg[1:], " ", 1)[0]
data = msg[len(prefix)+1:]
} else {
prefix = ""
data = msg
}
if !strings.HasSuffix(data, "\r\n") {
log.Warning("Invalid data: not terminated with <CR><LF>")
return
}
data = data[:len(data)-2]
tokens := strings.Split(data, " ")
cmd := tokens[0]
args := tokens[1:]
var trailing string
for idx, arg := range args {
if strings.HasPrefix(arg, ":") {
trailing = strings.Join(args[idx:], " ")[1:]
args = args[:idx]
break
}
}
handler, ok := IrcCommandHandlers[cmd]
if !ok {
log.Warningf("No handler found for %v", cmd)
return
}
ctx, ok := UserContexts[conn.RemoteAddr()]
if !ok || ctx == nil {
ctx = &IrcContext{
Conn: conn,
ServerName: s.Name,
SlackAPIKey: s.SlackAPIKey,
SlackDebug: s.SlackDebug,
ChunkSize: s.ChunkSize,
postMessage: make(chan SlackPostMessage),
conversationCache: make(map[string]*slack.Channel),
FileHandler: &FileHandler{
SlackAPIKey: s.SlackAPIKey,
FileDownloadLocation: s.FileDownloadLocation,
ProxyPrefix: s.FileProxyPrefix,
},
Users: NewUsers(s.Pagination),
Channels: NewChannels(s.Pagination),
}
go ctx.Start()
UserContexts[conn.RemoteAddr()] = ctx
}
handler(ctx, prefix, cmd, args, trailing)
}
================================================
FILE: pkg/ircslack/users.go
================================================
package ircslack
import (
"context"
"fmt"
"sync"
"time"
"github.com/slack-go/slack"
)
// Users wraps the user list with convenient operations and cache.
type Users struct {
users map[string]slack.User
mu sync.Mutex
pagination int
}
// NewUsers creates a new Users object.
func NewUsers(pagination int) *Users {
return &Users{
users: make(map[string]slack.User),
pagination: pagination,
}
}
// FetchByIDs fetches the users with the specified IDs and updates the internal
// user mapping.
func (u *Users) FetchByIDs(client *slack.Client, skipCache bool, userIDs ...string) ([]slack.User, error) {
var (
toRetrieve []string
alreadyRetrieved []slack.User
)
if !skipCache {
u.mu.Lock()
for _, uid := range userIDs {
if u, ok := u.users[uid]; !ok {
toRetrieve = append(toRetrieve, uid)
} else {
alreadyRetrieved = append(alreadyRetrieved, u)
}
}
u.mu.Unlock()
log.Debugf("Fetching information for %d users out of %d (%d already in cache)", len(toRetrieve), len(userIDs), len(userIDs)-len(toRetrieve))
} else {
toRetrieve = userIDs
}
chunkSize := 1000
allFetchedUsers := make([]slack.User, 0, len(userIDs))
for i := 0; i < len(toRetrieve); i += chunkSize {
upperLimit := i + chunkSize
if upperLimit > len(toRetrieve) {
upperLimit = len(toRetrieve)
}
for {
attempt := 0
if attempt >= MaxSlackAPIAttempts {
return nil, fmt.Errorf("Users.FetchByIDs: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts)
}
log.Debugf("Fetching %d users of %d, attempt %d of %d", len(toRetrieve), len(userIDs), attempt+1, MaxSlackAPIAttempts)
slackUsers, err := client.GetUsersInfo(toRetrieve[i:upperLimit]...)
if err != nil {
if rlErr, ok := err.(*slack.RateLimitedError); ok {
// we were rate-limited. Let's wait the recommended delay
log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter)
time.Sleep(rlErr.RetryAfter)
attempt++
continue
}
return nil, err
}
if len(*slackUsers) != len(toRetrieve[i:upperLimit]) {
log.Warningf("Tried to fetch %d users but only got %d", len(toRetrieve[i:upperLimit]), len(*slackUsers))
}
allFetchedUsers = append(allFetchedUsers, *slackUsers...)
// also update the local users map
u.mu.Lock()
for _, user := range *slackUsers {
u.users[user.ID] = user
}
u.mu.Unlock()
break
}
}
allUsers := append(alreadyRetrieved, allFetchedUsers...)
if len(userIDs) != len(allUsers) {
return allFetchedUsers, fmt.Errorf("Found %d users but %d were requested", len(allUsers), len(userIDs))
}
return allUsers, nil
}
// Fetch retrieves all the users on a given Slack team. The Slack client has to
// be valid and connected.
func (u *Users) Fetch(client *slack.Client) ([]slack.User, error) {
log.Infof("Fetching all users, might take a while on large Slack teams")
var opts []slack.GetUsersOption
if u.pagination > 0 {
log.Debugf("Setting user pagination to %d", u.pagination)
opts = append(opts, slack.GetUsersOptionLimit(u.pagination))
}
up := client.GetUsersPaginated(opts...)
var (
err error
ctx = context.Background()
users = make(map[string]slack.User)
)
start := time.Now()
var allFetchedUsers []slack.User
for err == nil {
up, err = up.Next(ctx)
if err == nil {
log.Debugf("Retrieved %d users (current total is %d)", len(up.Users), len(users))
for _, u := range up.Users {
users[u.ID] = u
}
allFetchedUsers = append(allFetchedUsers, up.Users...)
} else if rateLimitedError, ok := err.(*slack.RateLimitedError); ok {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(rateLimitedError.RetryAfter):
err = nil
}
}
}
log.Infof("Retrieved %d users in %s", len(users), time.Since(start))
err = up.Failure(err)
if err != nil {
log.Warningf("Failed to get users: %v", err)
}
u.mu.Lock()
u.users = users
u.mu.Unlock()
return allFetchedUsers, nil
}
// Count returns the number of users. This method must be called after `Fetch`.
func (u *Users) Count() int {
return len(u.users)
}
// ByID retrieves a user by its Slack ID.
func (u *Users) ByID(id string) *slack.User {
u.mu.Lock()
defer u.mu.Unlock()
for _, u := range u.users {
if u.ID == id {
return &u
}
}
return nil
}
// ByName retrieves a user by its Slack name.
func (u *Users) ByName(name string) *slack.User {
u.mu.Lock()
defer u.mu.Unlock()
for _, u := range u.users {
if u.Name == name {
return &u
}
}
return nil
}
// IDsToNames returns a list of user names from the given IDs. The
// returned list could be shorter if there are invalid user IDs.
// Warning: this method is probably only useful for NAMES commands
// where a non-exact mapping is acceptable.
func (u *Users) IDsToNames(userIDs ...string) []string {
u.mu.Lock()
defer u.mu.Unlock()
names := make([]string, 0)
for _, uid := range userIDs {
if u, ok := u.users[uid]; ok {
names = append(names, u.Name)
} else {
log.Warningf("IDsToNames: unknown user ID %s", uid)
}
}
return names
}
================================================
FILE: pkg/ircslack/users_test.go
================================================
package ircslack
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"testing"
"github.com/slack-go/slack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUsersNewUsers(t *testing.T) {
u := NewUsers(0)
require.NotNil(t, u)
assert.NotNil(t, u.users)
assert.Equal(t, 0, u.pagination)
u = NewUsers(200)
require.NotNil(t, u)
assert.NotNil(t, u.users)
assert.Equal(t, 200, u.pagination)
}
type fakeErrorUsersPaginationComplete struct{}
type fakeUsersResponse struct {
Members []slack.User
User slack.User
}
func (f fakeErrorUsersPaginationComplete) Error() string {
return "pagination complete"
}
type fakeSlackHTTPClient struct{}
func (c fakeSlackHTTPClient) Do(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/api/users.list":
// reply as per https://api.slack.com/methods/users.list
data := []byte(`{"ok": true, "members": [{"id": "UABCD", "name": "insomniac"}], "response_metadata": {"next_cursor": ""}}`)
return &http.Response{
Status: "200 OK",
StatusCode: 200,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Body: ioutil.NopCloser(bytes.NewBuffer(data)),
}, nil
default:
return nil, fmt.Errorf("testing: http client URL not supported: %s", req.URL)
}
}
func TestUsersFetch(t *testing.T) {
client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{}))
users := NewUsers(10)
fetched, err := users.Fetch(client)
require.NoError(t, err)
assert.Equal(t, 1, users.Count())
assert.Equal(t, 1, len(fetched))
}
func TestUsersById(t *testing.T) {
client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{}))
users := NewUsers(10)
_, err := users.Fetch(client)
require.NoError(t, err)
u := users.ByID("UABCD")
require.NotNil(t, u)
assert.Equal(t, "UABCD", u.ID)
assert.Equal(t, "insomniac", u.Name)
}
func TestUsersByName(t *testing.T) {
client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{}))
users := NewUsers(10)
_, err := users.Fetch(client)
require.NoError(t, err)
u := users.ByName("insomniac")
require.NotNil(t, u)
assert.Equal(t, "UABCD", u.ID)
assert.Equal(t, "insomniac", u.Name)
}
func TestUsersIDsToNames(t *testing.T) {
client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{}))
users := NewUsers(10)
_, err := users.Fetch(client)
require.NoError(t, err)
names := users.IDsToNames("UABCD")
assert.Equal(t, []string{"insomniac"}, names)
}
================================================
FILE: pkg/ircslack/wordwrap.go
================================================
package ircslack
import (
"strings"
)
// WordWrap wraps the given words up to the maximum specified length.
// If a single word is longer than the max length, it is truncated.
func WordWrap(allWords []string, maxLen int) []string {
var (
lines []string
curLen int
words []string
)
for _, word := range allWords {
// curLen + len(words) + len(word) is the length of the current
// line including spaces
if curLen+len(words)+len(word) > maxLen {
// we have our line. That does not include the current word
lines = append(lines, strings.Join(words, " "))
// reset the current line, add the current word
words = []string{word}
curLen = len(word)
} else {
words = append(words, word)
curLen += len(word)
}
}
if len(words) > 0 {
// there's one last line to add
lines = append(lines, strings.Join(words, " "))
}
for idx, line := range lines {
if len(line) > maxLen {
// truncate
lines[idx] = line[:maxLen]
}
}
return lines
}
================================================
FILE: pkg/ircslack/wordwrap_test.go
================================================
package ircslack
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
var fox = "The quick brown fox jumps over the lazy dog"
func TestWordWrapMultiLine(t *testing.T) {
words := strings.Fields(fox)
wrapped := WordWrap(words, 10)
require.Equal(t, 5, len(wrapped))
require.Equal(t, "The quick", wrapped[0])
require.Equal(t, "brown fox", wrapped[1])
require.Equal(t, "jumps over", wrapped[2])
require.Equal(t, "the lazy", wrapped[3])
require.Equal(t, "dog", wrapped[4])
}
func TestWordWrapSingleLine(t *testing.T) {
words := strings.Fields(fox)
wrapped := WordWrap(words, 100)
require.Equal(t, 1, len(wrapped))
require.Equal(t, fox, wrapped[0])
}
func TestWordWrapTruncate(t *testing.T) {
words := strings.Fields(fox)
wrapped := WordWrap(words, 3)
require.Equal(t, 9, len(wrapped))
require.Equal(t, "The", wrapped[0])
require.Equal(t, "qui", wrapped[1])
require.Equal(t, "bro", wrapped[2])
require.Equal(t, "fox", wrapped[3])
require.Equal(t, "jum", wrapped[4])
require.Equal(t, "ove", wrapped[5])
require.Equal(t, "the", wrapped[6])
require.Equal(t, "laz", wrapped[7])
require.Equal(t, "dog", wrapped[8])
}
================================================
FILE: tools/autotoken/main.go
================================================
// autotoken retrieves a Slack token and cookie using your Slack team
// credentials.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/cdproto/storage"
"github.com/chromedp/chromedp"
"github.com/spf13/pflag"
)
var (
flagDebug = pflag.BoolP("debug", "d", false, "Enable debug log")
flagShowBrowser = pflag.BoolP("show-browser", "b", false, "show browser, useful for debugging")
flagChromePath = pflag.StringP("chrome-path", "c", "", "Custom path for chrome browser")
flagTimeout = pflag.DurationP("timeout", "t", 5*time.Minute, "Timeout")
)
func main() {
usage := func() {
fmt.Fprintf(os.Stderr, "autotoken: log into slack team and get token and cookie.\n\n")
fmt.Fprintf(os.Stderr, "Usage: %s [-d|--debug] [-m|--mfa <token>] [-g|--gdpr] teamname[.slack.com]\n\n", os.Args[0])
pflag.PrintDefaults()
os.Exit(1)
}
pflag.Usage = usage
pflag.Parse()
if len(pflag.Args()) < 1 {
usage()
}
team := pflag.Arg(0)
timeout := *flagTimeout
token, cookie, err := fetchCredentials(context.TODO(), team, timeout, *flagDebug, *flagChromePath)
if err != nil {
log.Fatalf("Failed to fetch credentials for team `%s`: %v", team, err)
}
fmt.Printf("%s|%s\n", token, cookie)
}
// fetchCredentials fetches Slack token and cookie for a given team.
func fetchCredentials(ctx context.Context, team string, timeout time.Duration, doDebug bool, chromePath string) (string, string, error) {
if !strings.HasSuffix(team, ".slack.com") {
team += ".slack.com"
}
var cancel func()
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
// show browser
var allocatorOpts []chromedp.ExecAllocatorOption
if *flagShowBrowser {
allocatorOpts = append(allocatorOpts, chromedp.NoFirstRun, chromedp.NoDefaultBrowserCheck)
} else {
allocatorOpts = append(allocatorOpts, chromedp.Headless)
}
if chromePath != "" {
allocatorOpts = append(allocatorOpts, chromedp.ExecPath(chromePath))
}
ctx, cancel = chromedp.NewExecAllocator(ctx, allocatorOpts...)
defer cancel()
var opts []chromedp.ContextOption
if doDebug {
opts = append(opts, chromedp.WithDebugf(log.Printf))
}
ctx, cancel = chromedp.NewContext(ctx, opts...)
defer cancel()
fmt.Fprintf(os.Stderr, "Fetching token and cookie for %s \n", team)
// run chromedp tasks
return extractTokenAndCookie(ctx, team)
}
// extractTokenAndCookie extracts Slack token and cookie from an existing
// context.
func extractTokenAndCookie(ctx context.Context, team string) (string, string, error) {
teamURL := "https://" + team
var token, cookie string
tasks := chromedp.Tasks{
chromedp.Navigate(teamURL),
chromedp.WaitVisible(".p-workspace__primary_view_contents"),
chromedp.ActionFunc(func(ctx context.Context) error {
v, exp, err := runtime.Evaluate(`q=JSON.parse(localStorage.localConfig_v2)["teams"]; q[Object.keys(q)[0]]["token"]`).Do(ctx)
if err != nil {
return err
}
if exp != nil {
return exp
}
if err := json.Unmarshal(v.Value, &token); err != nil {
return fmt.Errorf("failed to unmarshal token: %v", err)
}
return nil
}),
chromedp.ActionFunc(func(ctx context.Context) error {
cookies, err := storage.GetCookies().Do(ctx)
if err != nil {
return err
}
for _, c := range cookies {
if c.Name == "d" {
cookie = fmt.Sprintf("d=%s;", c.Value)
}
}
return nil
}),
}
if err := chromedp.Run(ctx, tasks); err != nil {
return "", "", err
}
return token, cookie, nil
}
================================================
FILE: tools/slackapp/main.go
================================================
package main
/* Slack Oauth app built according to
* https://api.slack.com/authentication/oauth-v2
*/
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
var (
// irc-slack app client ID, see https://api.slack.com/apps/
clientID = os.Getenv("SLACK_APP_CLIENT_ID")
clientSecret = os.Getenv("SLACK_APP_CLIENT_SECRET")
)
func httpStatus(w http.ResponseWriter, r *http.Request, statusCode int, fmtstr string, args ...interface{}) {
w.WriteHeader(statusCode)
msg := fmt.Sprintf(fmtstr, args...)
fullmsg := fmt.Sprintf("%d - %s\n%s", statusCode, http.StatusText(statusCode), msg)
if _, err := w.Write([]byte(fullmsg)); err != nil {
log.Warningf("Cannot write response: %v", err)
}
}
type slackChallenge struct {
Token, Challenge, Type string
}
func handleSlackChallenge(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Infof("Cannot read body: %v", err)
httpStatus(w, r, 500, "")
return
}
var sc slackChallenge
if err := json.Unmarshal(data, &sc); err != nil {
log.Infof("Cannot unmarshal JSON: %v", err)
httpStatus(w, r, 400, "")
return
}
if _, err := w.Write([]byte(sc.Challenge)); err != nil {
log.Warningf("Failed to write response: %v", err)
}
}
func handleSlackAuth(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query()["code"]
if len(code) < 1 {
log.Info("Missing \"code\" parameter in request")
httpStatus(w, r, 400, "")
return
}
// get token from Slack
form := url.Values{}
form.Add("code", code[0])
form.Add("client_id", clientID)
form.Add("client_secret", clientSecret)
accessURL := "https://slack.com/api/oauth.access"
client := http.Client{}
req, err := http.NewRequest("POST", accessURL, strings.NewReader(form.Encode()))
if err != nil {
log.Infof("Failed to build request for Slack auth API: %v", err)
httpStatus(w, r, 500, "")
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
log.Infof("Failed to request token to Slack auth API: %v", err)
httpStatus(w, r, 500, "")
return
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Infof("Failed to read body from Slack auth API response: %v", err)
httpStatus(w, r, 500, "")
return
}
// parse the response message
// Documented at https://api.slack.com/methods/oauth.access .
// There are other fields, but we don't care about them here.
type authmsg struct {
Ok bool `json:"ok"`
Error string `json:"error"`
AccessToken string `json:"access_token"`
TeamName string `json:"team_name"`
TeamID string `json:"team_id"`
Scope string `json:"scope"`
EnterpriseID *string `json:"enterprise_id,omitempty"`
}
var msg authmsg
if err := json.Unmarshal(data, &msg); err != nil {
log.Infof("Failed to unmarshal API response response: %v", err)
httpStatus(w, r, 500, "")
}
if !msg.Ok {
log.Infof("Got error response from auth API: %s", msg.Error)
httpStatus(w, r, 500, "")
return
}
indented, err := json.MarshalIndent(msg, "", " ")
if err != nil {
log.Infof("Cannot re-marshal API response with indentation: %v", err)
httpStatus(w, r, 500, "")
return
}
httpStatus(w, r, 200, "%s", string(indented))
}
func main() {
flag.Parse()
addr := ":2020"
if flag.Arg(0) != "" {
addr = flag.Arg(0)
}
if clientID == "" {
log.Fatalf("SLACK_APP_CLIENT_ID is empty or not set")
}
if clientSecret == "" {
log.Fatalf("SLACK_APP_CLIENT_SECRET is empty or not set")
}
http.HandleFunc("/irc-slack/challenge/", handleSlackChallenge)
http.HandleFunc("/irc-slack/auth/", handleSlackAuth)
log.Printf("Listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
gitextract_xhv39y9c/
├── .dockerignore
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── docker.yml
│ └── tests.yml
├── .gitignore
├── .stickler.yml
├── Dockerfile
├── Dockerfile.autotoken
├── LICENSE
├── README.md
├── cmd/
│ └── irc-slack/
│ ├── Makefile
│ └── main.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── pkg/
│ └── ircslack/
│ ├── channel.go
│ ├── channels.go
│ ├── channels_test.go
│ ├── event_handler.go
│ ├── file_handler.go
│ ├── irc_context.go
│ ├── irc_server.go
│ ├── logger.go
│ ├── server.go
│ ├── users.go
│ ├── users_test.go
│ ├── wordwrap.go
│ └── wordwrap_test.go
└── tools/
├── autotoken/
│ └── main.go
└── slackapp/
└── main.go
SYMBOL INDEX (130 symbols across 15 files)
FILE: cmd/irc-slack/main.go
function getLogLevels (line 52) | func getLogLevels() []string {
function main (line 60) | func main() {
FILE: pkg/ircslack/channel.go
constant ChannelPrefixPublicChannel (line 16) | ChannelPrefixPublicChannel = "#"
constant ChannelPrefixPrivateChannel (line 17) | ChannelPrefixPrivateChannel = "@"
constant ChannelPrefixMpIM (line 18) | ChannelPrefixMpIM = "&"
constant ChannelPrefixThread (line 20) | ChannelPrefixThread = "+"
function HasChannelPrefix (line 25) | func HasChannelPrefix(name string) bool {
function StripChannelPrefix (line 39) | func StripChannelPrefix(name string) string {
function ChannelMembers (line 47) | func ChannelMembers(ctx *IrcContext, channelID string) ([]slack.User, er...
type Channel (line 95) | type Channel
method IsPublicChannel (line 98) | func (c *Channel) IsPublicChannel() bool {
method IsPrivateChannel (line 103) | func (c *Channel) IsPrivateChannel() bool {
method IsMP (line 108) | func (c *Channel) IsMP() bool {
method IRCName (line 117) | func (c *Channel) IRCName() string {
method SlackName (line 138) | func (c *Channel) SlackName() string {
FILE: pkg/ircslack/channels.go
type Channels (line 13) | type Channels struct
method AsMap (line 40) | func (c *Channels) AsMap() map[string]Channel {
method FetchByIDs (line 52) | func (c *Channels) FetchByIDs(client *slack.Client, skipCache bool, ch...
method Fetch (line 109) | func (c *Channels) Fetch(client *slack.Client) error {
method Count (line 158) | func (c *Channels) Count() int {
method ByID (line 163) | func (c *Channels) ByID(id string) *Channel {
method ByName (line 175) | func (c *Channels) ByName(name string) *Channel {
function NewChannels (line 20) | func NewChannels(pagination int) *Channels {
function SupportedChannelPrefixes (line 28) | func SupportedChannelPrefixes() []string {
FILE: pkg/ircslack/channels_test.go
function TestChannelsNewChannels (line 15) | func TestChannelsNewChannels(t *testing.T) {
type fakeErrorChannelsPaginationComplete (line 21) | type fakeErrorChannelsPaginationComplete struct
method Error (line 28) | func (f fakeErrorChannelsPaginationComplete) Error() string {
type fakeChannelsResponse (line 23) | type fakeChannelsResponse struct
type fakeSlackHTTPClientChannels (line 32) | type fakeSlackHTTPClientChannels struct
method Do (line 34) | func (c fakeSlackHTTPClientChannels) Do(req *http.Request) (*http.Resp...
function TestChannelsFetch (line 52) | func TestChannelsFetch(t *testing.T) {
function TestChannelsById (line 60) | func TestChannelsById(t *testing.T) {
function TestChannelsByName (line 71) | func TestChannelsByName(t *testing.T) {
FILE: pkg/ircslack/event_handler.go
function joinText (line 11) | func joinText(first string, second string, separator string) string {
function formatThreadChannelName (line 21) | func formatThreadChannelName(threadTimestamp string, channel *Channel) s...
function resolveChannelName (line 25) | func resolveChannelName(ctx *IrcContext, msgChannel, threadTimestamp str...
function appendIfNotMoreThan (line 138) | func appendIfNotMoreThan(slice []slack.Msg, msg slack.Msg) []slack.Msg {
function getConversationDetails (line 145) | func getConversationDetails(
function replacePermalinkWithText (line 165) | func replacePermalinkWithText(ctx *IrcContext, text string) string {
function printMessage (line 180) | func printMessage(ctx *IrcContext, message slack.Msg, prefix string) {
function eventHandler (line 257) | func eventHandler(ctx *IrcContext, rtm *slack.RTM) {
FILE: pkg/ircslack/file_handler.go
constant maxHTTPAttempts (line 19) | maxHTTPAttempts = 3
constant retryInterval (line 20) | retryInterval = time.Second
type FileHandler (line 24) | type FileHandler struct
method Download (line 55) | func (handler *FileHandler) Download(file slack.File) string {
function retryableNetError (line 30) | func retryableNetError(err error) bool {
function retryableHTTPError (line 43) | func retryableHTTPError(resp *http.Response) bool {
FILE: pkg/ircslack/irc_context.go
type SlackPostMessage (line 13) | type SlackPostMessage struct
type IrcContext (line 20) | type IrcContext struct
method Nick (line 43) | func (ic *IrcContext) Nick() string {
method UserName (line 52) | func (ic *IrcContext) UserName() string {
method GetThreadOpener (line 60) | func (ic *IrcContext) GetThreadOpener(channel string, threadTimestamp ...
method ExpandUserIds (line 72) | func (ic *IrcContext) ExpandUserIds(text string) string {
method Start (line 84) | func (ic *IrcContext) Start() {
method PostTextMessage (line 112) | func (ic *IrcContext) PostTextMessage(target, text, targetTs string) {
method GetUserInfo (line 122) | func (ic *IrcContext) GetUserInfo(userID string) *slack.User {
method GetUserInfoByName (line 132) | func (ic *IrcContext) GetUserInfoByName(username string) *slack.User {
method UserID (line 141) | func (ic IrcContext) UserID() string {
method Mask (line 149) | func (ic IrcContext) Mask() string {
method GetConversationInfo (line 154) | func (ic IrcContext) GetConversationInfo(conversation string) (*slack....
method SendUnknownError (line 174) | func (ic *IrcContext) SendUnknownError(fmtstr string, args ...interfac...
FILE: pkg/ircslack/irc_server.go
constant ProjectAuthor (line 20) | ProjectAuthor = "Andrea Barberio"
constant ProjectAuthorEmail (line 21) | ProjectAuthorEmail = "insomniac@slackware.it"
constant ProjectURL (line 22) | ProjectURL = "https://github.com/insomniacslk/irc-slack"
constant MaxSlackAPIAttempts (line 23) | MaxSlackAPIAttempts = 3
type IrcCommandHandler (line 27) | type IrcCommandHandler
function SplitReply (line 71) | func SplitReply(preamble, msg string, chunksize int) []string {
function ExpandText (line 104) | func ExpandText(text string) string {
function SendIrcNumeric (line 139) | func SendIrcNumeric(ctx *IrcContext, code int, args, desc string) error {
function IrcSendChanInfoAfterJoin (line 155) | func IrcSendChanInfoAfterJoin(ctx *IrcContext, ch *Channel, members []sl...
function IrcSendChanInfoAfterJoinCustom (line 162) | func IrcSendChanInfoAfterJoinCustom(ctx *IrcContext, chanName, chanID, t...
function joinChannel (line 190) | func joinChannel(ctx *IrcContext, ch *Channel) error {
function joinChannels (line 206) | func joinChannels(ctx *IrcContext) error {
function IrcAfterLoggingIn (line 222) | func IrcAfterLoggingIn(ctx *IrcContext, rtm *slack.RTM) error {
function IrcCapHandler (line 270) | func IrcCapHandler(ctx *IrcContext, prefix, cmd string, args []string, t...
function parseMentions (line 285) | func parseMentions(text string) string {
function getTargetTs (line 301) | func getTargetTs(channelName string) string {
function IrcPrivMsgHandler (line 310) | func IrcPrivMsgHandler(ctx *IrcContext, prefix, cmd string, args []strin...
type loggerWrapper (line 374) | type loggerWrapper struct
method Output (line 378) | func (l *loggerWrapper) Output(calldepth int, s string) error {
type httpClient (line 385) | type httpClient struct
method Do (line 390) | func (hc httpClient) Do(req *http.Request) (*http.Response, error) {
function passwordToTokenAndCookie (line 405) | func passwordToTokenAndCookie(p string) (string, string, error) {
function connectToSlack (line 428) | func connectToSlack(ctx *IrcContext) error {
function IrcNickHandler (line 482) | func IrcNickHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcUserHandler (line 516) | func IrcUserHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcPingHandler (line 532) | func IrcPingHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcQuitHandler (line 543) | func IrcQuitHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcModeHandler (line 548) | func IrcModeHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcPassHandler (line 571) | func IrcPassHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcWhoHandler (line 594) | func IrcWhoHandler(ctx *IrcContext, prefix, cmd string, args []string, t...
function IrcWhoisHandler (line 657) | func IrcWhoisHandler(ctx *IrcContext, prefix, cmd string, args []string,...
function IrcJoinHandler (line 723) | func IrcJoinHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcPartHandler (line 753) | func IrcPartHandler(ctx *IrcContext, prefix, cmd string, args []string, ...
function IrcTopicHandler (line 801) | func IrcTopicHandler(ctx *IrcContext, prefix, cmd string, args []string,...
function IrcNamesHandler (line 828) | func IrcNamesHandler(ctx *IrcContext, prefix, cmd string, args []string,...
FILE: pkg/ircslack/server.go
type Server (line 15) | type Server struct
method Start (line 29) | func (s Server) Start() error {
method HandleRequest (line 51) | func (s Server) HandleRequest(conn net.Conn) {
method HandleMsg (line 71) | func (s *Server) HandleMsg(conn net.Conn, msg string) {
FILE: pkg/ircslack/users.go
type Users (line 13) | type Users struct
method FetchByIDs (line 29) | func (u *Users) FetchByIDs(client *slack.Client, skipCache bool, userI...
method Fetch (line 95) | func (u *Users) Fetch(client *slack.Client) ([]slack.User, error) {
method Count (line 139) | func (u *Users) Count() int {
method ByID (line 144) | func (u *Users) ByID(id string) *slack.User {
method ByName (line 156) | func (u *Users) ByName(name string) *slack.User {
method IDsToNames (line 171) | func (u *Users) IDsToNames(userIDs ...string) []string {
function NewUsers (line 20) | func NewUsers(pagination int) *Users {
FILE: pkg/ircslack/users_test.go
function TestUsersNewUsers (line 15) | func TestUsersNewUsers(t *testing.T) {
type fakeErrorUsersPaginationComplete (line 27) | type fakeErrorUsersPaginationComplete struct
method Error (line 34) | func (f fakeErrorUsersPaginationComplete) Error() string {
type fakeUsersResponse (line 29) | type fakeUsersResponse struct
type fakeSlackHTTPClient (line 38) | type fakeSlackHTTPClient struct
method Do (line 40) | func (c fakeSlackHTTPClient) Do(req *http.Request) (*http.Response, er...
function TestUsersFetch (line 58) | func TestUsersFetch(t *testing.T) {
function TestUsersById (line 67) | func TestUsersById(t *testing.T) {
function TestUsersByName (line 78) | func TestUsersByName(t *testing.T) {
function TestUsersIDsToNames (line 89) | func TestUsersIDsToNames(t *testing.T) {
FILE: pkg/ircslack/wordwrap.go
function WordWrap (line 9) | func WordWrap(allWords []string, maxLen int) []string {
FILE: pkg/ircslack/wordwrap_test.go
function TestWordWrapMultiLine (line 12) | func TestWordWrapMultiLine(t *testing.T) {
function TestWordWrapSingleLine (line 23) | func TestWordWrapSingleLine(t *testing.T) {
function TestWordWrapTruncate (line 30) | func TestWordWrapTruncate(t *testing.T) {
FILE: tools/autotoken/main.go
function main (line 27) | func main() {
function fetchCredentials (line 51) | func fetchCredentials(ctx context.Context, team string, timeout time.Dur...
function extractTokenAndCookie (line 88) | func extractTokenAndCookie(ctx context.Context, team string) (string, st...
FILE: tools/slackapp/main.go
function httpStatus (line 26) | func httpStatus(w http.ResponseWriter, r *http.Request, statusCode int, ...
type slackChallenge (line 35) | type slackChallenge struct
function handleSlackChallenge (line 39) | func handleSlackChallenge(w http.ResponseWriter, r *http.Request) {
function handleSlackAuth (line 57) | func handleSlackAuth(w http.ResponseWriter, r *http.Request) {
function main (line 123) | func main() {
Condensed preview — 30 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (130K chars).
[
{
"path": ".dockerignore",
"chars": 46,
"preview": "Dockerfile\n.travis\n.dockerignore\ncoverage.txt\n"
},
{
"path": ".github/dependabot.yml",
"chars": 487,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".github/workflows/docker.yml",
"chars": 701,
"preview": "name: Docker push\n\non:\n push:\n branches:\n - master\n\n\njobs:\n docker_publish:\n runs-on: 1.25\n env:\n D"
},
{
"path": ".github/workflows/tests.yml",
"chars": 1576,
"preview": "name: Tests\n\non: [push, pull_request]\n\n\njobs:\n build:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n go"
},
{
"path": ".gitignore",
"chars": 37,
"preview": "coverage.txt\ncmd/irc-slack/irc-slack\n"
},
{
"path": ".stickler.yml",
"chars": 58,
"preview": "linters:\n golint:\n fixer: true\nfixers:\n enable: true\n"
},
{
"path": "Dockerfile",
"chars": 992,
"preview": "############################\n# STEP 1 build executable binary\n############################\nFROM golang:1.23-alpine AS bu"
},
{
"path": "Dockerfile.autotoken",
"chars": 758,
"preview": "############################\n# STEP 1 build executable binary\n############################\nFROM golang:1.16-alpine AS bu"
},
{
"path": "LICENSE",
"chars": 1515,
"preview": "BSD 3-Clause License\n\nCopyright (c) 2018, Andrea Barberio\nAll rights reserved.\n\nRedistribution and use in source and bin"
},
{
"path": "README.md",
"chars": 9815,
"preview": "# IRC-to-Slack gateway\n\n`irc-slack` is an IRC-to-Slack gateway. It is an IRC server that lets you\nconnect to your Slack "
},
{
"path": "cmd/irc-slack/Makefile",
"chars": 238,
"preview": "CMD=irc-slack\n\nREVISION := $(shell git rev-parse --short HEAD)\nBRANCH := $(shell git rev-parse --abbrev-ref HEAD)\n\nall: "
},
{
"path": "cmd/irc-slack/main.go",
"chars": 4325,
"preview": "package main\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net\"\n\t\"os\"\n\n\t\"github.com/insomniacslk/irc-slack/pkg/ircslack\""
},
{
"path": "docker-compose.yml",
"chars": 109,
"preview": "services:\n irc-slack:\n build:\n context: .\n dockerfile: Dockerfile\n ports:\n - 6666:6666\n"
},
{
"path": "go.mod",
"chars": 1580,
"preview": "module github.com/insomniacslk/irc-slack\n\ngo 1.25\n\nrequire (\n\tgithub.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a"
},
{
"path": "go.sum",
"chars": 14574,
"preview": "github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb h1:aZTKxMminKeQWHtzJBbV8TttfTxzdJ+7iEJFE6FmUzg=\ngith"
},
{
"path": "pkg/ircslack/channel.go",
"chars": 4292,
"preview": "package ircslack\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/slack-go/slack\"\n)\n\n// Constants for public, private, "
},
{
"path": "pkg/ircslack/channels.go",
"chars": 5237,
"preview": "package ircslack\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/slack-go/slack\"\n)\n\n// Channels wraps the chan"
},
{
"path": "pkg/ircslack/channels_test.go",
"chars": 2146,
"preview": "package ircslack\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/slack-go/slack\"\n\t\"github.co"
},
{
"path": "pkg/ircslack/event_handler.go",
"chars": 13474,
"preview": "package ircslack\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"strings\"\n\n\t\"github.com/slack-go/slack\"\n)\n\nfunc joinText(first string, second"
},
{
"path": "pkg/ircslack/file_handler.go",
"chars": 2408,
"preview": "package ircslack\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"math\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n"
},
{
"path": "pkg/ircslack/irc_context.go",
"chars": 5166,
"preview": "package ircslack\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/slack-go/slack\"\n)\n\n// SlackPostMessage represe"
},
{
"path": "pkg/ircslack/irc_server.go",
"chars": 29652,
"preview": "package ircslack\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"html\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/co"
},
{
"path": "pkg/ircslack/logger.go",
"chars": 103,
"preview": "package ircslack\n\nimport \"github.com/coredhcp/coredhcp/logger\"\n\nvar log = logger.GetLogger(\"ircslack\")\n"
},
{
"path": "pkg/ircslack/server.go",
"chars": 3251,
"preview": "package ircslack\n\nimport (\n\t\"bufio\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"strings\"\n\n\t\"github.com/slack-go/slack\"\n)\n\n// Ser"
},
{
"path": "pkg/ircslack/users.go",
"chars": 5082,
"preview": "package ircslack\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/slack-go/slack\"\n)\n\n// Users wraps the user li"
},
{
"path": "pkg/ircslack/users_test.go",
"chars": 2497,
"preview": "package ircslack\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/slack-go/slack\"\n\t\"github.co"
},
{
"path": "pkg/ircslack/wordwrap.go",
"chars": 983,
"preview": "package ircslack\n\nimport (\n\t\"strings\"\n)\n\n// WordWrap wraps the given words up to the maximum specified length.\n// If a s"
},
{
"path": "pkg/ircslack/wordwrap_test.go",
"chars": 1154,
"preview": "package ircslack\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nvar fox = \"The quick brown f"
},
{
"path": "tools/autotoken/main.go",
"chars": 3544,
"preview": "// autotoken retrieves a Slack token and cookie using your Slack team\n// credentials.\npackage main\n\nimport (\n\t\"context\"\n"
},
{
"path": "tools/slackapp/main.go",
"chars": 3826,
"preview": "package main\n\n/* Slack Oauth app built according to\n * https://api.slack.com/authentication/oauth-v2\n */\n\nimport (\n\t\"enc"
}
]
About this extraction
This page contains the full source code of the insomniacslk/irc-slack GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 30 files (116.8 KB), approximately 38.2k tokens, and a symbol index with 130 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.