add logic to improve fetching channels list on slack workspaces with enterprise setting

This commit is contained in:
Dmitrii Korotovskii
2025-06-29 22:45:50 +02:00
parent 831e0fe3ce
commit 4983e1098b
23 changed files with 2837 additions and 208 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.23 AS build
FROM golang:1.24 AS build
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=local
@@ -21,7 +21,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM build AS dev
RUN --mount=type=cache,target=/go/pkg/mod \
go install github.com/go-delve/delve/cmd/dlv@v1.23.1 && cp /go/bin/dlv /dlv
go install github.com/go-delve/delve/cmd/dlv@v1.25.0 && cp /go/bin/dlv /dlv
WORKDIR /app/mcp-server
+4 -2
View File
@@ -26,8 +26,10 @@ func main() {
p,
)
go newUsersWatcher(p)()
go newChannelsWatcher(p)()
go func() {
newUsersWatcher(p)()
newChannelsWatcher(p)()
}()
switch transport {
case "stdio":
+5 -5
View File
@@ -4,7 +4,7 @@ services:
context: .
target: dev
dockerfile: Dockerfile
# command: /dlv debug /app/cmd/slack-mcp-server/main.go --headless --listen=:40000 --api-version=2 --log -- --transport sse
command: /dlv debug /app/cmd/slack-mcp-server/main.go --accept-multiclient --headless --listen=:40000 --api-version=2 --log -- --transport sse
restart: unless-stopped
networks:
- app-tier
@@ -14,8 +14,8 @@ services:
# Optional: Uncomment to mount the CA certificate if you need to trust a custom CA
# Don't forget to set SLACK_MCP_SERVER_CA=/usr/local/share/ca-certificates/ca.crt
#
# volumes:
# - ./ca-cert.crt:/usr/local/share/ca-certificates/ca.crt
volumes:
- ./ca-cert.crt:/usr/local/share/ca-certificates/ca.crt
# - ./.users_cache.json:/app/mcp-server/.users_cache.json
env_file:
- .env
@@ -24,8 +24,8 @@ services:
SLACK_MCP_PORT: "3001"
# Uncomment if you use HTTP Toolkit with proxy on 127.0.0.1:8000
# on your host, otherwise leave it commented.
# extra_hosts:
# - "host.docker.internal:host-gateway"
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
app-tier:
+56 -6
View File
@@ -1,20 +1,70 @@
module github.com/korotovsky/slack-mcp-server
go 1.23.0
go 1.24.0
toolchain go1.23.5
toolchain go1.24.4
require (
github.com/bbalet/stopwords v1.0.0
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
github.com/google/uuid v1.6.0
github.com/mark3labs/mcp-go v0.31.0
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f
github.com/rusq/slackauth v0.6.1
github.com/rusq/slackdump/v3 v3.1.6
github.com/rusq/tagops v0.1.1
github.com/slack-go/slack v0.16.0
golang.org/x/sync v0.14.0
golang.org/x/time v0.12.0
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.5 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/huh v0.7.0 // indirect
github.com/charmbracelet/huh/spinner v0.0.0-20250519092748-d6f1597485e0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20250520193441-8304e91a28cb // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-rod/rod v0.116.2 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/playwright-community/playwright-go v0.5200.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rusq/chttp v1.1.0 // indirect
github.com/rusq/fsadapter v1.1.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.24.0 // indirect
github.com/ysmood/fetchup v0.3.0 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
)
+180 -9
View File
@@ -1,9 +1,66 @@
github.com/bbalet/stopwords v1.0.0 h1:0TnGycCtY0zZi4ltKoOGRFIlZHv0WqpoIGUsObjztfo=
github.com/bbalet/stopwords v1.0.0/go.mod h1:sAWrQoDMfqARGIn4s6dp7OW7ISrshUD8IP2q3KoqPjc=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 h1:EtZwYyLbkEcIt+B//6sujwRCnHuTEK3qiSypAX5aJeM=
github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403/go.mod h1:mM6WvakkX2m+NgMiPCfFFjwfH4KzENC07zeGEqq9U7s=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
github.com/charmbracelet/huh/spinner v0.0.0-20250519092748-d6f1597485e0 h1:CiQY7CVtEigidVu1vzLxqdW3Tg2DB66R/2OaM3E2rbI=
github.com/charmbracelet/huh/spinner v0.0.0-20250519092748-d6f1597485e0/go.mod h1:D/ml7UtSMq/cwoJiHJ78KFzGrx4m01ALekBSHImKiu4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20250520193441-8304e91a28cb h1:JFEeU2KTS+W0dkZVbLeEgXI+PLBRZGomQKeWDpQD+V0=
github.com/charmbracelet/x/exp/strings v0.0.0-20250520193441-8304e91a28cb/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ=
@@ -13,29 +70,143 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4=
github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
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/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/playwright-community/playwright-go v0.5200.0 h1:z/5LGuX2tBrg3ug1HupMXLjIG93f1d2MWdDsNhkMQ9c=
github.com/playwright-community/playwright-go v0.5200.0/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rusq/chttp v1.1.0 h1:lfUALJ51uRLgb4tc7joXFOgz9pzKBmc4vGq0UDu3dmk=
github.com/rusq/chttp v1.1.0/go.mod h1:bmuoQMUFs9fmigUmT7xbp8s0rHyzUrf7+78yLklr1so=
github.com/rusq/fsadapter v1.1.0 h1:/tuzrPNGr4Tx2f8fPK+WudSRBLDvjjDaqVvto1yrVdk=
github.com/rusq/fsadapter v1.1.0/go.mod h1:aSH7MYrWvAGiFkz1qGPE8OknkplFfQSj66leC0eSqYg=
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f h1:w4klfw1A3iZv5qWg1YHcRF2bJuRDV7aOpsF6sLLSs0A=
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f/go.mod h1:gULX17QqyNX4BF001nHKlSe0uKYI+MAKiDQ7oi80BYI=
github.com/rusq/slackauth v0.6.1 h1:s09G3WHSA1yz6H9dHT+Yo6DCZF34ClY31tQz849B++Q=
github.com/rusq/slackauth v0.6.1/go.mod h1:wAtNCbeKH0pnaZnqJjG5RKY3e5BF9F2L/YTzhOjBIb0=
github.com/rusq/slackdump/v3 v3.1.6 h1:t6hi49jSDWpiXqyna8OlEd2I2zkLBgi9XZGr+xDl5ik=
github.com/rusq/slackdump/v3 v3.1.6/go.mod h1:c9AiEEkmLWIbQJuxDIK+K9H5g6kdfc06Eqk6DmLWWps=
github.com/rusq/tagops v0.1.1 h1:R5MHPR822lSg3LFr0RS3DFS0CapRiqtuHVD5NlOMOvY=
github.com/rusq/tagops v0.1.1/go.mod h1:mUJ5WoHxrSv9wreCrHQkAeMevt5aXFadlOdLM6UsoHc=
github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8=
github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8=
github.com/ysmood/fetchup v0.3.0/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+76 -76
View File
@@ -2,15 +2,14 @@ package handler
import (
"context"
"encoding/base64"
"fmt"
"log"
"sort"
"strings"
"github.com/gocarina/gocsv"
"github.com/korotovsky/slack-mcp-server/pkg/provider"
"github.com/mark3labs/mcp-go/mcp"
"github.com/slack-go/slack"
)
type Channel struct {
@@ -65,86 +64,29 @@ func (ch *ChannelsHandler) ChannelsHandler(ctx context.Context, request mcp.Call
return nil, fmt.Errorf("limit must be less than 1000, got %d", limit)
}
api, err := ch.apiProvider.Provide()
if err != nil {
return nil, err
}
var channelList []Channel
params := &slack.GetConversationsParameters{
Types: channelTypes,
Limit: limit,
ExcludeArchived: true,
Cursor: cursor,
}
var (
total int
nextcur string
nextcur string
channelList []Channel
)
for {
var chans []slack.Channel
chans, nextcur, err = api.GetConversationsContext(ctx, params)
if err != nil {
break
}
channels := filterChannelsByTypes(ch.apiProvider.ProvideChannelsMaps().Channels, channelTypes)
usersMap := ch.apiProvider.ProvideUsersMap()
var chans []provider.Channel
if l := len(chans); l > 0 {
for _, channel := range chans {
channelName := "#" + channel.Name
purpose := channel.Purpose.Value
numMembers := channel.NumMembers
if channel.IsIM {
numMembers = 2
user, ok := usersMap[channel.User]
if ok {
channelName = "@" + user.Name
purpose = "DM " + user.RealName
} else {
channelName = "@" + channel.User
purpose = "DM with " + channel.User
}
} else if channel.IsMpIM && channel.IsPrivate && channel.NumMembers > 0 {
numMembers = channel.NumMembers
userNames := make([]string, 0, channel.NumMembers)
for _, userID := range channel.Members {
if user, ok := usersMap[userID]; ok {
userNames = append(userNames, user.RealName)
} else {
userNames = append(userNames, userID)
}
}
chans, nextcur = paginateChannels(
channels,
cursor,
limit,
)
channelName = "@" + channel.NameNormalized
purpose = "Group DM with " + strings.Join(userNames, ", ")
}
channelList = append(channelList, Channel{
ID: channel.ID,
Name: channelName,
Topic: channel.Topic.Value,
Purpose: purpose,
MemberCount: numMembers,
})
}
total += l
params.Limit -= l
}
if total >= limit {
log.Printf("channels fetch limit reached %v", total)
break
}
if nextcur == "" {
log.Printf("channels fetch exhausted")
break
}
params.Cursor = nextcur
for _, channel := range chans {
channelList = append(channelList, Channel{
ID: channel.ID,
Name: channel.Name,
Topic: channel.Topic,
Purpose: channel.Purpose,
MemberCount: channel.MemberCount,
})
}
switch sortType {
@@ -167,3 +109,61 @@ func (ch *ChannelsHandler) ChannelsHandler(ctx context.Context, request mcp.Call
return mcp.NewToolResultText(string(csvBytes)), nil
}
func filterChannelsByTypes(channels map[string]provider.Channel, types []string) []provider.Channel {
var result []provider.Channel
typeSet := make(map[string]bool)
for _, t := range types {
typeSet[t] = true
}
for _, ch := range channels {
if typeSet["public_channel"] && !ch.IsPrivate && !ch.IsIM && !ch.IsMpIM {
result = append(result, ch)
}
if typeSet["private_channel"] && ch.IsPrivate && !ch.IsIM && !ch.IsMpIM {
result = append(result, ch)
}
if typeSet["im"] && ch.IsIM {
result = append(result, ch)
}
if typeSet["mpim"] && ch.IsMpIM {
result = append(result, ch)
}
}
return result
}
func paginateChannels(channels []provider.Channel, cursor string, limit int) ([]provider.Channel, string) {
sort.Slice(channels, func(i, j int) bool {
return channels[i].ID < channels[j].ID
})
startIndex := 0
if cursor != "" {
if decoded, err := base64.StdEncoding.DecodeString(cursor); err == nil {
lastID := string(decoded)
for i, ch := range channels {
if ch.ID > lastID {
startIndex = i
break
}
}
}
}
endIndex := startIndex + limit
if endIndex > len(channels) {
endIndex = len(channels)
}
paged := channels[startIndex:endIndex]
var nextCursor string
if endIndex < len(channels) {
nextCursor = base64.StdEncoding.EncodeToString([]byte(channels[endIndex-1].ID))
}
return paged, nextCursor
}
+2 -2
View File
@@ -51,7 +51,7 @@ func (ch *ConversationsHandler) ConversationsHistoryHandler(ctx context.Context,
return nil, err
}
api, err := ch.apiProvider.Provide()
api, err := ch.apiProvider.ProvideGeneric()
if err != nil {
return nil, err
}
@@ -90,7 +90,7 @@ func (ch *ConversationsHandler) ConversationsRepliesHandler(ctx context.Context,
return nil, errors.New("thread_ts must be a string")
}
api, err := ch.apiProvider.Provide()
api, err := ch.apiProvider.ProvideGeneric()
if err != nil {
return nil, err
}
+26
View File
@@ -0,0 +1,26 @@
package limiter
import (
"time"
"golang.org/x/time/rate"
)
type tier struct {
// once every
t time.Duration
// burst
b int
}
func (t tier) Limiter() *rate.Limiter {
return rate.NewLimiter(rate.Every(t.t), t.b)
}
var (
// tier1 = tier{t: 1 * time.Minute, b: 2}
// tier2 = tier{t: 3 * time.Second, b: 3}
Tier2boost = tier{t: 300 * time.Millisecond, b: 5}
Tier3 = tier{t: 1200 * time.Millisecond, b: 4}
// tier4 = tier{t: 60 * time.Millisecond, b: 5}
)
+3
View File
@@ -0,0 +1,3 @@
The "edge" package has been partially copied from `rusq/slackdump`.
Credits to the https://github.com/rusq.
+277 -102
View File
@@ -10,8 +10,13 @@ import (
"net/http"
"net/url"
"os"
"strings"
"github.com/korotovsky/slack-mcp-server/pkg/limiter"
"github.com/korotovsky/slack-mcp-server/pkg/provider/edge"
"github.com/korotovsky/slack-mcp-server/pkg/transport"
slack2 "github.com/rusq/slack"
"github.com/rusq/slackdump/v3/auth"
"github.com/slack-go/slack"
)
@@ -20,27 +25,53 @@ var AllChanTypes = []string{"mpim", "im", "public_channel", "private_channel"}
var PubChanType = "public_channel"
type ChannelsCache struct {
Channels map[string]slack.Channel `json:"channels"`
ChannelsInv map[string]string `json:"channels_inv"`
Channels map[string]Channel `json:"channels"`
ChannelsInv map[string]string `json:"channels_inv"`
}
type ApiProvider struct {
boot func() *slack.Client
client *slack.Client
boot func(ap *ApiProvider) *slack.Client
authProvider *auth.ValueAuth
authResponse *slack2.AuthTestResponse
clientGeneric *slack.Client
clientEnterprise *edge.Client
users map[string]slack.User
usersCache string
channels map[string]slack.Channel
channels map[string]Channel
channelsInv map[string]string
channelsCache string
}
type Channel struct {
ID string `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
Purpose string `json:"purpose"`
MemberCount int `json:"memberCount"`
IsMpIM bool `json:"mpim"`
IsIM bool `json:"im"`
IsPrivate bool `json:"private"`
}
func New() *ApiProvider {
var (
authProvider auth.ValueAuth
err error
)
// Check for XOXP token first (User OAuth)
xoxpToken := os.Getenv("SLACK_MCP_XOXP_TOKEN")
if xoxpToken != "" {
return newWithXOXP(xoxpToken)
authProvider, err = auth.NewValueAuth(xoxpToken, "")
if err != nil {
panic(err)
}
return newWithXOXP(authProvider)
}
// Fall back to XOXC/XOXD tokens (session-based)
@@ -51,10 +82,15 @@ func New() *ApiProvider {
panic("Authentication required: Either SLACK_MCP_XOXP_TOKEN (User OAuth) or both SLACK_MCP_XOXC_TOKEN and SLACK_MCP_XOXD_TOKEN (session-based) environment variables must be provided")
}
return newWithXOXC(xoxcToken, xoxdToken)
authProvider, err = auth.NewValueAuth(xoxcToken, xoxdToken)
if err != nil {
panic(err)
}
return newWithXOXC(authProvider)
}
func newWithXOXP(token string) *ApiProvider {
func newWithXOXP(authProvider auth.ValueAuth) *ApiProvider {
usersCache := os.Getenv("SLACK_MCP_USERS_CACHE")
if usersCache == "" {
usersCache = ".users_cache.json"
@@ -66,12 +102,21 @@ func newWithXOXP(token string) *ApiProvider {
}
return &ApiProvider{
boot: func() *slack.Client {
api := slack.New(token)
boot: func(ap *ApiProvider) *slack.Client {
api := slack.New(authProvider.SlackToken())
res, err := api.AuthTest()
if err != nil {
panic(err)
} else {
ap.authResponse = &slack2.AuthTestResponse{
URL: res.URL,
Team: res.Team,
User: res.User,
TeamID: res.TeamID,
UserID: res.UserID,
EnterpriseID: res.EnterpriseID,
BotID: res.BotID,
}
log.Printf("Authenticated as: %s\n", res)
}
@@ -81,13 +126,13 @@ func newWithXOXP(token string) *ApiProvider {
users: make(map[string]slack.User),
usersCache: usersCache,
channels: make(map[string]slack.Channel),
channels: make(map[string]Channel),
channelsInv: map[string]string{},
channelsCache: channelsCache,
}
}
func newWithXOXC(token, cookie string) *ApiProvider {
func newWithXOXC(authProvider auth.ValueAuth) *ApiProvider {
usersCache := os.Getenv("SLACK_MCP_USERS_CACHE")
if usersCache == "" {
usersCache = ".users_cache.json"
@@ -99,19 +144,29 @@ func newWithXOXC(token, cookie string) *ApiProvider {
}
return &ApiProvider{
boot: func() *slack.Client {
api := slack.New(token,
withHTTPClientOption(cookie),
boot: func(ap *ApiProvider) *slack.Client {
api := slack.New(authProvider.SlackToken(),
withHTTPClientOption(authProvider.Cookies()),
)
res, err := api.AuthTest()
if err != nil {
panic(err)
} else {
ap.authProvider = &authProvider
ap.authResponse = &slack2.AuthTestResponse{
URL: res.URL,
Team: res.Team,
User: res.User,
TeamID: res.TeamID,
UserID: res.UserID,
EnterpriseID: res.EnterpriseID,
BotID: res.BotID,
}
log.Printf("Authenticated as: %s\n", res)
}
api = slack.New(token,
withHTTPClientOption(cookie),
api = slack.New(authProvider.SlackToken(),
withHTTPClientOption(authProvider.Cookies()),
withTeamEndpointOption(res.URL),
)
@@ -121,18 +176,28 @@ func newWithXOXC(token, cookie string) *ApiProvider {
users: make(map[string]slack.User),
usersCache: usersCache,
channels: make(map[string]slack.Channel),
channels: make(map[string]Channel),
channelsInv: map[string]string{},
channelsCache: channelsCache,
}
}
func (ap *ApiProvider) Provide() (*slack.Client, error) {
if ap.client == nil {
ap.client = ap.boot()
func (ap *ApiProvider) ProvideGeneric() (*slack.Client, error) {
if ap.clientGeneric == nil {
ap.clientGeneric = ap.boot(ap)
}
return ap.client, nil
return ap.clientGeneric, nil
}
func (ap *ApiProvider) ProvideEnterprise() (*edge.Client, error) {
if ap.clientEnterprise == nil {
ap.clientEnterprise, _ = edge.NewWithInfo(ap.authResponse, ap.authProvider,
withHTTPClientEdgeOption(ap.authProvider.Cookies()),
)
}
return ap.clientEnterprise, nil
}
func (ap *ApiProvider) RefreshUsers(ctx context.Context) error {
@@ -151,7 +216,7 @@ func (ap *ApiProvider) RefreshUsers(ctx context.Context) error {
optionLimit := slack.GetUsersOptionLimit(1000)
client, err := ap.Provide()
client, err := ap.ProvideGeneric()
if err != nil {
return err
}
@@ -183,14 +248,14 @@ func (ap *ApiProvider) RefreshUsers(ctx context.Context) error {
func (ap *ApiProvider) RefreshChannels(ctx context.Context) error {
if data, err := ioutil.ReadFile(ap.channelsCache); err == nil {
var cachedChannels []slack.Channel
var cachedChannels []Channel
if err := json.Unmarshal(data, &cachedChannels); err != nil {
log.Printf("Failed to unmarshal %s: %v; will refetch", ap.usersCache, err)
log.Printf("Failed to unmarshal %s: %v; will refetch", cachedChannels, err)
} else {
for _, c := range cachedChannels {
ap.channels[c.ID] = c
}
log.Printf("Loaded %d channels from cache %q", len(cachedChannels), ap.usersCache)
log.Printf("Loaded %d channels from cache %q", len(cachedChannels), ap.channelsCache)
return nil
}
}
@@ -210,7 +275,7 @@ func (ap *ApiProvider) RefreshChannels(ctx context.Context) error {
return nil
}
func (ap *ApiProvider) GetChannels(ctx context.Context, channelTypes []string) []slack.Channel {
func (ap *ApiProvider) GetChannels(ctx context.Context, channelTypes []string) []Channel {
if len(channelTypes) == 0 {
channelTypes = AllChanTypes
}
@@ -220,33 +285,82 @@ func (ap *ApiProvider) GetChannels(ctx context.Context, channelTypes []string) [
Limit: 999,
ExcludeArchived: true,
}
var (
total int
chans1 []slack.Channel
chans2 []slack2.Channel
chans []Channel
nextcur string
err error
)
client, err := ap.Provide()
clientGeneric, err := ap.ProvideGeneric()
if err != nil {
return nil
}
for {
var chans []slack.Channel
clientE, err := ap.ProvideEnterprise()
if err != nil {
return nil
}
chans, nextcur, err = client.GetConversationsContext(ctx, params)
if err != nil {
break
lim := limiter.Tier2boost.Limiter()
for {
if ap.authResponse.EnterpriseID == "" {
chans1, nextcur, err = clientGeneric.GetConversationsContext(ctx, params)
if err != nil {
break
}
for _, channel := range chans1 {
ch := mapChannel(
channel.ID,
channel.Name,
channel.NameNormalized,
channel.Topic.Value,
channel.Purpose.Value,
channel.User,
channel.Members,
channel.NumMembers,
channel.IsIM,
channel.IsMpIM,
channel.IsPrivate,
ap.ProvideUsersMap(),
)
chans = append(chans, ch)
}
if err := lim.Wait(ctx); err != nil {
return nil
}
} else {
chans2, _, err = clientE.GetConversationsContext(ctx, nil)
if err != nil {
break
}
for _, channel := range chans2 {
ch := mapChannel(
channel.ID,
channel.Name,
channel.NameNormalized,
channel.Topic.Value,
channel.Purpose.Value,
channel.User,
channel.Members,
channel.NumMembers,
channel.IsIM,
channel.IsMpIM,
channel.IsPrivate,
ap.ProvideUsersMap(),
)
chans = append(chans, ch)
}
if err := lim.Wait(ctx); err != nil {
return nil
}
}
if l := len(chans); l > 0 {
for _, channel := range chans {
ap.channels[channel.ID] = channel
ap.channelsInv["#"+channel.Name] = channel.ID
}
total += l
params.Limit -= l
for _, ch := range chans {
ap.channels[ch.ID] = ch
ap.channelsInv["#"+ch.Name] = ch.ID
}
if nextcur == "" {
@@ -257,7 +371,7 @@ func (ap *ApiProvider) GetChannels(ctx context.Context, channelTypes []string) [
params.Cursor = nextcur
}
var res []slack.Channel
var res []Channel
for _, t := range channelTypes {
for _, channel := range ap.channels {
if t == "public_channel" && !channel.IsPrivate {
@@ -289,66 +403,15 @@ func (ap *ApiProvider) ProvideChannelsMaps() *ChannelsCache {
}
}
func withHTTPClientOption(cookie string) func(c *slack.Client) {
func withHTTPClientOption(cookies []*http.Cookie) func(c *slack.Client) {
return func(c *slack.Client) {
var proxy func(*http.Request) (*url.URL, error)
if proxyURL := os.Getenv("SLACK_MCP_PROXY"); proxyURL != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
log.Fatalf("Failed to parse proxy URL: %v", err)
}
slack.OptionHTTPClient(provideHTTPClient(cookies))(c)
}
}
proxy = http.ProxyURL(parsed)
} else {
proxy = nil
}
rootCAs, _ := x509.SystemCertPool()
if rootCAs == nil {
rootCAs = x509.NewCertPool()
}
if localCertFile := os.Getenv("SLACK_MCP_SERVER_CA"); localCertFile != "" {
certs, err := ioutil.ReadFile(localCertFile)
if err != nil {
log.Fatalf("Failed to append %q to RootCAs: %v", localCertFile, err)
}
if ok := rootCAs.AppendCertsFromPEM(certs); !ok {
log.Println("No certs appended, using system certs only")
}
}
insecure := false
if os.Getenv("SLACK_MCP_SERVER_CA_INSECURE") != "" {
if localCertFile := os.Getenv("SLACK_MCP_SERVER_CA"); localCertFile != "" {
log.Fatalf("Variable SLACK_MCP_SERVER_CA is at the same time with SLACK_MCP_SERVER_CA_INSECURE")
}
insecure = true
}
customHTTPTransport := &http.Transport{
Proxy: proxy,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecure,
RootCAs: rootCAs,
},
}
userAgent := defaultUA
if os.Getenv("SLACK_MCP_USER_AGENT") != "" {
userAgent = os.Getenv("SLACK_MCP_USER_AGENT")
}
client := &http.Client{
Transport: transport.New(
customHTTPTransport,
userAgent,
cookie,
),
}
slack.OptionHTTPClient(client)(c)
func withHTTPClientEdgeOption(cookies []*http.Cookie) func(c *edge.Client) {
return func(c *edge.Client) {
edge.OptionHTTPClient(provideHTTPClient(cookies))(c)
}
}
@@ -357,3 +420,115 @@ func withTeamEndpointOption(url string) slack.Option {
slack.OptionAPIURL(url + "api/")(c)
}
}
func provideHTTPClient(cookies []*http.Cookie) *http.Client {
var proxy func(*http.Request) (*url.URL, error)
if proxyURL := os.Getenv("SLACK_MCP_PROXY"); proxyURL != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
log.Fatalf("Failed to parse proxy URL: %v", err)
}
proxy = http.ProxyURL(parsed)
} else {
proxy = nil
}
rootCAs, _ := x509.SystemCertPool()
if rootCAs == nil {
rootCAs = x509.NewCertPool()
}
if localCertFile := os.Getenv("SLACK_MCP_SERVER_CA"); localCertFile != "" {
certs, err := ioutil.ReadFile(localCertFile)
if err != nil {
log.Fatalf("Failed to append %q to RootCAs: %v", localCertFile, err)
}
if ok := rootCAs.AppendCertsFromPEM(certs); !ok {
log.Println("No certs appended, using system certs only")
}
}
insecure := false
if os.Getenv("SLACK_MCP_SERVER_CA_INSECURE") != "" {
if localCertFile := os.Getenv("SLACK_MCP_SERVER_CA"); localCertFile != "" {
log.Fatalf("Variable SLACK_MCP_SERVER_CA is at the same time with SLACK_MCP_SERVER_CA_INSECURE")
}
insecure = true
}
customHTTPTransport := &http.Transport{
Proxy: proxy,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecure,
RootCAs: rootCAs,
},
}
userAgent := defaultUA
if os.Getenv("SLACK_MCP_USER_AGENT") != "" {
userAgent = os.Getenv("SLACK_MCP_USER_AGENT")
}
client := &http.Client{
Transport: transport.New(
customHTTPTransport,
userAgent,
cookies,
),
}
return client
}
func mapChannel(
id, name, nameNormalized, topic, purpose, user string,
members []string,
numMembers int,
isIM, isMpIM, isPrivate bool,
usersMap map[string]slack.User,
) Channel {
channelName := "#" + name
finalPurpose := purpose
finalTopic := topic
finalMemberCount := numMembers
if isIM {
finalMemberCount = 2
if u, ok := usersMap[user]; ok {
channelName = "@" + u.Name
finalPurpose = "DM with " + u.RealName
} else {
channelName = "@" + user
finalPurpose = "DM with " + user
}
finalTopic = ""
} else if isMpIM {
if len(members) > 0 {
finalMemberCount = len(members)
var userNames []string
for _, uid := range members {
if u, ok := usersMap[uid]; ok {
userNames = append(userNames, u.RealName)
} else {
userNames = append(userNames, uid)
}
}
channelName = "@" + nameNormalized
finalPurpose = "Group DM with " + strings.Join(userNames, ", ")
finalTopic = ""
}
}
return Channel{
ID: id,
Name: channelName,
Topic: finalTopic,
Purpose: finalPurpose,
MemberCount: finalMemberCount,
IsIM: isIM,
IsMpIM: isMpIM,
IsPrivate: isPrivate,
}
}
+147
View File
@@ -0,0 +1,147 @@
package edge
import (
"context"
"runtime/trace"
"github.com/korotovsky/slack-mcp-server/pkg/limiter"
"github.com/korotovsky/slack-mcp-server/pkg/provider/edge/fasttime"
"github.com/rusq/slack"
)
// client.* API
type clientCountsForm struct {
BaseRequest
ThreadCountsByChannel bool `json:"thread_counts_by_channel"`
OrgWideAware bool `json:"org_wide_aware"`
IncludeFileChannels bool `json:"include_file_channels"`
WebClientFields
}
type ClientCountsResponse struct {
baseResponse
Channels []ChannelSnapshot `json:"channels,omitempty"`
MPIMs []ChannelSnapshot `json:"mpims,omitempty"`
IMs []ChannelSnapshot `json:"ims,omitempty"`
}
type ChannelSnapshot struct {
ID string `json:"id"`
LastRead fasttime.Time `json:"last_read"`
Latest fasttime.Time `json:"latest"`
HistoryInvalid fasttime.Time `json:"history_invalid"`
MentionCount int `json:"mention_count"`
HasUnreads bool `json:"has_unreads"`
}
func (cl *Client) ClientCounts(ctx context.Context) (ClientCountsResponse, error) {
ctx, task := trace.NewTask(ctx, "ClientCounts")
defer task.End()
form := clientCountsForm{
BaseRequest: BaseRequest{Token: cl.token},
ThreadCountsByChannel: true,
OrgWideAware: true,
IncludeFileChannels: true,
WebClientFields: webclientReason("client-counts-api/fetchClientCounts"),
}
resp, err := cl.PostForm(ctx, "client.counts", values(form, true))
if err != nil {
return ClientCountsResponse{}, err
}
r := ClientCountsResponse{}
if err := cl.ParseResponse(&r, resp); err != nil {
return ClientCountsResponse{}, err
}
return r, nil
}
type clientDMsForm struct {
BaseRequest
Count int `json:"count"`
IncludeClosed bool `json:"include_closed"`
IncludeChannel bool `json:"include_channel"`
ExcludeBots bool `json:"exclude_bots"`
Cursor string `json:"cursor,omitempty"`
WebClientFields
}
type clientDMsResponse struct {
baseResponse
IMs []ClientDM `json:"ims,omitempty"`
MPIMs []ClientDM `json:"mpims,omitempty"` //TODO
}
type ClientDM struct {
ID string `json:"id"`
// Message slack.Message `json:"message,omitempty"`
Channel IM `json:"channel,omitempty"`
Latest fasttime.Time `json:"latest,omitempty"` // i.e. "1710632873.037269"
}
type IM struct {
ID string `json:"id"`
Created slack.JSONTime `json:"created"`
IsFrozen bool `json:"is_frozen"`
IsArchived bool `json:"is_archived"`
IsIM bool `json:"is_im"`
IsOrgShared bool `json:"is_org_shared"`
ContextTeamID string `json:"context_team_id"`
Updated slack.JSONTime `json:"updated"`
User string `json:"user"`
LastRead fasttime.Time `json:"last_read"`
Latest fasttime.Time `json:"latest"`
IsOpen bool `json:"is_open"`
}
func (c IM) SlackChannel() slack.Channel {
return slack.Channel{
GroupConversation: slack.GroupConversation{
Conversation: slack.Conversation{
ID: c.ID,
Created: c.Created,
IsIM: c.IsIM,
IsOrgShared: c.IsOrgShared,
User: c.User,
LastRead: c.LastRead.SlackString(),
},
IsArchived: c.IsArchived,
},
}
}
func (cl *Client) ClientDMs(ctx context.Context) ([]ClientDM, error) {
form := clientDMsForm{
BaseRequest: BaseRequest{Token: cl.token},
Count: 250,
IncludeClosed: true,
IncludeChannel: true,
ExcludeBots: false,
Cursor: "",
WebClientFields: webclientReason("dms-tab-populate"),
}
lim := limiter.Tier2boost.Limiter()
var IMs []ClientDM
for {
resp, err := cl.PostFormRaw(ctx, cl.webapiURL("client.dms"), values(form, true))
if err != nil {
return nil, err
}
r := clientDMsResponse{}
if err := cl.ParseResponse(&r, resp); err != nil {
return nil, err
}
IMs = append(IMs, r.IMs...)
if r.ResponseMetadata.NextCursor == "" {
break
}
form.Cursor = r.ResponseMetadata.NextCursor
if err := lim.Wait(ctx); err != nil {
return nil, err
}
}
return IMs, nil
}
+611
View File
@@ -0,0 +1,611 @@
package edge
import (
"context"
"encoding/json"
"runtime/trace"
"time"
"github.com/korotovsky/slack-mcp-server/pkg/provider/edge/fasttime"
"github.com/rusq/slack"
)
// client.userBoot API. It was too large to chuck into the client.* file.
type clientUserBootForm struct {
BaseRequest
MinChannelUpdated int64 `json:"min_channel_updated"`
IncludeMinVersionBumpCheck int `json:"include_min_version_bump_check"`
VersionTS int64 `json:"version_ts"`
BuildVersionTS int64 `json:"build_version_ts"`
WebClientFields
}
// ClientUserBoot calls the client.userBoot API.
func (cl *Client) ClientUserBoot(ctx context.Context) (*ClientUserBootResponse, error) {
ctx, task := trace.NewTask(ctx, "ClientUserBoot")
defer task.End()
future := time.Now().Add(24 * time.Hour)
form := clientUserBootForm{
BaseRequest: BaseRequest{Token: cl.token},
IncludeMinVersionBumpCheck: 1,
VersionTS: future.Unix(),
BuildVersionTS: future.Unix(),
WebClientFields: webclientReason("initial-data"),
}
var ub ClientUserBootResponse
resp, err := cl.PostForm(ctx, "client.userBoot", values(form, true))
if err != nil {
return nil, err
}
if err := cl.ParseResponse(&ub, resp); err != nil {
return nil, err
}
return &ub, nil
}
func UnmarshalClientUserBootResponse(data []byte) (ClientUserBootResponse, error) {
var r ClientUserBootResponse
err := json.Unmarshal(data, &r)
return r, err
}
func (r *ClientUserBootResponse) Marshal() ([]byte, error) {
return json.Marshal(r)
}
// "client.userBoot"
type ClientUserBootResponse struct {
baseResponse
Self Self `json:"self"`
Team Team `json:"team"`
IMs []IM `json:"ims"`
Workspaces []Workspace `json:"workspaces"`
DefaultWorkspace string `json:"default_workspace"`
AccountTypes AccountTypes `json:"account_types"`
AcceptTosURL any `json:"accept_tos_url"`
IsOpen []string `json:"is_open"`
IsEurope bool `json:"is_europe"`
TranslationsCacheTs fasttime.Time `json:"translations_cache_ts"`
EmojiCacheTs fasttime.Time `json:"emoji_cache_ts"`
AppCommandsCacheTs fasttime.Time `json:"app_commands_cache_ts"`
CacheTsVersion string `json:"cache_ts_version"`
DND DND `json:"dnd"`
Prefs map[string]any `json:"prefs"`
Subteams Subteams `json:"subteams"`
MobileAppRequiresUpgrade bool `json:"mobile_app_requires_upgrade"`
Starred []any `json:"starred"`
ChannelsPriority ChannelsPriority `json:"channels_priority"`
ReadOnlyChannels []string `json:"read_only_channels"`
NonThreadableChannels []any `json:"non_threadable_channels"`
ThreadOnlyChannels []any `json:"thread_only_channels"`
Channels []UserBootChannel `json:"channels"`
UnchangedChannelIDS []any `json:"unchanged_channel_ids"`
CacheVersion string `json:"cache_version"`
SlackRoute string `json:"slack_route"`
AuthMinLastFetched int64 `json:"auth_min_last_fetched"`
CanAccessClientV2 bool `json:"can_access_client_v2"`
ShouldReload bool `json:"should_reload"`
ClientMinVersion int64 `json:"client_min_version"`
BuildVersionEnabled bool `json:"build_version_enabled"`
Links Links `json:"links"`
}
type UserBootChannel struct {
ID string `json:"id"`
Name string `json:"name"`
IsChannel bool `json:"is_channel"`
IsGroup bool `json:"is_group"`
IsIM bool `json:"is_im"`
IsMpim bool `json:"is_mpim"`
IsPrivate bool `json:"is_private"`
Created int64 `json:"created"`
IsArchived bool `json:"is_archived"`
IsGeneral bool `json:"is_general"`
Unlinked int64 `json:"unlinked"`
NameNormalized string `json:"name_normalized"`
IsShared bool `json:"is_shared"`
IsFrozen bool `json:"is_frozen"`
IsOrgShared bool `json:"is_org_shared"`
IsPendingEXTShared bool `json:"is_pending_ext_shared"`
PendingShared []json.RawMessage `json:"pending_shared"`
ContextTeamID string `json:"context_team_id"`
Updated int64 `json:"updated"`
ParentConversation json.RawMessage `json:"parent_conversation"`
Creator string `json:"creator"`
IsEXTShared bool `json:"is_ext_shared"`
SharedTeamIDS []string `json:"shared_team_ids"`
PendingConnectedTeamIDS []json.RawMessage `json:"pending_connected_team_ids"`
Topic Purpose `json:"topic"`
Purpose Purpose `json:"purpose"`
Properties *Properties `json:"properties,omitempty"`
PreviousNames []json.RawMessage `json:"previous_names"`
IsMember bool `json:"is_member,omitempty"`
LastRead fasttime.Time `json:"last_read,omitempty"`
Latest fasttime.Time `json:"latest,omitempty"`
IsOpen bool `json:"is_open,omitempty"`
Members []string `json:"members"`
}
func (c *UserBootChannel) SlackChannel() slack.Channel {
return slack.Channel{
GroupConversation: slack.GroupConversation{
Conversation: slack.Conversation{
ID: c.ID,
Created: slack.JSONTime(c.Created),
IsOpen: c.IsOpen,
LastRead: c.LastRead.SlackString(),
Latest: &slack.Message{},
UnreadCount: 0,
UnreadCountDisplay: 0,
IsGroup: c.IsGroup,
IsShared: c.IsShared,
IsIM: c.IsIM,
IsExtShared: c.IsEXTShared,
IsOrgShared: c.IsOrgShared,
IsGlobalShared: false,
IsPendingExtShared: c.IsPendingEXTShared,
IsPrivate: c.IsPrivate,
IsMpIM: c.IsMpim,
Unlinked: int(c.Unlinked),
NameNormalized: c.NameNormalized,
NumMembers: len(c.Members),
Priority: 0,
User: "",
ConnectedTeamIDs: []string{},
SharedTeamIDs: []string{},
InternalTeamIDs: []string{},
},
Name: c.Name,
Creator: c.Creator,
IsArchived: c.IsArchived,
Members: c.Members,
Topic: slack.Topic{
Value: c.Topic.Value,
Creator: c.Topic.Creator,
LastSet: slack.JSONTime(c.Topic.LastSet),
},
Purpose: slack.Purpose{
Value: c.Purpose.Value,
Creator: c.Purpose.Creator,
LastSet: slack.JSONTime(c.Purpose.LastSet),
},
},
IsChannel: c.IsChannel,
IsGeneral: c.IsGeneral,
IsMember: c.IsMember,
Locale: "",
}
}
type AccountTypes struct {
IsAdmin []any `json:"is_admin"`
IsOwner []any `json:"is_owner"`
IsPrimaryOwner []any `json:"is_primary_owner"`
}
type Properties struct {
PostingRestrictedTo SlackConnectAllowedWorkspaces `json:"posting_restricted_to"`
}
type SlackConnectAllowedWorkspaces struct {
Type []string `json:"type"`
}
type Purpose struct {
Value string `json:"value"`
Creator string `json:"creator"`
LastSet int64 `json:"last_set"`
}
type ChannelsPriority struct {
}
type DND struct {
DNDEnabled bool `json:"dnd_enabled"`
NextDNDStartTs slack.JSONTime `json:"next_dnd_start_ts"`
NextDNDEndTs slack.JSONTime `json:"next_dnd_end_ts"`
SnoozeEnabled bool `json:"snooze_enabled"`
}
type Links struct {
DomainsTs int64 `json:"domains_ts"`
}
type Self struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
Deleted bool `json:"deleted"`
Color string `json:"color"`
RealName string `json:"real_name"`
Tz string `json:"tz"`
TzLabel string `json:"tz_label"`
TzOffset int64 `json:"tz_offset"`
Profile Profile `json:"profile"`
IsAdmin bool `json:"is_admin"`
IsOwner bool `json:"is_owner"`
IsPrimaryOwner bool `json:"is_primary_owner"`
IsRestricted bool `json:"is_restricted"`
IsUltraRestricted bool `json:"is_ultra_restricted"`
IsBot bool `json:"is_bot"`
IsAppUser bool `json:"is_app_user"`
Updated slack.JSONTime `json:"updated"`
IsEmailConfirmed bool `json:"is_email_confirmed"`
WhoCanShareContactCard string `json:"who_can_share_contact_card"`
FirstLogin slack.JSONTime `json:"first_login"`
LobSalesHomeEnabled bool `json:"lob_sales_home_enabled"`
ManualPresence string `json:"manual_presence"`
}
type Profile1 struct {
Title string `json:"title"`
Phone string `json:"phone"`
Skype string `json:"skype"`
RealName string `json:"real_name"`
RealNameNormalized string `json:"real_name_normalized"`
DisplayName string `json:"display_name"`
DisplayNameNormalized string `json:"display_name_normalized"`
Fields any `json:"fields"`
StatusText string `json:"status_text"`
StatusEmoji string `json:"status_emoji"`
StatusEmojiDisplayInfo []any `json:"status_emoji_display_info"`
StatusExpiration int64 `json:"status_expiration"`
AvatarHash string `json:"avatar_hash"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
StatusTextCanonical string `json:"status_text_canonical"`
Team string `json:"team"`
}
type Subteams struct {
Self []any `json:"self"`
}
type Team struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Domain string `json:"domain"`
EmailDomain string `json:"email_domain"`
Icon Icon `json:"icon"`
AvatarBaseURL string `json:"avatar_base_url"`
IsVerified bool `json:"is_verified"`
Plan string `json:"plan"`
IsPlanFrozen bool `json:"is_plan_frozen"`
Prefs Prefs `json:"prefs"`
OnboardingChannelID string `json:"onboarding_channel_id"`
ImageProxyURL string `json:"image_proxy_url"`
OverStorageLimit bool `json:"over_storage_limit"`
MessagesCount int64 `json:"messages_count"`
LobSalesHomeEnabled bool `json:"lob_sales_home_enabled"`
}
type Icon struct {
ImageDefault bool `json:"image_default"`
Image34 string `json:"image_34"`
Image44 string `json:"image_44"`
Image68 string `json:"image_68"`
Image88 string `json:"image_88"`
Image102 string `json:"image_102"`
Image230 string `json:"image_230"`
Image132 string `json:"image_132"`
}
type Prefs struct {
Locale string `json:"locale"`
InvitesOnlyAdmins bool `json:"invites_only_admins"`
InvitesLimit bool `json:"invites_limit"`
ShowJoinLeave bool `json:"show_join_leave"`
DefaultChannels []string `json:"default_channels"`
Image34 string `json:"image_34"`
Image44 string `json:"image_44"`
Image68 string `json:"image_68"`
Image88 string `json:"image_88"`
Image102 string `json:"image_102"`
Image132 string `json:"image_132"`
Image230 string `json:"image_230"`
ImageOriginal string `json:"image_original"`
SeenInvitesOnlyAdminsWarning bool `json:"seen_invites_only_admins_warning"`
WhoCanAtEveryone string `json:"who_can_at_everyone"`
WhoCanAtChannel string `json:"who_can_at_channel"`
WhoCanPostGeneral string `json:"who_can_post_general"`
WarnBeforeAtChannel string `json:"warn_before_at_channel"`
WhoCanCreateChannels string `json:"who_can_create_channels"`
WhoCanArchiveChannels string `json:"who_can_archive_channels"`
WhoCanCreateGroups string `json:"who_can_create_groups"`
WhoCanKickChannels string `json:"who_can_kick_channels"`
WhoCanKickGroups string `json:"who_can_kick_groups"`
InvitedUserPreset InvitedUserPreset `json:"invited_user_preset"`
WelcomePlaceEnabled bool `json:"welcome_place_enabled"`
HasInstalledApps bool `json:"has_installed_apps"`
WhoCanManageChannelPostingPrefs string `json:"who_can_manage_channel_posting_prefs"`
AdminCustomizedQuickReactions []string `json:"admin_customized_quick_reactions"`
AllUsersCanPurchase bool `json:"all_users_can_purchase"`
AllowAdminRetentionOverride int64 `json:"allow_admin_retention_override"`
AllowAudioClipSharingSlackConnect bool `json:"allow_audio_clip_sharing_slack_connect"`
AllowAudioClips bool `json:"allow_audio_clips"`
AllowBoxCfs bool `json:"allow_box_cfs"`
AllowCalls bool `json:"allow_calls"`
AllowCallsInteractiveScreenSharing bool `json:"allow_calls_interactive_screen_sharing"`
AllowClipDownloads string `json:"allow_clip_downloads"`
AllowContentReview bool `json:"allow_content_review"`
AllowDeveloperSandboxes string `json:"allow_developer_sandboxes"`
AllowFreeAutomatedTrials bool `json:"allow_free_automated_trials"`
AllowHuddles bool `json:"allow_huddles"`
AllowHuddlesTranscriptions bool `json:"allow_huddles_transcriptions"`
AllowHuddlesVideo bool `json:"allow_huddles_video"`
AllowLists string `json:"allow_lists"`
AllowLockThread bool `json:"allow_lock_thread"`
AllowMediaTranscriptions bool `json:"allow_media_transcriptions"`
AllowMessageDeletion bool `json:"allow_message_deletion"`
AllowNativeGIFPicker bool `json:"allow_native_gif_picker"`
AllowRetentionOverride bool `json:"allow_retention_override"`
AllowSpaceship string `json:"allow_spaceship"`
AllowSponsoredSlackConnections bool `json:"allow_sponsored_slack_connections"`
AllowVideoClipSharingSlackConnect bool `json:"allow_video_clip_sharing_slack_connect"`
AllowVideoClips bool `json:"allow_video_clips"`
AppDirOnly bool `json:"app_dir_only"`
AppManagementApps []any `json:"app_management_apps"`
AppWhitelistEnabled bool `json:"app_whitelist_enabled"`
AppWhitelistRequestsRequireCommentEnabled bool `json:"app_whitelist_requests_require_comment_enabled"`
AtlasOrgChartsAccess string `json:"atlas_org_charts_access"`
AtlasProfilesAccess string `json:"atlas_profiles_access"`
AutomaticWelcomeDmEnabled bool `json:"automatic_welcome_dm_enabled"`
BillingEmailDaily bool `json:"billing_email_daily"`
BillingEmailMonthly bool `json:"billing_email_monthly"`
BlockFileDownload bool `json:"block_file_download"`
BlockFileTypes bool `json:"block_file_types"`
BoxAppInstalled bool `json:"box_app_installed"`
CallsApps CallsApps `json:"calls_apps"`
CallsLocations []any `json:"calls_locations"`
CanAcceptSlackConnectChannelInvites bool `json:"can_accept_slack_connect_channel_invites"`
CanCreateExternalLimitedInvite bool `json:"can_create_external_limited_invite"`
CanCreateSlackConnectChannelInvite bool `json:"can_create_slack_connect_channel_invite"`
CanReceiveSharedChannelsInvites bool `json:"can_receive_shared_channels_invites"`
CanvasRetentionDuration int64 `json:"canvas_retention_duration"`
CanvasRetentionType int64 `json:"canvas_retention_type"`
CanvasVersionHistoryEnabled bool `json:"canvas_version_history_enabled"`
ChannelAuditExportEnabled bool `json:"channel_audit_export_enabled"`
ChannelEmailAddressesEnabled bool `json:"channel_email_addresses_enabled"`
ComplianceExportStart int64 `json:"compliance_export_start"`
ContentReviewEnabled bool `json:"content_review_enabled"`
CreatedWithGoogle bool `json:"created_with_google"`
CustomContactEmail any `json:"custom_contact_email"`
CustomStatusDefaultEmoji string `json:"custom_status_default_emoji"`
CustomStatusPresets [][]string `json:"custom_status_presets"`
DailyPromptsEnabled bool `json:"daily_prompts_enabled"`
DefaultChannelCreationEnabled bool `json:"default_channel_creation_enabled"`
DefaultCreatePrivateChannel bool `json:"default_create_private_channel"`
DefaultFunctionReuseVisibility DefaultFunctionReuseVisibility `json:"default_function_reuse_visibility"`
DefaultRxns []string `json:"default_rxns"`
DisableEmailIngestion bool `json:"disable_email_ingestion"`
DisableFileDeleting bool `json:"disable_file_deleting"`
DisableFileEditing bool `json:"disable_file_editing"`
DisableFileUploads string `json:"disable_file_uploads"`
DisablePrivacyAndCookiePolicy bool `json:"disable_privacy_and_cookie_policy"`
DisableSidebarConnectPrompts []any `json:"disable_sidebar_connect_prompts"`
DisableSidebarInstallPrompts []any `json:"disable_sidebar_install_prompts"`
DisallowPublicFileUrls bool `json:"disallow_public_file_urls"`
Discoverable string `json:"discoverable"`
DisplayAnniversaryCelebration bool `json:"display_anniversary_celebration"`
DisplayDefaultPhone bool `json:"display_default_phone"`
DisplayEmailAddresses bool `json:"display_email_addresses"`
DisplayExternalEmailAddresses bool `json:"display_external_email_addresses"`
DisplayNewHireCelebration bool `json:"display_new_hire_celebration"`
DisplayPronouns bool `json:"display_pronouns"`
DisplayRealNames bool `json:"display_real_names"`
DmRetentionDuration int64 `json:"dm_retention_duration"`
DmRetentionRedactionDuration int64 `json:"dm_retention_redaction_duration"`
DmRetentionType int64 `json:"dm_retention_type"`
DNDAfterFriday string `json:"dnd_after_friday"`
DNDAfterMonday string `json:"dnd_after_monday"`
DNDAfterSaturday string `json:"dnd_after_saturday"`
DNDAfterSunday string `json:"dnd_after_sunday"`
DNDAfterThursday string `json:"dnd_after_thursday"`
DNDAfterTuesday string `json:"dnd_after_tuesday"`
DNDAfterWednesday string `json:"dnd_after_wednesday"`
DNDBeforeFriday string `json:"dnd_before_friday"`
DNDBeforeMonday string `json:"dnd_before_monday"`
DNDBeforeSaturday string `json:"dnd_before_saturday"`
DNDBeforeSunday string `json:"dnd_before_sunday"`
DNDBeforeThursday string `json:"dnd_before_thursday"`
DNDBeforeTuesday string `json:"dnd_before_tuesday"`
DNDBeforeWednesday string `json:"dnd_before_wednesday"`
DNDDays string `json:"dnd_days"`
DNDEnabled bool `json:"dnd_enabled"`
DNDEnabledFriday string `json:"dnd_enabled_friday"`
DNDEnabledMonday string `json:"dnd_enabled_monday"`
DNDEnabledSaturday string `json:"dnd_enabled_saturday"`
DNDEnabledSunday string `json:"dnd_enabled_sunday"`
DNDEnabledThursday string `json:"dnd_enabled_thursday"`
DNDEnabledTuesday string `json:"dnd_enabled_tuesday"`
DNDEnabledWednesday string `json:"dnd_enabled_wednesday"`
DNDEndHour string `json:"dnd_end_hour"`
DNDStartHour string `json:"dnd_start_hour"`
DNDWeekdaysOffAllday bool `json:"dnd_weekdays_off_allday"`
DropboxLegacyPicker bool `json:"dropbox_legacy_picker"`
EmojiOnlyAdmins bool `json:"emoji_only_admins"`
EnableConnectDmEarlyAccess bool `json:"enable_connect_dm_early_access"`
EnableDomainAllowlistForCea bool `json:"enable_domain_allowlist_for_cea"`
EnableInfoBarriers bool `json:"enable_info_barriers"`
EnableMpdmToPrivateChannelConversion bool `json:"enable_mpdm_to_private_channel_conversion"`
EnableSharedChannels int64 `json:"enable_shared_channels"`
EnterpriseDefaultChannels []any `json:"enterprise_default_channels"`
EnterpriseHasCorporateExports bool `json:"enterprise_has_corporate_exports"`
EnterpriseIntuneEnabled bool `json:"enterprise_intune_enabled"`
EnterpriseJointeamRequests any `json:"enterprise_jointeam_requests"`
EnterpriseMandatoryChannels []any `json:"enterprise_mandatory_channels"`
EnterpriseMdmDateEnabled int64 `json:"enterprise_mdm_date_enabled"`
EnterpriseMdmDisableFileDownload bool `json:"enterprise_mdm_disable_file_download"`
EnterpriseMdmLevel int64 `json:"enterprise_mdm_level"`
EnterpriseMdmToken string `json:"enterprise_mdm_token"`
EnterpriseMobileDeviceCheck bool `json:"enterprise_mobile_device_check"`
EnterpriseTeamCreationRequest EnterpriseTeamCreationRequest `json:"enterprise_team_creation_request"`
EXTAuditLogRetentionDuration int64 `json:"ext_audit_log_retention_duration"`
EXTAuditLogRetentionType int64 `json:"ext_audit_log_retention_type"`
FileLimitWhitelisted bool `json:"file_limit_whitelisted"`
FileRetentionDuration int64 `json:"file_retention_duration"`
FileRetentionType int64 `json:"file_retention_type"`
FilepickerAppFirstInstall bool `json:"filepicker_app_first_install"`
FlagContentAdminDash bool `json:"flag_content_admin_dash"`
FlagMessageUsersToNotify []any `json:"flag_message_users_to_notify"`
GdprEnabled bool `json:"gdpr_enabled"`
GdriveEnabledTeam bool `json:"gdrive_enabled_team"`
GroupRetentionDuration int64 `json:"group_retention_duration"`
GroupRetentionType int64 `json:"group_retention_type"`
HasComplianceExport bool `json:"has_compliance_export"`
HasHipaaCompliance bool `json:"has_hipaa_compliance"`
HasSeenPartnerPromo bool `json:"has_seen_partner_promo"`
HasSharedInvites bool `json:"has_shared_invites"`
HermesAllowInteractionsWithWorkflowsOwnedBySlackConnectedTeams bool `json:"hermes_allow_interactions_with_workflows_owned_by_slack_connected_teams"`
HermesHasAcceptedTos bool `json:"hermes_has_accepted_tos"`
HermesTriggersTrippableBySlackConnectedTeams bool `json:"hermes_triggers_trippable_by_slack_connected_teams"`
HideGsuiteInviteOption bool `json:"hide_gsuite_invite_option"`
HidePersonOptOut bool `json:"hide_person_opt_out"`
HideReferers bool `json:"hide_referers"`
IdentityLinksPrefs EnterpriseTeamCreationRequest `json:"identity_links_prefs"`
ImageDefault bool `json:"image_default"`
InstantSlackEnabled bool `json:"instant_slack_enabled"`
InviteRequestsEnabled bool `json:"invite_requests_enabled"`
LoadingOnlyAdmins bool `json:"loading_only_admins"`
LoudChannelMentionsLimit int64 `json:"loud_channel_mentions_limit"`
MagicUnfurlsEnabled bool `json:"magic_unfurls_enabled"`
MemberAnalyticsDisabled bool `json:"member_analytics_disabled"`
MlOptOut bool `json:"ml_opt_out"`
MobilePasscodeTimeoutInSeconds int64 `json:"mobile_passcode_timeout_in_seconds"`
MobileSessionDuration int64 `json:"mobile_session_duration"`
MsgEditWindowMins int64 `json:"msg_edit_window_mins"`
NoEmailUserProvisionType string `json:"no_email_user_provision_type"`
NotificationRedactionType string `json:"notification_redaction_type"`
NotifyPendingEnabled bool `json:"notify_pending_enabled"`
NTLMCredentialDomains string `json:"ntlm_credential_domains"`
OnedriveAppInstalled bool `json:"onedrive_app_installed"`
OnedriveEnabledTeam bool `json:"onedrive_enabled_team"`
PremiumWorkflowNotifications PremiumWorkflowNotifications `json:"premium_workflow_notifications"`
PrivateChannelAnalyticsDisabled bool `json:"private_channel_analytics_disabled"`
PrivateChannelMembershipLimit int64 `json:"private_channel_membership_limit"`
PrivateRetentionRedactionDuration int64 `json:"private_retention_redaction_duration"`
PublicRetentionRedactionDuration int64 `json:"public_retention_redaction_duration"`
ReceivedEscRouteToChannelAwarenessMessage bool `json:"received_esc_route_to_channel_awareness_message"`
RetentionDuration int64 `json:"retention_duration"`
RetentionType int64 `json:"retention_type"`
RichPreviewsDefault string `json:"rich_previews_default"`
SamlEnable bool `json:"saml_enable"`
SearchFeedbackOptOut bool `json:"search_feedback_opt_out"`
SelfServeSelect bool `json:"self_serve_select"`
SessionDuration int64 `json:"session_duration"`
SessionDurationType int64 `json:"session_duration_type"`
ShowLegacyPaidBenefitsPage bool `json:"show_legacy_paid_benefits_page"`
ShowLegacyWorkflows bool `json:"show_legacy_workflows"`
ShowMobilePromos bool `json:"show_mobile_promos"`
SignInWithSlackDefault string `json:"sign_in_with_slack_default"`
SignInWithSlackDisabled bool `json:"sign_in_with_slack_disabled"`
SingleUserExports bool `json:"single_user_exports"`
SlackAIDailyRecapOptOut bool `json:"slack_ai_daily_recap_opt_out"`
SlackAIDetailedFeedbackOptOut bool `json:"slack_ai_detailed_feedback_opt_out"`
SlackAISearchSuggestedQueries []any `json:"slack_ai_search_suggested_queries"`
SlackConnectAccountVisibility string `json:"slack_connect_account_visibility"`
SlackConnectAllowedWorkspaces SlackConnectAllowedWorkspaces `json:"slack_connect_allowed_workspaces"`
SlackConnectApprovalType string `json:"slack_connect_approval_type"`
SlackConnectDmOnlyVerifiedOrgs bool `json:"slack_connect_dm_only_verified_orgs"`
SlackConnectFileUploadSharingEnabled bool `json:"slack_connect_file_upload_sharing_enabled"`
SlackbotResponsesDisabled bool `json:"slackbot_responses_disabled"`
SlackbotResponsesOnlyAdmins bool `json:"slackbot_responses_only_admins"`
SpaceshipWorkspaceSettingVisible bool `json:"spaceship_workspace_setting_visible"`
SsoChangeEmail bool `json:"sso_change_email"`
SsoChooseUsername bool `json:"sso_choose_username"`
SsoDisableEmails bool `json:"sso_disable_emails"`
SsoOptional bool `json:"sso_optional"`
SsoSignupRestrictions int64 `json:"sso_signup_restrictions"`
SsoSyncWithProvider bool `json:"sso_sync_with_provider"`
StatsOnlyAdmins bool `json:"stats_only_admins"`
SubteamsAutoCreateAdmin bool `json:"subteams_auto_create_admin"`
SubteamsAutoCreateOwner bool `json:"subteams_auto_create_owner"`
ThornSaferScan bool `json:"thorn_safer_scan"`
TwoFactorAuthRequired int64 `json:"two_factor_auth_required"`
TwoFactorPreventSMS int64 `json:"two_factor_prevent_sms"`
TwoFactorRequired bool `json:"two_factor_required"`
UneditableUserProfileFields []any `json:"uneditable_user_profile_fields"`
UseBrowserPicker bool `json:"use_browser_picker"`
UseWorkspaceIconForSingleWorkspaceUsers bool `json:"use_workspace_icon_for_single_workspace_users"`
UsesCustomizedCustomStatusPresets bool `json:"uses_customized_custom_status_presets"`
WarnUserBeforeLogoutDesktop bool `json:"warn_user_before_logout_desktop"`
WarnUserBeforeLogoutMobile bool `json:"warn_user_before_logout_mobile"`
WfbDefaultConnectorVisibility string `json:"wfb_default_connector_visibility"`
WhoCanAcceptSlackConnectChannelInvites SlackConnectAllowedWorkspaces `json:"who_can_accept_slack_connect_channel_invites"`
WhoCanChangeTeamProfile string `json:"who_can_change_team_profile"`
WhoCanCreateChannelEmailAddresses SlackConnectAllowedWorkspaces `json:"who_can_create_channel_email_addresses"`
WhoCanCreateDeleteUserGroups string `json:"who_can_create_delete_user_groups"`
WhoCanCreateExternalLimitedInvite SlackConnectAllowedWorkspaces `json:"who_can_create_external_limited_invite"`
WhoCanCreateSharedChannels string `json:"who_can_create_shared_channels"`
WhoCanCreateSlackConnectChannelInvite SlackConnectAllowedWorkspaces `json:"who_can_create_slack_connect_channel_invite"`
WhoCanCreateWorkflows SlackConnectAllowedWorkspaces `json:"who_can_create_workflows"`
WhoCanDmAnyone SlackConnectAllowedWorkspaces `json:"who_can_dm_anyone"`
WhoCanEditUserGroups string `json:"who_can_edit_user_groups"`
WhoCanManageEXTSharedChannels SlackConnectAllowedWorkspaces `json:"who_can_manage_ext_shared_channels"`
WhoCanManageGuests SlackConnectAllowedWorkspaces `json:"who_can_manage_guests"`
WhoCanManageIntegrations SlackConnectAllowedWorkspaces `json:"who_can_manage_integrations"`
WhoCanManagePrivateChannels WhoCanManageP `json:"who_can_manage_private_channels"`
WhoCanManagePrivateChannelsAtWorkspaceLevel WhoCanManageP `json:"who_can_manage_private_channels_at_workspace_level"`
WhoCanManagePublicChannels WhoCanManageP `json:"who_can_manage_public_channels"`
WhoCanManageSharedChannels SlackConnectAllowedWorkspaces `json:"who_can_manage_shared_channels"`
WhoCanPostInSharedChannels SlackConnectAllowedWorkspaces `json:"who_can_post_in_shared_channels"`
WhoCanRequestEXTSharedChannels SlackConnectAllowedWorkspaces `json:"who_can_request_ext_shared_channels"`
WhoCanReviewFlaggedContent SlackConnectAllowedWorkspaces `json:"who_can_review_flagged_content"`
WhoCanUseHermes SlackConnectAllowedWorkspaces `json:"who_can_use_hermes"`
WhoCanViewMessageActivity WhoCanViewMessageActivity `json:"who_can_view_message_activity"`
WhoHasTeamVisibility string `json:"who_has_team_visibility"`
WorkflowBuilderEnabled bool `json:"workflow_builder_enabled"`
WorkflowExtensionStepsBetaOptIn bool `json:"workflow_extension_steps_beta_opt_in"`
WorkflowExtensionStepsEnabled bool `json:"workflow_extension_steps_enabled"`
WorkflowsExportCSVEnabled bool `json:"workflows_export_csv_enabled"`
WorkflowsWebhookTriggerEnabled bool `json:"workflows_webhook_trigger_enabled"`
AuthMode string `json:"auth_mode"`
}
type CallsApps struct {
Video []any `json:"video"`
Audio []any `json:"audio"`
}
type DefaultFunctionReuseVisibility struct {
Visibility string `json:"visibility"`
}
type EnterpriseTeamCreationRequest struct {
IsEnabled bool `json:"is_enabled"`
}
type InvitedUserPreset struct {
EnableInvitedUser bool `json:"enable_invited_user"`
}
type PremiumWorkflowNotifications struct {
NotificationsEnabled bool `json:"notifications_enabled"`
NotificationLocation string `json:"notification_location"`
}
type WhoCanManageP struct {
User []any `json:"user"`
Type []string `json:"type"`
}
type WhoCanViewMessageActivity struct {
Type []string `json:"type"`
ChannelType []string `json:"channel_type"`
}
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Domain string `json:"domain"`
EmailDomain string `json:"email_domain"`
Icon Icon `json:"icon"`
AvatarBaseURL string `json:"avatar_base_url"`
IsVerified bool `json:"is_verified"`
Prefs Prefs `json:"prefs"`
}
+115
View File
@@ -0,0 +1,115 @@
package edge
import (
"context"
"encoding/json"
"runtime/trace"
"github.com/rusq/slack"
)
// conversations.* API
// conversationsGenericInfoForm is the request to conversations.genericInfo
type conversationsGenericInfoForm struct {
BaseRequest
UpdatedChannels string `json:"updated_channels"` // i.e. {"C065H568ZAT":0}
WebClientFields
}
type conversationsGenericInfoResponse struct {
baseResponse
Channels []slack.Channel `json:"channels"`
UnchangedChannelIDs []string `json:"unchanged_channel_ids"`
}
func (cl *Client) ConversationsGenericInfo(ctx context.Context, channelID ...string) ([]slack.Channel, error) {
ctx, task := trace.NewTask(ctx, "ConversationsGenericInfo")
defer task.End()
trace.Logf(ctx, "params", "channelID=%v", channelID)
updChannel := make(map[string]int, len(channelID))
for _, id := range channelID {
updChannel[id] = 0
}
b, err := json.Marshal(updChannel)
if err != nil {
return nil, err
}
form := conversationsGenericInfoForm{
BaseRequest: BaseRequest{
Token: cl.token,
},
UpdatedChannels: string(b),
WebClientFields: webclientReason("fallback:UnknownFetchManager"),
}
resp, err := cl.PostForm(ctx, "conversations.genericInfo", values(form, true))
if err != nil {
return nil, err
}
var r conversationsGenericInfoResponse
if err := cl.ParseResponse(&r, resp); err != nil {
return nil, err
}
return r.Channels, nil
}
type conversationsViewForm struct {
BaseRequest
CanonicalAvatars bool `json:"canonical_avatars"`
NoUserProfile bool `json:"no_user_profile"`
IgnoreReplies bool `json:"ignore_replies"`
NoSelf bool `json:"no_self"`
IncludeFullUsers bool `json:"include_full_users"`
IncludeUseCases bool `json:"include_use_cases"`
IncludeStories bool `json:"include_stories"`
NoMembers bool `json:"no_members"`
IncludeMutationTimestamps bool `json:"include_mutation_timestamps"`
Count int `json:"count"`
Channel string `json:"channel"`
IncludeFreeTeamExtraMessages bool `json:"include_free_team_extra_messages"`
WebClientFields
}
type ConversationsViewResponse struct {
Users []User `json:"users"`
IM IM `json:"im"`
Emojis map[string]string `json:"emojis"`
// we don't care about the rest of the response
}
func (cl *Client) ConversationsView(ctx context.Context, channelID string) (ConversationsViewResponse, error) {
ctx, task := trace.NewTask(ctx, "ConversationsView")
defer task.End()
trace.Logf(ctx, "params", "channelID=%v", channelID)
form := conversationsViewForm{
BaseRequest: BaseRequest{
Token: cl.token,
},
CanonicalAvatars: true,
NoUserProfile: true,
IgnoreReplies: true,
NoSelf: true,
IncludeFullUsers: false,
IncludeUseCases: false,
IncludeStories: false,
NoMembers: true,
IncludeMutationTimestamps: false,
Count: 50,
Channel: channelID,
WebClientFields: webclientReason(""),
}
resp, err := cl.PostForm(ctx, "conversations.view", values(form, true))
if err != nil {
return ConversationsViewResponse{}, err
}
var r = struct {
baseResponse
ConversationsViewResponse
}{}
if err := cl.ParseResponse(&r, resp); err != nil {
return ConversationsViewResponse{}, err
}
return r.ConversationsViewResponse, nil
}
+62
View File
@@ -0,0 +1,62 @@
package edge
import (
"context"
"runtime/trace"
"github.com/korotovsky/slack-mcp-server/pkg/limiter"
)
// im.* API
type imListForm struct {
BaseRequest
GetLatest bool `json:"get_latest"`
GetReadState bool `json:"get_read_state"`
Cursor string `json:"cursor,omitempty"`
WebClientFields
}
type imListResponse struct {
baseResponse
IMs []IM `json:"ims,omitempty"`
}
func (cl *Client) IMList(ctx context.Context) ([]IM, error) {
ctx, task := trace.NewTask(ctx, "IMList")
defer task.End()
form := imListForm{
BaseRequest: BaseRequest{Token: cl.token},
GetLatest: true,
GetReadState: true,
WebClientFields: WebClientFields{
XReason: "guided-search-people-empty-state",
XMode: "online",
XSonic: true,
XAppName: "client",
},
Cursor: "",
}
lim := limiter.Tier2boost.Limiter()
var IMs []IM
for {
resp, err := cl.PostForm(ctx, "im.list", values(form, true))
if err != nil {
return nil, err
}
r := imListResponse{}
if err := cl.ParseResponse(&r, resp); err != nil {
return nil, err
}
IMs = append(IMs, r.IMs...)
if r.ResponseMetadata.NextCursor == "" {
break
}
form.Cursor = r.ResponseMetadata.NextCursor
if err := lim.Wait(ctx); err != nil {
return nil, err
}
}
return IMs, nil
}
+403
View File
@@ -0,0 +1,403 @@
// Package edge provides a limited implementation of undocumented Slack Edge
// API necessary to get the data from a slack workspace.
//
// It handles rate limit retries internally (but only once).
package edge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"runtime/trace"
"strings"
"time"
"github.com/rusq/slack"
"github.com/rusq/slackauth"
"github.com/rusq/slackdump/v3/auth"
"github.com/rusq/tagops"
)
type httpClient interface {
Do(*http.Request) (*http.Response, error)
}
type Client struct {
// cl is the http client to use
cl httpClient
// edgeAPI is the edge API endpoint
edgeAPI string
// webclientAPI is the webclient APIs endpoint
webclientAPI string
// token is the slack token
token string
// teamID is the team ID
teamID string
tape io.WriteCloser
}
type Option func(*Client)
func WithTape(tape io.WriteCloser) Option {
return func(cl *Client) {
cl.tape = tape
}
}
// OptionHTTPClient - provide a custom http client to the slack client.
func OptionHTTPClient(client httpClient) func(*Client) {
return func(cl *Client) {
cl.cl = client
}
}
var (
ErrNoTeamID = errors.New("teamID is empty")
ErrNoToken = errors.New("token is empty")
)
func NewWithClient(workspaceName string, teamID string, token string, cl *http.Client, opt ...Option) (*Client, error) {
if teamID == "" {
return nil, ErrNoTeamID
}
if token == "" {
return nil, ErrNoToken
}
tape, err := os.Create("tape.txt")
if err != nil {
return nil, err
}
return &Client{
cl: cl,
token: token,
teamID: teamID,
webclientAPI: fmt.Sprintf("https://%s.slack.com/api/", workspaceName),
edgeAPI: fmt.Sprintf("https://edgeapi.slack.com/cache/%s/", teamID),
tape: tape,
}, nil
}
func NewWithToken(ctx context.Context, token string, cookies []*http.Cookie) (*Client, error) {
if token == "" {
return nil, ErrNoToken
}
prov, err := auth.NewValueCookiesAuth(token, cookies)
if err != nil {
return nil, err
}
return New(ctx, prov)
}
type nopTape struct{}
func (nopTape) Write(p []byte) (n int, err error) {
return len(p), nil
}
func (nopTape) Close() error {
return nil
}
// NewWithInfo is the same as New, but doesn't call the AuthTest on
// initialisation. Caller must ensure that the token is valid.
func NewWithInfo(info *slack.AuthTestResponse, prov auth.Provider, opt ...Option) (*Client, error) {
hcl, err := prov.HTTPClient()
if err != nil {
return nil, err
}
c := &Client{
cl: hcl,
token: prov.SlackToken(),
teamID: info.TeamID,
webclientAPI: info.URL + "api/",
edgeAPI: fmt.Sprintf("https://edgeapi.slack.com/cache/%s/", info.TeamID),
tape: nopTape{},
}
for _, o := range opt {
o(c)
}
return c, nil
}
func New(ctx context.Context, prov auth.Provider, opt ...Option) (*Client, error) {
info, err := prov.Test(ctx)
if err != nil {
return nil, err
}
return NewWithInfo(info, prov, opt...)
}
func (cl *Client) Raw() httpClient {
return cl.cl
}
func (cl *Client) Close() error {
if cl.tape != nil {
return cl.tape.Close()
}
return nil
}
type BaseRequest struct {
Token string `json:"token"`
}
type baseResponse struct {
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
ResponseMetadata ResponseMetadata `json:"response_metadata,omitempty"`
}
func (r baseResponse) validate(ep string) error {
if !r.Ok {
return &APIError{Err: r.Error, Metadata: r.ResponseMetadata, Endpoint: ep}
}
return nil
}
type ResponseMetadata struct {
Messages []string `json:"messages,omitempty"`
NextCursor string `json:"next_cursor,omitempty"`
}
func (r *BaseRequest) SetToken(token string) {
r.Token = token
}
func (r *BaseRequest) IsTokenSet() bool {
return len(r.Token) > 0
}
type PostRequest interface {
SetToken(string)
IsTokenSet() bool
}
const (
hdrContentType = "Content-Type"
)
// PostJSON posts a request to the edge API. The request is marshalled to
// JSON and the response is unmarshalled to the req, which must be a pointer
// to a struct.
func (cl *Client) PostJSON(ctx context.Context, path string, req PostRequest) (*http.Response, error) {
if !req.IsTokenSet() {
req.SetToken(cl.token)
}
data, err := json.Marshal(req)
if err != nil {
return nil, err
}
tape := cl.recorder(bytes.NewReader(data))
defer cl.record([]byte("\n\n"))
r, err := http.NewRequestWithContext(ctx, http.MethodPost, cl.edgeAPI+path, tape)
if err != nil {
return nil, err
}
r.Header.Set(hdrContentType, "application/json")
return do(ctx, cl.cl, r)
}
// callEdgeAPI calls the edge API.
func (cl *Client) callEdgeAPI(ctx context.Context, v any, endpoint string, req PostRequest) error {
r, err := cl.PostJSON(ctx, endpoint, req)
if err != nil && !errors.Is(err, io.EOF) {
return err
}
return cl.ParseResponse(v, r)
}
// PostForm sends a POST request to a webclient API, it marshals the form
// values to url.Values, omitting empty fields, and sends the request.
func (cl *Client) Post(ctx context.Context, path string, a any) (*http.Response, error) {
return cl.PostFormRaw(ctx, cl.webclientAPI+path, values(a, true))
}
// PostForm sends a POST request to a webclient API with form values.
func (cl *Client) PostForm(ctx context.Context, path string, form url.Values) (*http.Response, error) {
return cl.PostFormRaw(ctx, cl.webclientAPI+path, form)
}
func (cl *Client) record(b []byte) {
if cl.tape != nil {
if _, err := cl.tape.Write(b); err != nil {
slog.Default().Error("error writing to tape", "error", err)
}
}
}
func (cl *Client) PostFormRaw(ctx context.Context, url string, form url.Values) (*http.Response, error) {
if form["token"] == nil {
form.Set("token", cl.token)
}
r := cl.recorder(strings.NewReader(form.Encode()))
defer cl.record([]byte("\n\n"))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, r)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return do(ctx, cl.cl, req)
}
func (cl *Client) ParseResponse(req any, r *http.Response) error {
if r.StatusCode < http.StatusOK || http.StatusMultipleChoices <= r.StatusCode {
return fmt.Errorf("error: status code: %s", r.Status)
}
defer r.Body.Close()
// Read the body into a byte slice
bodyBytes, err := io.ReadAll(cl.recorder(r.Body))
if err != nil {
return err
}
// Convert body to string and print it
bodyStr := string(bodyBytes)
fmt.Println("Raw response body:", bodyStr)
// Decode the body into the expected struct
if err := json.Unmarshal(bodyBytes, req); err != nil {
return err
}
return nil
}
// do is a helper function to do the request and handle rate limiting. it
// tries to handle the rate limiting by waiting for the Retry-After only once,
// if it receives another rate limit error, it returns slack.RateLimitedError
// to let the caller handle it.
func do(ctx context.Context, cl httpClient, req *http.Request) (*http.Response, error) {
ctx, task := trace.NewTask(ctx, "edge.do")
defer task.End()
lg := slog.Default()
req.Header.Set("Accept-Language", "en-NZ,en-AU;q=0.9,en;q=0.8")
req.Header.Set("User-Agent", slackauth.DefaultUserAgent)
rgn := trace.StartRegion(ctx, "http.Do")
resp, err := cl.Do(req)
rgn.End()
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests {
wait, err := parseRetryAfter(resp)
if err != nil {
return nil, err
}
lg.InfoContext(ctx, "got rate limited, waiting", "delay", wait)
time.Sleep(wait)
resp, err = cl.Do(req)
if err != nil {
return nil, err
}
// if we are still rate limited, then we are in trouble
if resp.StatusCode == http.StatusTooManyRequests {
lg.DebugContext(ctx, "edge.do: did my best, but still rate limited, giving up, not my problem")
wait, err = parseRetryAfter(resp)
if err != nil {
return nil, err
}
return nil, &slack.RateLimitedError{RetryAfter: wait}
}
}
if resp.StatusCode < http.StatusOK || http.StatusMultipleChoices <= resp.StatusCode {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, slack.StatusCodeError{Code: resp.StatusCode, Status: string(body)}
}
return resp, err
}
func parseRetryAfter(resp *http.Response) (time.Duration, error) {
strWait := resp.Header.Get("Retry-After")
if strWait == "" {
return 0, errors.New("got rate limited, but did not get a Retry-After header")
}
wait, err := time.ParseDuration(strWait + "s")
if err != nil {
return 0, err
}
return wait, nil
}
// values returns url.Values from a struct. If omitempty is true, then the
// empty values are omitted for the fields that have the `omitempty` tag.
func values(s any, omitempty bool) url.Values {
var v = make(url.Values)
m := tagops.ToMap(s, "json", omitempty, true)
for k, val := range m {
v.Set(k, fmt.Sprint(val))
}
return v
}
func (cl *Client) webapiURL(endpoint string) string {
return cl.webclientAPI + endpoint
}
type APIError struct {
Err string
Metadata ResponseMetadata
Endpoint string
}
func (e *APIError) Error() string {
if len(e.Metadata.Messages) > 0 {
return e.Err + ": " + e.Metadata.Messages[0]
}
return e.Err
}
type WebClientFields struct {
XReason string `json:"_x_reason,omitempty"`
XMode string `json:"_x_mode,omitempty"`
XSonic bool `json:"_x_sonic"`
XAppName string `json:"_x_app_name"`
}
func webclientReason(reason string) WebClientFields {
return WebClientFields{
XReason: reason,
XMode: "online",
XSonic: true,
XAppName: "client",
}
}
func (cl *Client) recorder(r io.Reader) io.Reader {
if cl.tape == nil {
return r
}
return io.TeeReader(r, cl.tape)
}
// Pagination contains the pagination information. It is truly fucked, Slack
// does not allow to seek past Page 100, when page > 100 requested, Slack
// returns the first page (Page=1). Seems to be an internal limitation. The
// workaround would be to use the Query parameter, to be more specific about
// the channel names, but to get all channels, this would require iterating
// through all 65536 runes of unicode give or take the special characters.
//
// For now, this doesn't work as a replacement for conversation.list (202403).
type Pagination struct {
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
PerPage int `json:"per_page"`
PageCount int `json:"page_count"`
First int64 `json:"first"`
Last int64 `json:"last"`
NextCursor string `json:"next_cursor,omitempty"`
}
+55
View File
@@ -0,0 +1,55 @@
package fasttime
import (
"errors"
"strconv"
"strings"
"time"
)
type Time time.Time
func (t *Time) UnmarshalJSON(data []byte) error {
clean := strings.Trim(string(data), `"`)
if clean == "" || clean == "null" {
*t = Time{}
return nil
}
ts, err := TS2int(clean)
if err != nil {
return err
}
*t = Time(Int2Time(ts))
return nil
}
func (t Time) MarshalJSON() ([]byte, error) {
ts := time.Time(t).UnixMicro()
return []byte(`"` + Int2TS(ts) + `"`), nil
}
// SlackString returns the time as a slack timestamp (i.e. "1234567890.123456").
func (t Time) SlackString() string {
return Int2TS(time.Time(t).UnixMicro())
}
var ErrNotATimestamp = errors.New("not a slack timestamp")
// Int2TS converts an int64 to a slack timestamp by inserting a dot in the
// right place.
func Int2TS(ts int64) string {
const cut = 6
s := strconv.FormatInt(ts, 10)
l := len(s)
if l < cut+1 {
return ""
}
lo := s[l-cut:]
hi := s[:l-cut]
return hi + "." + lo
}
// Int2Time converts an int64 to a time.Time.
func Int2Time(ts int64) time.Time {
return time.UnixMicro(ts)
}
@@ -0,0 +1,25 @@
//go:build 386
package fasttime
import (
"fmt"
"strconv"
"strings"
)
// int size on the 32-bit systems is 32 bit (surprise), this constraints us to slower 64-bit implementation.
// TS2int converts a slack timestamp to an int64 by stripping the dot and
// converting the string to an int64. It is useful for fast comparison.
func TS2int(ts string) (int64, error) {
if ts == "" {
return 0, nil
}
i := strings.IndexByte(ts, '.')
if i == -1 {
return 0, fmt.Errorf("%w: %q", ErrNotATimestamp, ts)
}
val, err := strconv.ParseInt(ts[:i]+ts[i+1:], 10, 64)
return int64(val), err
}
+143
View File
@@ -0,0 +1,143 @@
package fasttime
import (
"math"
"reflect"
"strconv"
"testing"
"time"
)
func TestTs2int(t *testing.T) {
type args struct {
ts string
}
tests := []struct {
name string
args args
want int64
wantErr bool
}{
{
"valid ts",
args{"1638494510.037400"},
1638494510037400,
false,
},
{
"invalid ts",
args{"x"},
0,
true,
},
{
"real ts",
args{"1674255434.388009"},
1674255434388009,
false,
},
{
"maxint64",
args{strconv.FormatInt(math.MaxInt64/1000, 10) + ".000"},
math.MaxInt64 / 1000 * 1000,
false,
},
{
"no dot",
args{"1674255434"},
0,
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := TS2int(tt.args.ts)
if (err != nil) != tt.wantErr {
t.Errorf("TS2Int64() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("TS2Int64() = %v, want %v", got, tt.want)
}
})
}
}
func TestInt2ts(t *testing.T) {
type args struct {
ts int64
}
tests := []struct {
name string
args args
want string
}{
{
"valid ts",
args{1638494510037400},
"1638494510.037400",
},
{
"real ts",
args{1674255434388009},
"1674255434.388009",
},
{
"zero",
args{0},
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Int2TS(tt.args.ts); got != tt.want {
t.Errorf("Int642TS() = %v, want %v", got, tt.want)
}
})
}
}
func TestInt2Time(t *testing.T) {
type args struct {
ts int64
}
tests := []struct {
name string
args args
want time.Time
}{
{
"valid ts",
args{1645054781942979},
time.Unix(1645054781, 942979000),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Int2Time(tt.args.ts); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Int2Time() = %v, want %v", got, tt.want)
}
})
}
}
func BenchmarkTs2Int(b *testing.B) {
var n int64
for i := 0; i < b.N; i++ {
n, _ = TS2int("1638494510.037400")
}
if n != 1638494510037400 {
b.Errorf("Expected 1638494510037400, got %d", n)
}
}
func BenchmarkInt2Ts(b *testing.B) {
var s string
for b.Loop() {
s = Int2TS(1638494510037400)
}
if s != "1638494510.037400" {
b.Errorf("Expected 1638494510.037400, got %s", s)
}
}
@@ -0,0 +1,23 @@
//go:build !386
package fasttime
import (
"fmt"
"strconv"
"strings"
)
// TS2int converts a slack timestamp to an int64 by stripping the dot and
// converting the string to an int64. It is useful for fast comparison.
func TS2int(ts string) (int64, error) {
if ts == "" {
return 0, nil
}
i := strings.IndexByte(ts, '.')
if i == -1 {
return 0, fmt.Errorf("%w: %q", ErrNotATimestamp, ts)
}
val, err := strconv.Atoi(ts[:i] + ts[i+1:])
return int64(val), err
}
+191
View File
@@ -0,0 +1,191 @@
package edge
import (
"context"
"encoding/json"
"errors"
"log/slog"
"runtime/trace"
"github.com/google/uuid"
"github.com/korotovsky/slack-mcp-server/pkg/limiter"
"github.com/rusq/slack"
)
// search.* API
const perPage = 100
var ErrPagination = errors.New("pagination fault")
type Channel struct {
slack.GroupConversation
IsChannel bool `json:"is_channel"`
IsGeneral bool `json:"is_general"`
IsMember bool `json:"is_member"`
NumMembers int `json:"member_count"`
Locale string `json:"locale"`
Properties *slack.Properties `json:"properties"`
}
type SearchResponse[T any] struct {
baseResponse
Module string `json:"module"`
Query string `json:"query"`
Filters json.RawMessage `json:"filters"`
Pagination Pagination `json:"pagination"`
Items []T `json:"items"`
}
// searchForm is the form to be sent to the search endpoint.
type searchForm struct {
BaseRequest
Cursor string `json:"cursor,omitempty"`
Module string `json:"module"`
Query string `json:"query"`
Page int `json:"page,omitempty"`
ClientReqID string `json:"client_req_id"`
BrowseID string `json:"browse_session_id"`
Extracts int `json:"extracts"`
Highlight int `json:"highlight"`
ExtraMsg int `json:"extra_message_data"`
NoUserProfile int `json:"no_user_profile"`
Count int `json:"count"`
FileTitleOnly bool `json:"file_title_only"`
QueryRewriteDisabled bool `json:"query_rewrite_disabled"`
IncludeFilesShares int `json:"include_files_shares"`
Browse string `json:"browse"`
SearchContext string `json:"search_context"`
MaxFilterSuggestions int `json:"max_filter_suggestions"`
Sort searchSortType `json:"sort"`
SortDir searchSortDir `json:"sort_dir"`
ChannelType searchChannelType `json:"channel_type"`
ExcludeMyChannels int `json:"exclude_my_channels"`
SearchOnlyMyChannels bool `json:"search_only_my_channels"`
RecommendSource string `json:"recommend_source"`
WebClientFields
}
type searchChannelType string
const (
sctPublic searchChannelType = "public"
sctPrivate searchChannelType = "private"
scpArchived searchChannelType = "archived"
scpExternalShared searchChannelType = "external_shared"
scpExcludeArchived searchChannelType = "exclude_archived"
scpPrivateExclude searchChannelType = "private_exclude"
scpAll searchChannelType = ""
)
type searchSortDir string
const (
ssdEmpty searchSortDir = ""
ssdAsc searchSortDir = "asc"
ssdDesc searchSortDir = "desc"
)
type searchSortType string
const (
sstRecommended searchSortType = "recommended"
sstName searchSortType = "name"
)
func (cl *Client) SearchChannels(ctx context.Context, query string) ([]slack.Channel, error) {
ctx, task := trace.NewTask(ctx, "SearchChannels")
defer task.End()
lg := slog.With("in", "SearchChannels", "query", query)
trace.Logf(ctx, "params", "query=%q", query)
clientReq, err := uuid.NewRandom()
if err != nil {
return nil, err
}
browseID, err := uuid.NewRandom()
if err != nil {
return nil, err
}
form := searchForm{
BaseRequest: BaseRequest{Token: cl.token},
Module: "channels",
Query: query,
Page: 0,
ClientReqID: clientReq.String(),
BrowseID: browseID.String(),
Extracts: 0,
Highlight: 0,
Cursor: "*",
ExtraMsg: 0,
NoUserProfile: 1,
Count: perPage,
FileTitleOnly: false,
QueryRewriteDisabled: false,
IncludeFilesShares: 1,
Browse: "standard",
SearchContext: "desktop_channel_browser",
MaxFilterSuggestions: 10,
Sort: sstName,
SortDir: ssdAsc,
ChannelType: scpAll,
ExcludeMyChannels: 0,
SearchOnlyMyChannels: false,
RecommendSource: "channel-browser",
WebClientFields: WebClientFields{
XReason: "browser-query",
XMode: "online",
XSonic: true,
XAppName: "client",
},
}
const ep = "search.modules.channels"
lim := limiter.Tier2boost.Limiter()
var cc []slack.Channel
for {
resp, err := cl.PostForm(ctx, ep, values(form, true))
if err != nil {
return nil, err
}
var sr SearchResponse[Channel]
if err := cl.ParseResponse(&sr, resp); err != nil {
return nil, err
}
if err := sr.validate(ep); err != nil {
return nil, err
}
// fix for the members count, mapping is incorrect in the slack.Channel
// if the object is being used for search.modules.channels ep
for _, c := range sr.Items {
obj := slack.Channel{
GroupConversation: c.GroupConversation,
IsChannel: true,
IsGeneral: c.IsGeneral,
IsMember: c.IsMember,
Locale: c.Locale,
Properties: c.Properties,
}
obj.NumMembers = c.NumMembers
if obj.NumMembers == 0 {
obj.IsArchived = true
}
cc = append(cc, obj)
}
if sr.Pagination.NextCursor == "" {
lg.Debug("no more channels")
break
}
lg.DebugContext(ctx, "pagination", "next_cursor", sr.Pagination.NextCursor)
form.Cursor = sr.Pagination.NextCursor
if err := lim.Wait(ctx); err != nil {
return nil, err
}
}
trace.Logf(ctx, "info", "channels found=%d", len(cc))
lg.DebugContext(ctx, "channels", "count", len(cc))
return cc, nil
}
+130
View File
@@ -0,0 +1,130 @@
package edge
import (
"context"
"errors"
"sync"
"github.com/rusq/slack"
)
var ErrParameterMissing = errors.New("required parameter missing")
// High level functions that wrap low level calls to webclient API to return
// the data in the format close to the Slack API.
func (cl *Client) GetConversationsContext(ctx context.Context, _ *slack.GetConversationsParameters) (channels []slack.Channel, _ string, err error) {
type result struct {
Channels []slack.Channel
Err error
}
var resultC = make(chan result, 2)
var pipeline = []func(){
func() {
// getting client.userBoot information
ub, err := cl.ClientUserBoot(ctx)
if err != nil {
resultC <- result{Err: err}
return
}
var ch = make([]slack.Channel, 0, len(ub.Channels))
for _, c := range ub.Channels {
ch = append(ch, c.SlackChannel())
}
resultC <- result{Channels: ch, Err: err}
},
func() {
// collecting the IMs.
ims, err := cl.IMList(ctx)
var ch = make([]slack.Channel, 0, len(ims))
for _, c := range ims {
ch = append(ch, c.SlackChannel())
}
resultC <- result{Channels: ch, Err: err}
},
func() {
// collecting the channels.
ch, err := cl.SearchChannels(ctx, "")
resultC <- result{Channels: ch, Err: err}
},
}
var wg sync.WaitGroup
wg.Add(len(pipeline))
for _, f := range pipeline {
go func(f func()) {
defer wg.Done()
f()
}(f)
}
go func() {
wg.Wait()
close(resultC)
}()
// create a map of channels that we have already seen
var seenChannels = make(map[string]struct{})
for r := range resultC {
if r.Err != nil {
return nil, "", r.Err
}
for _, c := range r.Channels {
if _, seen := seenChannels[c.ID]; !seen {
seenChannels[c.ID] = struct{}{}
channels = append(channels, c)
}
}
}
// ClientCounts hopefully returns MPIM IDs that we haven't seen in the
// user boot response.
cr, err := cl.ClientCounts(ctx)
if err != nil {
return nil, "", err
}
// determine which mpims are already in the list, and which need to be
// fetched
var fetchIDs = make([]string, 0, len(cr.MPIMs))
for _, c := range cr.MPIMs {
if _, seen := seenChannels[c.ID]; !seen {
fetchIDs = append(fetchIDs, c.ID)
}
}
// getting the info on any MPIMs that we haven't seen yet.
mpims, err := cl.ConversationsGenericInfo(ctx, fetchIDs...)
if err != nil {
return nil, "", err
}
channels = append(channels, mpims...)
return channels, "", nil
}
func (cl *Client) GetUsersInConversationContext(ctx context.Context, p *slack.GetUsersInConversationParameters) (ids []string, _ string, err error) {
if p.ChannelID == "" {
return nil, "", ErrParameterMissing
}
uu, err := cl.UsersList(ctx, p.ChannelID)
if err != nil {
return nil, "", err
}
for _, u := range uu {
ids = append(ids, u.ID)
}
return ids, "", nil
}
var ErrNotFound = errors.New("not found")
func (cl *Client) GetConversationInfoContext(ctx context.Context, input *slack.GetConversationInfoInput) (*slack.Channel, error) {
cc, err := cl.ConversationsGenericInfo(ctx, input.ChannelID)
if err != nil {
return nil, err
}
if len(cc) == 0 {
return nil, ErrNotFound
}
return &cc[0], nil
}
+294
View File
@@ -0,0 +1,294 @@
package edge
import (
"context"
"errors"
"github.com/korotovsky/slack-mcp-server/pkg/limiter"
"github.com/rusq/slack"
"golang.org/x/sync/errgroup"
)
type UsersListRequest struct {
BaseRequest
Channels []string `json:"channels"`
PresentFirst bool `json:"present_first,omitempty"`
Filter string `json:"filter"`
Index string `json:"index,omitempty"`
Locale string `json:"locale,omitempty"`
IncludeProfileOnlyUsers bool `json:"include_profile_only_users,omitempty"`
Marker string `json:"marker,omitempty"` // pagination, it must contain the next_marker from the previous response
Count int `json:"count"`
}
type UsersListResponse struct {
Results []User `json:"results"`
NextMarker string `json:"next_marker"` // pagination, marker value which must be used in the next request, if not empty.
baseResponse
}
type User struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
Deleted bool `json:"deleted"`
Color string `json:"color"`
RealName string `json:"real_name"`
Tz string `json:"tz"`
TzLabel string `json:"tz_label"`
TzOffset int64 `json:"tz_offset"`
Profile Profile `json:"profile"`
IsAdmin bool `json:"is_admin"`
IsOwner bool `json:"is_owner"`
IsPrimaryOwner bool `json:"is_primary_owner"`
IsRestricted bool `json:"is_restricted"`
IsUltraRestricted bool `json:"is_ultra_restricted"`
IsBot bool `json:"is_bot"`
IsAppUser bool `json:"is_app_user"`
Updated slack.JSONTime `json:"updated"`
IsEmailConfirmed bool `json:"is_email_confirmed"`
WhoCanShareContactCard string `json:"who_can_share_contact_card"`
Has2Fa *bool `json:"has_2fa,omitempty"`
}
type Profile struct {
Title string `json:"title"`
Phone string `json:"phone"`
Skype string `json:"skype"`
RealName string `json:"real_name"`
RealNameNormalized string `json:"real_name_normalized"`
DisplayName string `json:"display_name"`
DisplayNameNormalized string `json:"display_name_normalized"`
Fields any `json:"fields"`
StatusText string `json:"status_text"`
StatusEmoji string `json:"status_emoji"`
StatusEmojiDisplayInfo []any `json:"status_emoji_display_info"`
StatusExpiration int64 `json:"status_expiration"`
AvatarHash string `json:"avatar_hash"`
GuestInvitedBy string `json:"guest_invited_by"`
ImageOriginal *string `json:"image_original,omitempty"`
IsCustomImage *bool `json:"is_custom_image,omitempty"`
Email string `json:"email"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
StatusTextCanonical string `json:"status_text_canonical"`
Team string `json:"team"`
}
type UsersInfoRequest struct {
BaseRequest
CheckInteraction bool `json:"check_interaction"`
IncludeProfileOnlyUsers bool `json:"include_profile_only_users"`
UpdatedIDS map[string]int64 `json:"updated_ids"`
}
type UserInfoResponse struct {
Results []UserInfo `json:"results"`
FailedIDS []string `json:"failed_ids"`
PendingIDS []string `json:"pending_ids"`
CanInteract map[string]bool `json:"can_interact"`
baseResponse
}
type UserInfo struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
Color string `json:"color"`
IsBot bool `json:"is_bot"`
IsAppUser bool `json:"is_app_user"`
Deleted bool `json:"deleted"`
Profile Profile `json:"profile"`
IsStranger bool `json:"is_stranger"`
Updated int64 `json:"updated"`
WhoCanShareContactCard string `json:"who_can_share_contact_card"`
}
var ErrNotOK = errors.New("server returned NOT OK")
// GetUsers returns users from the slack edge api for the channel. User IDs
// should be provided by the caller. If ids is empty, does nothing.
//
// This tries to replicate the logic of the Slack client, when it fetches
// the channel users while being logged in as a guest user.
func (cl *Client) GetUsers(ctx context.Context, userID ...string) ([]UserInfo, error) {
if len(userID) == 0 {
return []UserInfo{}, nil
}
updatedIds := make(map[string]int64, len(userID))
for _, id := range userID {
updatedIds[id] = 0
}
lim := limiter.Tier3.Limiter()
var users []UserInfo
for {
uiresp, err := cl.UsersInfo(ctx, &UsersInfoRequest{
CheckInteraction: true,
IncludeProfileOnlyUsers: true,
UpdatedIDS: updatedIds,
})
if err != nil {
return nil, err
}
if !uiresp.Ok {
return nil, ErrNotOK
}
if len(uiresp.Results) > 0 {
users = append(users, uiresp.Results...)
}
if len(uiresp.PendingIDS) == 0 {
break
}
for _, ui := range uiresp.Results {
updatedIds[ui.ID] = ui.Updated
}
if err := lim.Wait(ctx); err != nil {
return nil, err
}
}
return users, nil
}
// UsersInfo calls a users.info endpoint. This endpoint does not return results
// straight away. It may return "pending ids", and when it does, it should be
// called again to get the actual user info (see [Client.GetUsers]).
func (cl *Client) UsersInfo(ctx context.Context, req *UsersInfoRequest) (*UserInfoResponse, error) {
var ui UserInfoResponse
if err := cl.callEdgeAPI(ctx, &ui, "users/info", req); err != nil {
return nil, err
}
return &ui, nil
}
type ChannelsMembershipRequest struct {
BaseRequest
Channel string `json:"channel"`
Users []string `json:"users"`
AsAdmin bool `json:"as_admin"`
}
type ChannelsMembershipResponse struct {
Channel string `json:"channel"`
NonMembers []string `json:"non_members"`
baseResponse
}
// ChannelsMembership calls channels/membership endpoint.
func (cl *Client) ChannelsMembership(ctx context.Context, req *ChannelsMembershipRequest) (*ChannelsMembershipResponse, error) {
var um ChannelsMembershipResponse
if err := cl.callEdgeAPI(ctx, &um, "channels/membership", req); err != nil {
return nil, err
}
return &um, nil
}
// UserList lists users in the conversation(s).
func (cl *Client) UsersList(ctx context.Context, channelIDs ...string) ([]User, error) {
if len(channelIDs) == 0 {
return nil, errors.New("no channel IDs provided")
}
channelIDs, dmIDs := splitDMs(channelIDs)
var uu []User
eg, ctx := errgroup.WithContext(ctx)
if len(channelIDs) > 0 {
eg.Go(func() error {
u, err := cl.publicUserList(ctx, channelIDs)
if err != nil {
return err
}
uu = append(uu, u...)
return nil
})
}
if len(dmIDs) > 0 {
eg.Go(func() error {
u, err := cl.directUserList(ctx, dmIDs)
if err != nil {
return err
}
uu = append(uu, u...)
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
return uu, nil
}
func (cl *Client) publicUserList(ctx context.Context, channelIDs []string) ([]User, error) {
const (
// everyone = "everyone AND NOT bots AND NOT apps"
everyone = "everyone"
filter = "people"
index = "users_by_display_name"
count = 50
)
req := UsersListRequest{
Channels: channelIDs,
Filter: everyone,
PresentFirst: false,
Index: index,
Locale: "en-US",
Marker: "",
Count: count,
}
uu := make([]User, 0, count)
lim := limiter.Tier3.Limiter()
for {
var ur UsersListResponse
if err := cl.callEdgeAPI(ctx, &ur, "users/list", &req); err != nil {
return nil, err
}
if len(ur.Results) == 0 && ur.NextMarker == "" {
break
}
uu = append(uu, ur.Results...)
if ur.NextMarker == "" {
break
}
req.Marker = ur.NextMarker
if err := lim.Wait(ctx); err != nil {
return nil, err
}
}
return uu, nil
}
// directUserList tries to get users from the direct message channels. It is
// much slower than getting users from the public channels, as it uses
// conversations.view endpoint.
func (cl *Client) directUserList(ctx context.Context, dmIDs []string) ([]User, error) {
if len(dmIDs) == 0 {
return nil, errors.New("no direct message IDs provided")
}
var ret []User
lim := limiter.Tier3.Limiter()
for _, id := range dmIDs {
resp, err := cl.ConversationsView(ctx, id)
if err != nil {
return nil, err
}
ret = append(ret, resp.Users...)
if err := lim.Wait(ctx); err != nil {
return nil, err
}
}
return ret, nil
}
func splitDMs(IDs []string) (chans []string, dms []string) {
for _, id := range IDs {
if len(id) == 0 {
continue
}
if len(id) > 0 && id[0] == 'D' {
dms = append(dms, id)
} else {
chans = append(chans, id)
}
}
return chans, dms
}
+7 -4
View File
@@ -5,14 +5,14 @@ import "net/http"
type UserAgentTransport struct {
roundTripper http.RoundTripper
userAgent string
cookie string
cookies []*http.Cookie
}
func New(roundTripper http.RoundTripper, userAgent string, cookie string) *UserAgentTransport {
func New(roundTripper http.RoundTripper, userAgent string, cookies []*http.Cookie) *UserAgentTransport {
return &UserAgentTransport{
roundTripper: roundTripper,
userAgent: userAgent,
cookie: cookie,
cookies: cookies,
}
}
@@ -20,7 +20,10 @@ func New(roundTripper http.RoundTripper, userAgent string, cookie string) *UserA
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
clonedReq := req.Clone(req.Context())
clonedReq.Header.Set("User-Agent", t.userAgent)
clonedReq.Header.Set("Cookie", "d="+t.cookie+";d-s=1744415074")
for _, cookie := range t.cookies {
clonedReq.AddCookie(cookie)
}
return t.roundTripper.RoundTrip(clonedReq)
}