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. [![](images/team_chat_2x.png)](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 -cert ` 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- /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- /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 "" } } // 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 := "" if u != nil { username = u.Name } c, err := ctx.GetConversationInfo(ev.Channel) channame := "" 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 "" } 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 "" } 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 "" } 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 . 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] = "" } else if token == "@channel" { tokens[idx] = "" } else if token == "@everyone" { tokens[idx] = "" } 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 ") } 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 // " \ // [*][@|+] : " if err := SendIrcNumeric(ctx, 352, rargs, desc); err != nil { log.Warningf("Failed to send IRC message: %v", err) } } // RPL_ENDOFWHO // " :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 // " \ // [*][@|+] : " 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 ") 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 // " * :" 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 // " :" 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 // " :{[@|+]}" 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) // " :seconds idle" } // RPL_ENDOFWHOIS // " :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 ") 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 ] [-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)) }