diff --git a/README.md b/README.md index e5f01f0..07196a8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deslackify/cli.py b/deslackify/cli.py index a33e9b4..1149881 100644 --- a/deslackify/cli.py +++ b/deslackify/cli.py @@ -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 @@ -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"] @@ -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", @@ -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) diff --git a/tests/test_deslackify.py b/tests/test_deslackify.py index f6f105b..edaef60 100644 --- a/tests/test_deslackify.py +++ b/tests/test_deslackify.py @@ -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])) @@ -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__