Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,66 @@ Install the `deslackify` command with [uv](https://docs.astral.sh/uv/):
uv tool install deslackify
```

## Obtaining a Slack Token
## Obtaining credentials

__Note__: Slack [discontinued legacy
tokens](https://api.slack.com/legacy/custom-integrations/legacy-tokens) in 2020,
so they can no longer be generated. Use a Slack app user token instead:
`deslackify` is meant to be run without the overhead of creating a Slack app. It
authenticates as your own browser session, using two values copied from your
logged-in Slack web client (`https://app.slack.com`):

1. Open your workspace in a browser and open the developer tools.
2. **Token** — in the __Console__, run:
```js
(d=>d.teams[Object.keys(d.teams)[0]].token)(JSON.parse(localStorage.localConfig_v2))
```
It begins with `xoxc-`. This is the `TOKEN` below.
3. **Cookie** — in __Application → Cookies → `https://app.slack.com`__, copy the
value of the `d` cookie. It begins with `xoxd-`. This is the `COOKIE` below.
(`deslackify` re-encodes it as needed, so it does not matter whether the value
is shown URL-encoded or not.)

The token and cookie must come from the same logged-in session.

### Alternative: a Slack app token

If you are able to create a Slack app, you can authenticate with a user OAuth
token instead of a browser session. It is more stable — it does not expire when
you sign out — and needs no `--cookie`:

1. Create a new app at https://api.slack.com/apps ("From scratch").
2. Under __OAuth & Permissions__, add the following __User Token Scopes__:
`search:read` and `chat:write`.
2. Under __OAuth & Permissions__, add the __User Token Scopes__ `search:read` and
`chat:write`.
3. Click __Install to Workspace__ and authorize the app.
4. Copy the __User OAuth Token__ (it begins with `xoxp-`); use it as `TOKEN`
below.
4. Copy the __User OAuth Token__ (it begins with `xoxp-`); use it as `TOKEN` and
omit `--cookie`.

## Running

```sh
deslackify --token TOKEN USERNAME
deslackify --token TOKEN --cookie COOKIE USERNAME
```

Both values can also be supplied via the `SLACK_TOKEN` and `SLACK_COOKIE`
environment variables (preferred, so the secrets do not appear in your shell
history or process list):

```sh
SLACK_TOKEN=… SLACK_COOKIE=… deslackify USERNAME
```

Or run it once without installing:

```sh
uvx deslackify --token TOKEN USERNAME
uvx deslackify --token TOKEN --cookie COOKIE USERNAME
```

Use `--dry-run` first to preview what would be deleted without deleting
anything.

By default `deslackify` will remove USERNAME's messages that are more than a
year old. You may also manually specify a `before` date via:

```sh
deslackify --token TOKEN --before YYYY-MM-DD USERNAME
deslackify --token TOKEN --cookie COOKIE --before YYYY-MM-DD USERNAME
```

__Note__: If no results are found, you may need to replace `USERNAME` with
Expand Down
39 changes: 38 additions & 1 deletion deslackify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import sys
import time
import urllib.parse
from collections import Counter
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
Expand Down Expand Up @@ -100,6 +101,34 @@ def _handle_message(
return 1


def _normalize_d_cookie(cookie: str) -> str:
"""Return the Slack ``d`` cookie value URL-encoded, as the API requires.

``cookie`` may be a bare ``d`` value or a full ``Cookie:`` header copied from a
browser request, in either the encoded (network request) or decoded (cookie
inspector) form. Slack only authenticates when ``d`` is URL-encoded, so the value is
re-encoded idempotently.

Returns:
The ``d`` cookie value, URL-encoded.

"""
value = cookie
for part in cookie.split(";"):
name, separator, candidate = part.strip().partition("=")
if separator and name == "d":
value = candidate
break
return urllib.parse.quote(urllib.parse.unquote(value), safe="")


def _session(cookie: str | None) -> Session:
session = Session()
if cookie:
session.cookies.set("d", _normalize_d_cookie(cookie), domain=".slack.com")
return session


def delete_message(slack: Slack, message: Mapping[str, Any], *, update_first: bool = False) -> None:
"""Delete ``message``, optionally overwriting its text with ``-`` beforehand."""
channel = message["channel"]["id"]
Expand Down Expand Up @@ -162,6 +191,14 @@ def main() -> int:
default=default_before.strftime("%Y-%m-%d"),
help="Date to delete messages prior to (default: %(default)s)",
)
parser.add_argument(
"--cookie",
help=(
"The Slack `d` session cookie (xoxd-...) that pairs with an xoxc- browser"
" token. This value can also be passed via the SLACK_COOKIE environment"
" variable."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
Expand Down Expand Up @@ -193,7 +230,7 @@ def main() -> int:
)
return 1

with Session() as session:
with _session(args.cookie or os.getenv("SLACK_COOKIE")) as session:
slack = cast("Slack", slacker.Slacker(token, session=session))
return run(slack, args)

Expand Down
22 changes: 22 additions & 0 deletions tests/test_deslackify.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ def test_handle_rate_limit_unsuccessful_raises():
cli.handle_rate_limit(lambda: FakeResponse(successful=False))


def test_normalize_d_cookie_encodes_decoded_value():
assert cli._normalize_d_cookie("xoxd-a/b+c") == "xoxd-a%2Fb%2Bc"


def test_normalize_d_cookie_extracts_d_from_full_header():
header = "b=hex; d=xoxd-a/b+c; d-s=123"
assert cli._normalize_d_cookie(header) == "xoxd-a%2Fb%2Bc"


def test_normalize_d_cookie_is_idempotent_on_encoded_value():
assert cli._normalize_d_cookie("xoxd-a%2Fb%2Bc") == "xoxd-a%2Fb%2Bc"


def test_run_counts_slacker_errors(monkeypatch):
message = {"channel": {"id": "C1"}, "text": "hello", "ts": "1609459200.000"}
monkeypatch.setattr(cli, "search_messages", lambda *_a, **_k: iter([message]))
Expand Down Expand Up @@ -183,5 +196,14 @@ class FakeSlack:
assert search.query == "from:bob before:x"


def test_session_sets_encoded_d_cookie():
session = cli._session("xoxd-a/b+c")
assert session.cookies.get("d") == "xoxd-a%2Fb%2Bc"


def test_session_without_cookie_sets_nothing():
assert cli._session(None).cookies.get("d") is None


def test_version():
assert deslackify.__version__