Skip to content

Diagnostic Slack

Files that uploaded fine and were shared into no channel

The nightly job uploads a CSV every morning and logs uploaded ok every morning. Nobody has ever seen one. The channel it was supposed to land in has been empty for eleven weeks, and files.list comes back with seventy-seven CSVs, all present, all owned by the app, all shared into nothing at all.

No call failed. files.completeUploadExternal registered every one of them and returned ok: true. Uploading a file and sharing a file are two things, the second one is a parameter, and the parameter was never passed.

Read-only token Python and Node.js Tests included
Cardboard boxes on wheels moving past apartment buildings.
Photo by Jimmy Liu on Unsplash
The short answer

Sharing is not a side effect of uploading. files.completeUploadExternal puts the file in the workspace, and it shares it into a conversation only if you pass channel_id. Leave that off and you get a complete, well-formed, permanently stored file that is visible to the identity that uploaded it and to nobody else. The legacy files.upload had the same split, through its channels parameter, so this survived the migration intact.

Every file records where it is shared, twice. The modern shares object has a public map and a private map keyed by conversation id; the older channels, groups and ims arrays carry the same fact split by conversation type. A file with nothing in either representation has no audience. Page files.list?count=200&types=all&user=<your bot's user id> with files:read, and count them.

The number that matters is a proportion, not a total. Seventy-seven orphans out of eighty files your app has ever uploaded is a broken upload path. Seventy-seven out of nine thousand is somebody's abandoned experiment from 2023.

The problem in plain words

What makes this one hide so well is that there is no error anywhere and no missing piece anywhere. The bytes went up. The file object came back with an id, a permalink, a mimetype and a size. Logging ok: true is honest. The app is not broken in any sense that a monitor can see, and the failure is a human one: a person opened Slack, searched for the report, and did not find it, and then assumed they had the wrong channel.

The usual route in is a refactor. An app that used files.upload with channels="#reports" gets migrated to the three-step flow, and the new final call takes channel_id instead - a different name, in a different position, requiring an id rather than a name. Someone drops it while getting the rest of the sequence working, the uploads start succeeding, and the missing parameter never comes back. The tests pass because the tests assert on ok.

The second route is deliberate and worse. A team decides to upload first and post a link afterwards, because that feels tidier than passing a channel to an upload call. Then the second step is either never written or is written to post permalink - and a permalink is a page in the Slack client that enforces the file's permissions, so everybody who clicks it gets told they do not have access to this file. The link is right there in the channel and it opens nothing. That reads as a Slack bug and is not one.

There is also a shape of this that is not a bug at all and must not be reported as one. A file shared only into a private channel has an empty channels array, because private channels live in groups and in shares.private. A file shared only into a DM has both of those empty and something in ims. A checker written against channels alone flags every one of them, produces a wall of false findings on its first run, and gets switched off in the same week. Reading both representations is not thoroughness here, it is the difference between a useful check and a noisy one.

Get an uploadURLfilename andlengthBytes go upa plain POST, notSlackComplete theuploadno channel_idok, with a fileidlogged asdeliveredShares emptyand nobody cansearch it
Uploading and sharing are two things and only one of them was asked for. The logs are honest, the file is real, and the people looking for it conclude they are searching the wrong channel.

Why it happens

Nothing errors, so the only signal is a count. There is no error string to search for and no failed call to alert on. The finding is arithmetic: how many of the files this app owns have zero share targets. That is why the script reports a proportion of the app's own output rather than a list of ids - a list of ids invites someone to fix seventy-seven rows and leave the upload path alone.

The fact is stored twice and both copies have to be read. shares.public and shares.private are the current representation; channels, groups and ims are the old one. Slack populates both, but which one is populated for a given file has varied by upload path and by age, and reading either alone is wrong in one direction or the other. The script unions them and says which bucket each share came from.

Shared into a private channel is a share. The most damaging false positive this check can produce is reporting correctly-shared private files as orphans, because it destroys trust in the run. private-only and dm-only are separate verdicts here, reported as context and never as findings.

An unshared file that is also public is the worst row on the list, and it is a different note. A file with no share targets and public_url_shared: true is unreachable inside Slack and readable by anyone on the internet holding the link. The script gives it its own verdict and points at the note that owns that problem, because the repair is a revocation rather than a share.

Posting the permalink is the repair, and it works for a reason worth knowing. Putting a file's permalink in a message to a channel makes Slack unfurl it and grants that channel's members access to the file. That is the documented way to share a file after the fact, and it is why post a link later is a plan that can work - as long as the link is posted with the bot into a real conversation, rather than pasted into a wiki.

An external file is not yours to judge. is_external files are hosted elsewhere and Slack holds a reference, not the bytes. Their sharing state is somebody else's system's problem and the script says so instead of counting them.

The fix, as a flow

There is no red box in this chain and that is the note. Every step returns ok, the file is created exactly as asked, and the failure is an absence: one parameter that was never passed, so the file has no audience and nothing anywhere reports that as a problem. The fix branch sorts audiences rather than faults, and only the top two rows are findings. The two below them are narrow audiences, and reporting those as broken is how a check like this gets switched off in its first week.

files.list, scoped to the botshares, channels, groups, imsNothing in any of themno channel_id at upload timeNothing, and a public linkunreachable inside, open outsideDirect messages onlynarrow, and not a faultA private channela real share with an empty arrayA channel, and a commentwhich is what good looks like
Both representations of a share are read together, because the legacy channels array is empty for a file in a private channel and a check built on it alone cries wolf on every correct row.

How to fix it

Page files.list, scoped to the identity that does the uploading

files.list?count=200&types=all&user=<U...>, following paging.pages. The user filter is what turns a workspace-wide inventory into an audit of your app's own output, and the id you want is the user_id from auth.test - a bot's files are owned by its bot user.

Read both representations of the share, not the convenient one

buckets merges shares.public and shares.private with the legacy channels, groups and ims arrays and returns the three buckets separately. A file is unshared only when all three come back empty.

Separate the four states that look like zero from the outside

share_state returns unshared, link-only, dm-only, private-only, shared or external. Only the first two are findings. dm-only and private-only are narrow audiences and are printed as context, because reporting them as faults is how this check gets ignored.

Read the proportion rather than the total

orphan_rate divides the unreachable files by everything the app owns. A high proportion says the upload path is missing channel_id today; a handful against thousands says somebody experimented once. The two want completely different conversations and the total on its own does not distinguish them.

Pass channel_id at upload time rather than fixing the rows

The repair the script prints is a code change: files.completeUploadExternal?files=[...]&channel_id=C0123456789, with a channel id - the new flow rejects names and user ids where the old one accepted #reports. Add initial_comment while you are there, so the file arrives with a sentence explaining it.

Share what already exists by posting its permalink

For files that are already up, the way to give them an audience is to post their permalink into a channel with the bot. Slack expands the link and the channel's members gain access. That call is a write, so the script prints it rather than making it.

How to check it worked

Re-run after the deploy. The number to watch is the proportion, and it should stop growing before it starts shrinking.

python3 slack_unshared_files.py
# identity   U07BOT9QD (reports-bot) in Northwind
# scope      files:read granted
# files      312 file(s) owned by U07BOT9QD, over 2 page(s)
# state      unshared       F08K2M4QX daily-sales.csv  no channel, no private channel and
#                           no DM. The upload finished and channel_id was never passed
# state      link-only      F08K2M55Z q3-export.csv  shared into no conversation and
#                           readable by anyone holding permalink_public
# state      private-only   F08K2M77A incident-log.txt  shared into 1 private channel
# state      shared         F08K2M91B runbook.pdf  shared into 2 conversation(s)
# verdict    77 of 312 file(s) owned by this app have no audience in Slack (24.7%)
#   repair: pass channel_id (a C id, not a name) to files.completeUploadExternal
#   repair: to share a file that already exists, post its permalink into the channel

The full code

One paginated GET and nothing else. The pure functions are the whole argument: buckets reads the two representations of a share together so that a private channel is not mistaken for an absence, share_state keeps the four zero-looking states apart, and orphan_rate turns the list into the proportion that is the actual finding. The token needs files:read; sharing needs files:write, which this script deliberately does not use.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_unshared_files.py
"""Find the files your app uploaded and gave to nobody.

Read only. One paginated GET against files.list and no writes at all: sharing a
file, revoking a link and deleting a file all need files:write, and all three
are printed here rather than performed. A bot token with files:read is enough.

The question is not whether the upload worked - it did, or the file would not be
in the list. The question is whether anything was passed to
files.completeUploadExternal to say where the file should go, and the answer
lives in the share fields of every file the app owns.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_unshared_files")

API = "https://slack.com/api/"

# The two verdicts that mean nobody in the workspace can reach the file. Kept as
# a constant because the rate and the report have to agree on what counts.
UNREACHABLE = ("unshared", "link-only")


def buckets(f):
    """Where is this file shared? Read from both representations. Pure.

    Slack records the same fact twice. The modern `shares` object carries a
    `public` map and a `private` map, each keyed by conversation id. The older
    `channels`, `groups` and `ims` arrays carry it split by conversation type:
    public channels, private channels, direct messages.

    Reading only one of them is the mistake this function exists to prevent.
    `channels` is empty for a file shared into a private channel, so a check
    built on it alone reports every correctly-shared private file as an orphan,
    produces a wall of false findings on its first run, and gets switched off.

    Returns {"public": [...], "private": [...], "dm": [...]}, sorted.
    """
    f = f or {}
    out = {"public": set(), "private": set(), "dm": set()}
    shares = f.get("shares")
    if isinstance(shares, dict):
        for name in ("public", "private"):
            group = shares.get(name)
            if isinstance(group, dict):
                out[name].update(str(k) for k in group if k)
    for name, legacy in (("public", "channels"), ("private", "groups"), ("dm", "ims")):
        for cid in f.get(legacy) or []:
            if cid:
                out[name].add(str(cid))
    return {k: sorted(v) for k, v in out.items()}


def share_targets(f):
    """Every conversation this file is shared into, deduplicated. Pure."""
    got = buckets(f)
    return sorted(set(got["public"]) | set(got["private"]) | set(got["dm"]))


def share_state(f):
    """Classify one file by the audience it actually has. Pure.

    Returns (state, detail). Four of the six states look like zero from a
    distance and only two of them are findings:

      unshared      no conversation of any kind. The file exists and nobody can
                    find it. This is the note.
      link-only     no conversation, and public_url_shared is true. Unreachable
                    inside Slack and readable by anyone on the internet, which
                    is worse and is a different repair.
      dm-only       shared, into direct messages only. A narrow audience is not
                    an absent one.
      private-only  shared, into private channels only. Also not a fault.
      shared        reachable from at least one channel.
      external      hosted somewhere else; Slack holds a link, not the bytes,
                    and the sharing question belongs to that other system.
    """
    f = f or {}
    if f.get("is_external"):
        return ("external", "hosted outside Slack, so Slack holds a reference rather "
                            "than the bytes and its share fields describe nothing")
    got = buckets(f)
    targets = sorted(set(got["public"]) | set(got["private"]) | set(got["dm"]))
    if not targets:
        if f.get("public_url_shared"):
            return ("link-only", "shared into no conversation and readable by anyone "
                                 "holding permalink_public. Invisible inside Slack and "
                                 "visible outside it, which is the worse half of both")
        return ("unshared", "no channel, no private channel and no DM. The upload "
                            "finished, the file exists, and completeUploadExternal was "
                            "called without channel_id")
    if not got["public"] and not got["private"]:
        return ("dm-only", "shared into %d direct message conversation(s) and no "
                           "channel. Only the people in those conversations can find "
                           "it" % len(got["dm"]))
    if not got["public"]:
        return ("private-only", "shared into %d private channel(s). That is a real "
                                "share, and a file in a private channel has an empty "
                                "channels array by design" % len(got["private"]))
    return ("shared", "shared into %d conversation(s)" % len(targets))


def orphan_rate(states):
    """What proportion of this app's own output has no audience? Pure.

    Returns (unreachable, total, percent). External files are excluded from the
    denominator: their sharing state is another system's, and leaving them in
    would move the number for a reason that has nothing to do with the bug.
    """
    counted = [s for s in states if s != "external"]
    total = len(counted)
    bad = sum(1 for s in counted if s in UNREACHABLE)
    return (bad, total, 0.0 if not total else round(100.0 * bad / total, 1))


def page_files(session, user, page_size, max_pages):
    """Page files.list. A read, and the only network call this script makes."""
    out, page, pages = [], 1, 1
    while page <= min(pages, max_pages):
        params = {"count": str(page_size), "types": "all", "page": str(page)}
        if user:
            params["user"] = user
        body = session.get(API + "files.list", params=params, timeout=30).json()
        if body.get("ok") is not True:
            log.error("files.list unavailable    %s", body.get("error"))
            return out, pages
        out.extend(body.get("files") or [])
        pages = int((body.get("paging") or {}).get("pages") or 1)
        page += 1
    return out, pages


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--user", default="",
                    help="owner to audit; defaults to this token's own user id")
    ap.add_argument("--all-users", action="store_true",
                    help="audit every file in the workspace rather than the app's own")
    ap.add_argument("--page-size", type=int, default=200)
    ap.add_argument("--max-pages", type=int, default=25)
    ap.add_argument("--show", default="unshared,link-only",
                    help="comma separated states to print in full")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s to a bot token with files:read", args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    who_resp = s.get(API + "auth.test", timeout=30)
    who = who_resp.json()
    if who.get("ok") is not True:
        log.error("auth.test  unavailable    %s", who.get("error"))
        return 2
    log.info("identity   %s (%s) in %s", who.get("user_id"), who.get("user"),
             who.get("team"))

    scopes = who_resp.headers.get("x-oauth-scopes") or ""
    if scopes and "files:read" not in scopes.split(","):
        log.warning("scope      files:read is not granted; files.list will refuse")

    owner = "" if args.all_users else (args.user or who.get("user_id") or "")
    files, pages = page_files(s, owner, args.page_size, args.max_pages)
    log.info("files      %d file(s) owned by %s, over %d page(s)",
             len(files), owner or "anyone", pages)

    wanted = {w.strip() for w in args.show.split(",") if w.strip()}
    states = []
    for f in files:
        state, detail = share_state(f)
        states.append(state)
        name = f.get("name") or f.get("title") or "?"
        line = ("state      %-14s %s %s  %s", state, f.get("id") or "?", name, detail)
        if state in UNREACHABLE:
            log.warning(*line)
        elif state in wanted:
            log.info(*line)

    bad, total, percent = orphan_rate(states)
    if not bad:
        log.info("verdict    clean          every file this app owns is shared somewhere")
        return 0
    log.warning("verdict    %d of %d file(s) owned by this app have no audience in "
                "Slack (%.1f%%)", bad, total, percent)
    log.warning("  repair: pass channel_id (a C id, not a name) to "
                "files.completeUploadExternal, with initial_comment beside it")
    log.warning("  repair: to share a file that already exists, post its permalink "
                "into the channel with the bot; Slack expands it and grants access")
    if "link-only" in states:
        log.warning("  repair: the link-only rows are also readable without a Slack "
                    "login; revoke those with files.revokePublicURL first")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-unshared-files.mjs
/**
 * Find the files your app uploaded and gave to nobody.
 *
 * Read only. One paginated GET against files.list and no writes at all: sharing
 * a file, revoking a link and deleting a file all need files:write, and all
 * three are printed here rather than performed. A bot token with files:read is
 * enough.
 *
 * The question is not whether the upload worked - it did, or the file would not
 * be in the list. The question is whether anything was passed to
 * files.completeUploadExternal to say where the file should go.
 */

const API = 'https://slack.com/api/';

// The two verdicts that mean nobody in the workspace can reach the file.
export const UNREACHABLE = ['unshared', 'link-only'];

/**
 * Where is this file shared? Read from both representations. Pure.
 *
 * Slack records the same fact twice: the modern `shares` object with `public`
 * and `private` maps keyed by conversation id, and the older `channels`,
 * `groups` and `ims` arrays split by conversation type. Reading only `channels`
 * reports every correctly-shared private file as an orphan, which is how a
 * check like this one gets switched off in its first week.
 */
export function buckets(f) {
  const file = f ?? {};
  const out = { public: new Set(), private: new Set(), dm: new Set() };
  const shares = file.shares;
  if (shares && typeof shares === 'object' && !Array.isArray(shares)) {
    for (const name of ['public', 'private']) {
      const group = shares[name];
      if (group && typeof group === 'object' && !Array.isArray(group)) {
        for (const key of Object.keys(group)) if (key) out[name].add(String(key));
      }
    }
  }
  for (const [name, legacy] of [['public', 'channels'], ['private', 'groups'],
    ['dm', 'ims']]) {
    for (const cid of file[legacy] ?? []) if (cid) out[name].add(String(cid));
  }
  return {
    public: [...out.public].sort(),
    private: [...out.private].sort(),
    dm: [...out.dm].sort(),
  };
}

/** Every conversation this file is shared into, deduplicated. Pure. */
export function shareTargets(f) {
  const got = buckets(f);
  return [...new Set([...got.public, ...got.private, ...got.dm])].sort();
}

/**
 * Classify one file by the audience it actually has. Pure.
 * Returns [state, detail]; only unshared and link-only are findings.
 */
export function shareState(f) {
  const file = f ?? {};
  if (file.is_external) {
    return ['external', 'hosted outside Slack, so Slack holds a reference rather than '
      + 'the bytes and its share fields describe nothing'];
  }
  const got = buckets(file);
  const targets = [...new Set([...got.public, ...got.private, ...got.dm])];
  if (!targets.length) {
    if (file.public_url_shared) {
      return ['link-only', 'shared into no conversation and readable by anyone holding '
        + 'permalink_public. Invisible inside Slack and visible outside it, which is '
        + 'the worse half of both'];
    }
    return ['unshared', 'no channel, no private channel and no DM. The upload finished, '
      + 'the file exists, and completeUploadExternal was called without channel_id'];
  }
  if (!got.public.length && !got.private.length) {
    return ['dm-only', `shared into ${got.dm.length} direct message conversation(s) and `
      + 'no channel. Only the people in those conversations can find it'];
  }
  if (!got.public.length) {
    return ['private-only', `shared into ${got.private.length} private channel(s). That `
      + 'is a real share, and a file in a private channel has an empty channels array '
      + 'by design'];
  }
  return ['shared', `shared into ${targets.length} conversation(s)`];
}

/**
 * What proportion of this app's own output has no audience? Pure.
 * Returns [unreachable, total, percent]. External files leave the denominator.
 */
export function orphanRate(states) {
  const counted = (states ?? []).filter((s) => s !== 'external');
  const total = counted.length;
  const bad = counted.filter((s) => UNREACHABLE.includes(s)).length;
  const percent = total ? Math.round((1000 * bad) / total) / 10 : 0;
  return [bad, total, percent];
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function pageFiles(headers, user, pageSize, maxPages) {
  const files = [];
  let page = 1;
  let pages = 1;
  while (page <= Math.min(pages, maxPages)) {
    const params = new URLSearchParams({
      count: String(pageSize), types: 'all', page: String(page),
    });
    if (user) params.set('user', user);
    const body = await (await fetch(`${API}files.list?${params}`, { headers })).json();
    if (body.ok !== true) {
      console.error(`files.list unavailable    ${body.error}`);
      return [files, pages];
    }
    files.push(...(body.files ?? []));
    pages = Number((body.paging ?? {}).pages ?? 1) || 1;
    page += 1;
  }
  return [files, pages];
}

async function main() {
  const args = process.argv.slice(2);
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} to a bot token with files:read`);
    process.exitCode = 2;
    return;
  }
  const headers = { Authorization: `Bearer ${token}` };
  const whoResp = await fetch(`${API}auth.test`, { headers });
  const who = await whoResp.json();
  if (who.ok !== true) {
    console.error(`auth.test  unavailable    ${who.error}`);
    process.exitCode = 2;
    return;
  }
  console.log(`identity   ${who.user_id} (${who.user}) in ${who.team}`);

  const scopes = whoResp.headers.get('x-oauth-scopes') ?? '';
  if (scopes && !scopes.split(',').includes('files:read')) {
    console.warn('scope      files:read is not granted; files.list will refuse');
  }

  const owner = args.includes('--all-users')
    ? '' : (arg(args, '--user', '') || who.user_id || '');
  const [files, pages] = await pageFiles(headers, owner,
    Number(arg(args, '--page-size', '200')), Number(arg(args, '--max-pages', '25')));
  console.log(`files      ${files.length} file(s) owned by ${owner || 'anyone'}, over `
    + `${pages} page(s)`);

  const wanted = new Set(String(arg(args, '--show', 'unshared,link-only'))
    .split(',').map((w) => w.trim()).filter(Boolean));
  const states = [];
  for (const f of files) {
    const [state, detail] = shareState(f);
    states.push(state);
    const name = f.name ?? f.title ?? '?';
    const line = `state      ${state.padEnd(14)} ${f.id ?? '?'} ${name}  ${detail}`;
    if (UNREACHABLE.includes(state)) console.warn(line);
    else if (wanted.has(state)) console.log(line);
  }

  const [bad, total, percent] = orphanRate(states);
  if (!bad) {
    console.log('verdict    clean          every file this app owns is shared somewhere');
    return;
  }
  console.warn(`verdict    ${bad} of ${total} file(s) owned by this app have no `
    + `audience in Slack (${percent}%)`);
  console.warn('  repair: pass channel_id (a C id, not a name) to '
    + 'files.completeUploadExternal, with initial_comment beside it');
  console.warn('  repair: to share a file that already exists, post its permalink into '
    + 'the channel with the bot; Slack expands it and grants access');
  if (states.includes('link-only')) {
    console.warn('  repair: the link-only rows are also readable without a Slack login; '
      + 'revoke those with files.revokePublicURL first');
  }
  process.exitCode = 1;
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The assertions that matter are the ones about not crying wolf. A file shared only into a private channel has an empty channels array and must not be reported as an orphan, so there is a test for exactly that shape, written from the field a naive checker would have read. buckets is tested against both representations separately and against a file that carries both, because Slack populates them inconsistently by upload path and by age. And orphan_rate is tested for what it leaves out of the denominator, since a proportion that moves when an external file appears is not measuring the bug.

test_slack_unshared_files.py
from slack_unshared_files import buckets, orphan_rate, share_state, share_targets


def test_the_modern_shares_object_is_read():
    f = {"shares": {"public": {"C111": [{"ts": "1.1"}]}, "private": {}}}
    assert buckets(f)["public"] == ["C111"]
    assert share_targets(f) == ["C111"]


def test_the_legacy_arrays_are_read_too():
    f = {"channels": ["C111"], "groups": ["G222"], "ims": ["D333"]}
    got = buckets(f)
    assert got == {"public": ["C111"], "private": ["G222"], "dm": ["D333"]}


def test_both_representations_of_the_same_share_collapse_to_one_target():
    f = {"shares": {"public": {"C111": []}}, "channels": ["C111"]}
    assert share_targets(f) == ["C111"]


def test_an_empty_file_object_has_no_targets_and_does_not_raise():
    assert share_targets({}) == []
    assert share_targets(None) == []
    assert buckets({"shares": []})["public"] == []


def test_a_file_shared_nowhere_is_the_finding():
    state, detail = share_state({"id": "F1", "shares": {"public": {}, "private": {}}})
    assert state == "unshared"
    assert "channel_id" in detail


def test_a_private_channel_share_is_a_share_and_not_an_orphan():
    state, detail = share_state({"id": "F1", "channels": [], "groups": ["G222"]})
    assert state == "private-only"
    assert "empty channels array" in detail


def test_a_dm_only_share_is_narrow_rather_than_absent():
    assert share_state({"id": "F1", "ims": ["D333"]})[0] == "dm-only"


def test_a_public_url_on_a_file_nobody_can_reach_gets_its_own_verdict():
    state, detail = share_state({"id": "F1", "public_url_shared": True})
    assert state == "link-only"
    assert "permalink_public" in detail


def test_a_public_url_on_a_shared_file_is_not_this_note():
    assert share_state({"id": "F1", "channels": ["C111"],
                        "public_url_shared": True})[0] == "shared"


def test_an_external_file_is_not_judged_by_slack_share_fields():
    assert share_state({"id": "F1", "is_external": True})[0] == "external"


def test_the_rate_is_a_proportion_of_what_the_app_owns():
    bad, total, percent = orphan_rate(["unshared", "unshared", "shared", "shared"])
    assert (bad, total, percent) == (2, 4, 50.0)


def test_link_only_counts_as_unreachable_inside_slack():
    assert orphan_rate(["link-only", "shared"])[0] == 1


def test_external_files_leave_the_denominator_alone():
    assert orphan_rate(["unshared", "shared", "external"]) == (1, 2, 50.0)


def test_narrow_audiences_are_not_counted_as_orphans():
    assert orphan_rate(["dm-only", "private-only", "shared"])[0] == 0


def test_an_empty_run_reports_zero_rather_than_dividing_by_it():
    assert orphan_rate([]) == (0, 0, 0.0)
slack-unshared-files.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  buckets, orphanRate, shareState, shareTargets,
} from './slack-unshared-files.mjs';

test('the modern shares object is read', () => {
  const f = { shares: { public: { C111: [{ ts: '1.1' }] }, private: {} } };
  assert.deepEqual(buckets(f).public, ['C111']);
  assert.deepEqual(shareTargets(f), ['C111']);
});

test('the legacy arrays are read too', () => {
  const f = { channels: ['C111'], groups: ['G222'], ims: ['D333'] };
  assert.deepEqual(buckets(f), { public: ['C111'], private: ['G222'], dm: ['D333'] });
});

test('both representations of the same share collapse to one target', () => {
  const f = { shares: { public: { C111: [] } }, channels: ['C111'] };
  assert.deepEqual(shareTargets(f), ['C111']);
});

test('an empty file object has no targets and does not throw', () => {
  assert.deepEqual(shareTargets({}), []);
  assert.deepEqual(shareTargets(null), []);
  assert.deepEqual(buckets({ shares: [] }).public, []);
});

test('a file shared nowhere is the finding', () => {
  const [state, detail] = shareState({ id: 'F1', shares: { public: {}, private: {} } });
  assert.equal(state, 'unshared');
  assert.match(detail, /channel_id/);
});

test('a private channel share is a share and not an orphan', () => {
  const [state, detail] = shareState({ id: 'F1', channels: [], groups: ['G222'] });
  assert.equal(state, 'private-only');
  assert.match(detail, /empty channels array/);
});

test('a dm only share is narrow rather than absent', () => {
  assert.equal(shareState({ id: 'F1', ims: ['D333'] })[0], 'dm-only');
});

test('a public url on a file nobody can reach gets its own verdict', () => {
  const [state, detail] = shareState({ id: 'F1', public_url_shared: true });
  assert.equal(state, 'link-only');
  assert.match(detail, /permalink_public/);
});

test('a public url on a shared file is not this note', () => {
  assert.equal(shareState({ id: 'F1', channels: ['C111'], public_url_shared: true })[0],
    'shared');
});

test('an external file is not judged by Slack share fields', () => {
  assert.equal(shareState({ id: 'F1', is_external: true })[0], 'external');
});

test('the rate is a proportion of what the app owns', () => {
  assert.deepEqual(orphanRate(['unshared', 'unshared', 'shared', 'shared']),
    [2, 4, 50]);
});

test('link only counts as unreachable inside Slack', () => {
  assert.equal(orphanRate(['link-only', 'shared'])[0], 1);
});

test('external files leave the denominator alone', () => {
  assert.deepEqual(orphanRate(['unshared', 'shared', 'external']), [1, 2, 50]);
});

test('narrow audiences are not counted as orphans', () => {
  assert.equal(orphanRate(['dm-only', 'private-only', 'shared'])[0], 0);
});

test('an empty run reports zero rather than dividing by it', () => {
  assert.deepEqual(orphanRate([]), [0, 0, 0]);
});

FAQ

The upload returned ok and the file has a permalink. How is it not shared?

Because uploading and sharing are two operations and only one of them happened. files.completeUploadExternal registers the file in the workspace, and it puts the file into a conversation only when you pass channel_id. Without that the file is complete, addressable and owned by the uploading identity, with an empty shares object. Everything the API told you was true; it just never said anything about an audience, because you never asked for one.

Can somebody find the file by searching Slack?

No. Search follows the same permissions as everything else, so a file with no share targets is not in anyone's search results but the uploader's. This is the part that makes the bug expensive: the people looking for the file conclude they are searching wrong, and the app's own logs say the upload succeeded, so neither side has a reason to suspect the other.

We post the permalink in a message afterwards. Why does that not work?

It does work, if the bot posts it into the conversation. Slack expands a file permalink in a message and grants that channel's members access to the file, which is the documented way to share after the fact. What does not work is putting the permalink somewhere outside Slack - a ticket, a wiki, an email - because a permalink is a page that enforces the file's permissions, and everyone who clicks it is told they do not have access.

Why does the script insist on reading both the shares object and the old arrays?

Because either one alone gives a wrong answer. shares.public and shares.private are the current representation; channels, groups and ims are the older one, and which is populated has varied with the upload path and the file's age. Read only channels and every file shared into a private channel or a DM looks like an orphan. Read only shares and some older files look like orphans. The union is the only version of this check that is worth running twice.

Some of the orphans also have public_url_shared set. Is that better or worse?

Worse, and the script gives it a different verdict for that reason. A file with no share targets and a public URL cannot be reached from inside Slack at all, so no member will ever stumble on it and mention it, while the public link keeps serving the bytes to anyone who has it. Revoke those first, then decide where the file should have been shared.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.