Skip to content

Diagnostic Slack

not_visible: the file exists and your token is not in the room

A file_shared event arrives. It carries a file id, a user, a channel and a timestamp, and the handler does the obvious next thing: files.info on that id, so it can index the attachment. Slack answers {"ok": false, "error": "not_visible"}.

The id is not wrong. Slack just handed it to you. The file has not been deleted, the token is valid, and files:read is granted. not_visible means the file exists and this token is not part of its audience — and unlike almost every other Slack error, it will not tell you a single thing about where the file actually lives.

Read-only token Python and Node.js Tests included
Black android smartphone
Photo by Kelly Sikkema on Unsplash
The short answer

File visibility follows conversation visibility. A file is readable by a token that can see at least one conversation the file is shared into, and by nobody else. So a file shared only into a private channel your bot is not in, or into a DM between two other people, or into an externally shared Slack Connect channel, is invisible to you even though an event just told you it exists.

Four errors come back from files.info and they are not interchangeable. not_visible means it exists and is not for you, and the repair is membership. file_not_found means no such file for this token, and the repair is a correct id or a correct workspace. file_deleted means it is gone. access_denied means a Slack Connect sharing policy, set by an admin, is the blocker rather than your app. Treating all four as cannot read file is what turns a five-minute fix into a week.

The part that hurts is that not_visible returns no file object, so the API cannot tell you which conversation to be invited to. The event can. A file_shared payload carries channel_id, and comparing that against users.conversations for the bot turns an unactionable error into the name of a channel and a request to be added to it.

The problem in plain words

The reason this is confusing rather than merely annoying is that the event already told you about the file, and it is entirely reasonable to read that as permission. It is not. Events carry ids more liberally than the API grants access to what they name. Your app is subscribed to file_shared, the subscription fires for shares in conversations the app is in, and the payload is a pointer rather than a grant — and in the case of a file shared into several conversations at once, the pointer can outlive the one conversation you were entitled to see it in.

The second thing that goes wrong is the retry. not_visible is not a transient error and there is no amount of backoff that fixes it, but it looks exactly like one from the shape of the code: a call failed, so retry it. An indexer that treats it as retryable spends its rate limit on a file it will never be allowed to read, and the queue grows for a reason that has nothing to do with load. Only file_deleted and not_visible are permanent among the four; a missing_scope in the same position is permanent until somebody reinstalls, which is also not a retry.

Then there is the case that looks identical and is not a permission problem at all. file_not_found is what you get for an id that this token's workspace has never heard of — a file id copied from a different workspace, a truncated id, an id from a Grid workspace the app was not installed into. Slack deliberately answers file_not_found rather than not_visible there, because telling you that a file exists somewhere you cannot see would itself be a disclosure. The two errors are the API being careful, and reading them as the same thing throws away the care.

Slack Connect is the fourth flavour and the one nobody can fix in code. An externally shared channel is subject to sharing policy on both sides, and access_denied, along with the more explicit slack_connect_file_link_sharing_blocked, means an administrator has decided files do not cross that boundary. There is no scope for it and no membership change that helps. The right output is the sentence this is a policy, ask an admin, not another retry.

file_sharedarrivesid, user, channelHandler wakesindex thisattachmentfiles.info onthe idfiles:read grantednot_visibleexists, and notfor youRetried allnightno backoff canhelp
An event is a notification and not a grant. Slack hands out ids more freely than it hands out access, and not_visible is the sentence where those two part company.

Why it happens

not_visible and file_not_found are the whole note. One says exists, not for you and is fixed by an invitation. The other says no such file here and is fixed by looking at where the id came from. They are one character apart in a log line and a week apart in what you do about them, and a script that prints the raw error string and stops has done nothing that reading the log would not have.

The API refuses to say where the file is, so the event has to. A not_visible response contains no channels, no groups, no ims — nothing. That is correct behaviour and it is also why so many teams get stuck: there is no read that turns the error into an action. The channel_id on the event payload is the one place the room is named, which is why this script takes saved event payloads as input rather than bare file ids.

Membership is a set intersection, and it is worth doing explicitly. users.conversations for the bot, across public_channel, private_channel, im and mpim, is the set of rooms this token can see into. The room from the event either is in that set or is not, and the answer is either you are a member and something else is wrong or invite the bot to this channel.

A bot that cannot enumerate private channels cannot tell the two apart, and should say so. Without groups:read, users.conversations will not return private channels, so a private room the bot is genuinely in looks like a room it is not in. The script checks the granted scopes first and marks the membership answer as undecidable rather than reporting a wrong one confidently.

None of these four errors is retryable, and three of them are not even permanent in the same way. The script labels each verdict with what to do next — invite, correct the id, drop the reference, ask an admin — because the single most common wrong response to all four is a backoff loop.

A file you can read is not the end of the check. If files.info succeeds, the useful follow-up is whether the conversations the file is shared into overlap with the ones your bot is in, because a file readable today through one shared channel becomes unreadable the moment the bot is removed from it. That is the same error arriving later.

The fix, as a flow

This chain goes red at the very last arrow, which is the opposite of the one before it. The event is genuine, the id is genuine, the token is valid and the scope is granted, so there is nothing to colour until an identity is held against an audience. The fix branch sorts error strings rather than files, because the entire cost of this bug is four different answers being read as one, and the bottom row is the only one anybody can act on today.

files.info, then the roomsthe error, then the membershipfile_not_foundwrong id or wrong workspacefile_deletedgone, so drop the referenceaccess_denieda Connect policy, ask an adminnot_visible, and a memberthe share you were told of is gonenot_visible, and outsideone invitation, and it is named
The API refuses to name the room, so the room comes from the event payload instead and is held against the conversations this token can see. One row ends in an invitation; the rest end somewhere else entirely.

How to fix it

Keep the event payloads, not just the file ids

The script reads a JSON array of saved file_shared events with --events. event_rooms pulls channel_id, channel and item.channel out of whatever shape the payload has. That field is the only place the room is named once the API has said not_visible, so an indexer that logs the id and discards the envelope has thrown away its own diagnosis.

Confirm the scopes before believing the membership answer

auth.test returns X-OAuth-Scopes in the response headers. Without groups:read the bot cannot list private channels, so not a member and cannot tell look the same. The script reports which one it is looking at rather than assuming.

Sort the four errors before doing anything else

visibility maps the files.info response to readable, not-in-the-room, unknown-id, deleted, connect-policy, credential or other. Everything after this step branches on that verdict, and no branch of it is a retry.

Intersect the room from the event with the rooms the bot is in

join_gap takes the rooms named by the event and the rooms from users.conversations, and returns member, outside or nowhere. outside is the actionable one: it names a channel, and the repair is one invitation.

Read member plus not_visible as the interesting contradiction

If the bot is in the room the event named and files.info still says not_visible, the file was shared into more than one conversation and the share you were told about is not the one that survived, or the file moved to a Connect channel. That combination is rare and worth printing loudly, because it is the only case here that is not a one-line fix.

Take the repair per verdict rather than per file

repair_for prints the sentence that goes with the verdict: invite the bot, check where the id came from, drop the stored reference, or ask an admin about Slack Connect file sharing. Four verdicts, four repairs, and the reason to separate them is that only one of them is yours to carry out.

How to check it worked

Run it over the events your handler failed on. The output you want names a channel, because a channel is something you can ask to be added to.

python3 slack_file_visibility.py --events failed-events.json
# identity   U07BOT9QD in Northwind
# scope      files:read groups:read granted
# member     bot is in 41 conversation(s)
# check      not-in-the-room F08K2M4QX  exists, and this token is not part of its
#                           audience. The API will not say where it lives
# room       outside        F08K2M4QX  the event named C05SEC9QT and the bot is not in it
#   repair: invite the bot to C05SEC9QT, or have the file shared into a channel it is in
# check      unknown-id     F04ZZZ1QQ  no such file for this token. Slack answers
#                           file_not_found rather than admitting it exists elsewhere
#   repair: check the workspace the id came from before changing any permission
# check      connect-policy F08K3P2QW  a Slack Connect sharing policy is the blocker
#   repair: this is an admin setting on one side of the external channel, not a scope
# verdict    3 of 12 file(s) unreadable; 1 of them is one invitation away

The full code

Two reads and three pure functions. visibility is the error taxonomy and does the bulk of the work, because the whole cost of this bug is four errors being treated as one. event_rooms exists because the API refuses to name the room and the event payload does not. join_gap is a set intersection, kept as its own function so the case where the bot is in the room and still cannot read the file is a named outcome rather than a gap in an if-statement.

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_file_visibility.py
"""Sort the reasons your token cannot read a Slack file, and name the room.

Read only. files.info and users.conversations are reads; nothing here shares,
downloads or deletes anything, and the file's bytes are never fetched.

The point of the script is that four different errors mean four different
things, and that the one which means "you are not in the room" is the only one
Slack will not give you a room for. The room comes from the file_shared event
payload instead, which is why this takes saved events rather than bare ids.
"""
import argparse
import json
import logging
import os
import sys

import requests

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

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

# The taxonomy. Every value is (verdict, detail), and no verdict here is
# retryable: three are permanent and one wants a human with admin rights.
ERRORS = {
    "not_visible": (
        "not-in-the-room",
        "exists, and this token is not part of its audience. The API will not say "
        "where it lives, because saying so would be the disclosure it is refusing"),
    "file_not_found": (
        "unknown-id",
        "no such file for this token. Slack answers file_not_found rather than "
        "admitting a file exists somewhere you cannot see, so this is usually an id "
        "from another workspace or a truncated one"),
    "file_deleted": (
        "deleted",
        "the file is gone. The id stays a valid-looking string forever, which is why "
        "stored references rot rather than fail loudly"),
    "access_denied": (
        "connect-policy",
        "a Slack Connect sharing policy is the blocker rather than your app. An "
        "administrator on one side of the external channel decided files do not cross "
        "it"),
    "slack_connect_file_link_sharing_blocked": (
        "connect-policy",
        "file link sharing is switched off for this external channel by policy"),
    "slack_connect_canvas_sharing_blocked": (
        "connect-policy",
        "canvas sharing is switched off for this external channel by policy"),
    "missing_scope": (
        "credential",
        "files:read is not granted to this token. That is a reinstall, not a retry"),
    "not_authed": ("credential", "no token reached Slack on this call"),
    "invalid_auth": ("credential", "the token is not valid for this workspace"),
    "token_revoked": ("credential", "the app was uninstalled and the token is dead"),
    "account_inactive": ("credential", "the installing user was deactivated"),
}

REPAIRS = {
    "not-in-the-room": "invite the bot to the conversation the file lives in, or have "
                       "the file shared into a channel the bot is already in",
    "unknown-id": "check the workspace the id came from before changing any permission",
    "deleted": "drop the stored reference; subscribe to file_deleted so the next one "
               "goes away on its own",
    "connect-policy": "this is an admin setting on one side of the external channel, "
                      "not a scope and not a code change",
    "credential": "grant files:read and reinstall; no membership change helps here",
    "other": "read the raw error string, then the method reference for files.info",
    "malformed": "the response carried neither ok nor an error, which is a transport "
                 "problem rather than a Slack one",
}


def visibility(payload):
    """What did files.info actually say? Pure.

    Returns (verdict, detail). The two verdicts this function exists to keep
    apart are not-in-the-room and unknown-id: one is fixed by an invitation and
    the other by looking at where the id came from, they are one word apart in
    a log line, and every retry loop ever written treats them as the same.
    """
    p = payload or {}
    if p.get("ok") is True:
        return ("readable", "files.info returned the file object")
    error = str(p.get("error") or "").strip()
    if not error:
        return ("malformed", "no ok and no error field in the response")
    if error in ERRORS:
        return ERRORS[error]
    return ("other", "%s is not one of the four visibility errors; treat it on its own "
                     "terms" % error)


def event_rooms(event):
    """Which conversation did the event say the file was shared into? Pure.

    The only place the room is named once files.info has answered not_visible.
    file_shared payloads have moved fields around over the years, so all three
    shapes are read: the modern channel_id, the older bare channel, and the
    item wrapper some subscriptions still deliver.
    """
    e = event or {}
    inner = e.get("event") if isinstance(e.get("event"), dict) else e
    out = []
    for holder in (inner, inner.get("item") if isinstance(inner.get("item"), dict)
                   else {}, inner.get("file") if isinstance(inner.get("file"), dict)
                   else {}):
        for key in ("channel_id", "channel"):
            value = holder.get(key)
            if isinstance(value, str) and value:
                out.append(value)
    seen, unique = set(), []
    for room in out:
        if room not in seen:
            seen.add(room)
            unique.append(room)
    return unique


def join_gap(rooms, my_rooms, decidable=True):
    """Is the bot in any room the event named? Pure.

    Returns (verdict, rooms). member means the bot is in one of them and the
    file is still unreadable, which is the interesting contradiction. outside
    names the channel to be invited to. nowhere means the event never said.
    undecidable means the token cannot enumerate its own private channels, so
    "not a member" and "cannot tell" are the same answer and must not be
    reported as the first one.
    """
    theirs = [str(r) for r in rooms or [] if r]
    if not theirs:
        return ("nowhere", [])
    if not decidable:
        return ("undecidable", sorted(set(theirs)))
    mine = {str(r) for r in my_rooms or [] if r}
    overlap = sorted({r for r in theirs if r in mine})
    if overlap:
        return ("member", overlap)
    return ("outside", sorted(set(theirs)))


def repair_for(verdict):
    """The sentence that goes with a verdict. Pure."""
    return REPAIRS.get(verdict, REPAIRS["other"])


def load_events(path):
    """Saved file_shared payloads, or bare ids, whichever you kept."""
    raw = json.loads(open(path, encoding="utf-8").read())
    if isinstance(raw, dict):
        raw = raw.get("events") or raw.get("payloads") or [raw]
    out = []
    for row in raw or []:
        if isinstance(row, str):
            out.append({"file_id": row})
        elif isinstance(row, dict):
            out.append(row)
    return out


def file_id_of(event):
    """The file id, from whichever of the three shapes carries it."""
    e = event or {}
    inner = e.get("event") if isinstance(e.get("event"), dict) else e
    for holder in (inner, inner.get("file") if isinstance(inner.get("file"), dict)
                   else {}):
        for key in ("file_id", "id"):
            value = holder.get(key)
            if isinstance(value, str) and value:
                return value
    return ""


def bot_conversations(session, limit):
    """The rooms this token can see into. A read, paginated by cursor."""
    rooms, cursor = set(), ""
    for _ in range(limit):
        params = {"types": "public_channel,private_channel,im,mpim", "limit": "200",
                  "exclude_archived": "false"}
        if cursor:
            params["cursor"] = cursor
        body = session.get(API + "users.conversations", params=params,
                           timeout=30).json()
        if body.get("ok") is not True:
            log.warning("member     unavailable    %s", body.get("error"))
            return rooms, False
        rooms.update(str((c or {}).get("id") or "") for c in body.get("channels") or [])
        cursor = ((body.get("response_metadata") or {}).get("next_cursor") or "")
        if not cursor:
            break
    rooms.discard("")
    return rooms, True


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--events", default="",
                    help="a JSON file of saved file_shared payloads, or of file ids")
    ap.add_argument("--file", action="append", default=[],
                    help="one file id to check; repeatable")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN")
    ap.add_argument("--max-pages", type=int, default=20)
    args = ap.parse_args()

    events = load_events(args.events) if args.events else []
    events.extend({"file_id": f} for f in args.file)
    if not events:
        log.error("pass --events FILE, or one or more --file F...")
        return 2

    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 in %s", who.get("user_id"), who.get("team"))

    granted = {sc.strip() for sc in
               (who_resp.headers.get("x-oauth-scopes") or "").split(",") if sc.strip()}
    log.info("scope      %s granted",
             " ".join(sorted(granted & {"files:read", "groups:read"})) or "neither")
    decidable = "groups:read" in granted or not granted
    if not decidable:
        log.warning("scope      groups:read is missing, so a private channel the bot is "
                    "in reads the same as one it is not in")

    my_rooms, listed = bot_conversations(s, args.max_pages)
    if listed:
        log.info("member     bot is in %d conversation(s)", len(my_rooms))

    unreadable, invitable = 0, 0
    for event in events:
        fid = file_id_of(event)
        if not fid:
            log.warning("check      no-file-id     an event with no file id in it")
            continue
        body = s.get(API + "files.info", params={"file": fid}, timeout=30).json()
        verdict, detail = visibility(body)
        if verdict == "readable":
            log.info("check      readable       %s", fid)
            continue
        unreadable += 1
        log.warning("check      %-14s %s  %s", verdict, fid, detail)
        if verdict != "not-in-the-room":
            log.warning("  repair: %s", repair_for(verdict))
            continue
        where, rooms = join_gap(event_rooms(event), my_rooms, decidable and listed)
        if where == "outside":
            invitable += 1
            log.warning("room       outside        %s  the event named %s and the bot "
                        "is not in it", fid, ", ".join(rooms))
            log.warning("  repair: invite the bot to %s, or have the file shared into a "
                        "channel it is in", rooms[0])
        elif where == "member":
            log.warning("room       member         %s  the bot IS in %s and still "
                        "cannot read the file, so the share you were told about is not "
                        "the one that survived", fid, ", ".join(rooms))
        elif where == "undecidable":
            log.warning("room       undecidable    %s  named %s, and this token cannot "
                        "enumerate private channels", fid, ", ".join(rooms))
        else:
            log.warning("room       nowhere        %s  the event never named a channel, "
                        "so keep the whole payload next time", fid)
            log.warning("  repair: %s", repair_for(verdict))

    if not unreadable:
        log.info("verdict    clean          every file in this run is readable")
        return 0
    log.warning("verdict    %d of %d file(s) unreadable; %d of them %s one invitation "
                "away", unreadable, len(events), invitable,
                "is" if invitable == 1 else "are")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-file-visibility.mjs
/**
 * Sort the reasons your token cannot read a Slack file, and name the room.
 *
 * Read only. files.info and users.conversations are reads; nothing here shares,
 * downloads or deletes anything, and the file's bytes are never fetched.
 *
 * Four different errors mean four different things, and the one that means "you
 * are not in the room" is the only one Slack will not give you a room for. The
 * room comes from the file_shared event payload instead.
 */

import { readFile } from 'node:fs/promises';

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

// The taxonomy. No verdict here is retryable.
export const ERRORS = {
  not_visible: ['not-in-the-room',
    'exists, and this token is not part of its audience. The API will not say where '
    + 'it lives, because saying so would be the disclosure it is refusing'],
  file_not_found: ['unknown-id',
    'no such file for this token. Slack answers file_not_found rather than admitting '
    + 'a file exists somewhere you cannot see, so this is usually an id from another '
    + 'workspace or a truncated one'],
  file_deleted: ['deleted',
    'the file is gone. The id stays a valid-looking string forever, which is why '
    + 'stored references rot rather than fail loudly'],
  access_denied: ['connect-policy',
    'a Slack Connect sharing policy is the blocker rather than your app. An '
    + 'administrator on one side of the external channel decided files do not cross it'],
  slack_connect_file_link_sharing_blocked: ['connect-policy',
    'file link sharing is switched off for this external channel by policy'],
  slack_connect_canvas_sharing_blocked: ['connect-policy',
    'canvas sharing is switched off for this external channel by policy'],
  missing_scope: ['credential',
    'files:read is not granted to this token. That is a reinstall, not a retry'],
  not_authed: ['credential', 'no token reached Slack on this call'],
  invalid_auth: ['credential', 'the token is not valid for this workspace'],
  token_revoked: ['credential', 'the app was uninstalled and the token is dead'],
  account_inactive: ['credential', 'the installing user was deactivated'],
};

export const REPAIRS = {
  'not-in-the-room': 'invite the bot to the conversation the file lives in, or have '
    + 'the file shared into a channel the bot is already in',
  'unknown-id': 'check the workspace the id came from before changing any permission',
  deleted: 'drop the stored reference; subscribe to file_deleted so the next one goes '
    + 'away on its own',
  'connect-policy': 'this is an admin setting on one side of the external channel, not '
    + 'a scope and not a code change',
  credential: 'grant files:read and reinstall; no membership change helps here',
  other: 'read the raw error string, then the method reference for files.info',
  malformed: 'the response carried neither ok nor an error, which is a transport '
    + 'problem rather than a Slack one',
};

/**
 * What did files.info actually say? Pure.
 * Returns [verdict, detail]; not-in-the-room and unknown-id are the pair that
 * every retry loop ever written treats as the same thing.
 */
export function visibility(payload) {
  const p = payload ?? {};
  if (p.ok === true) return ['readable', 'files.info returned the file object'];
  const error = String(p.error ?? '').trim();
  if (!error) return ['malformed', 'no ok and no error field in the response'];
  if (Object.prototype.hasOwnProperty.call(ERRORS, error)) return ERRORS[error];
  return ['other', `${error} is not one of the four visibility errors; treat it on its `
    + 'own terms'];
}

/**
 * Which conversation did the event say the file was shared into? Pure.
 * The only place the room is named once files.info has answered not_visible.
 */
export function eventRooms(event) {
  const e = event ?? {};
  const inner = (e.event && typeof e.event === 'object') ? e.event : e;
  const holders = [inner];
  for (const key of ['item', 'file']) {
    if (inner[key] && typeof inner[key] === 'object') holders.push(inner[key]);
  }
  const out = [];
  for (const holder of holders) {
    for (const key of ['channel_id', 'channel']) {
      const value = holder[key];
      if (typeof value === 'string' && value) out.push(value);
    }
  }
  return [...new Set(out)];
}

/**
 * Is the bot in any room the event named? Pure.
 * Returns [verdict, rooms]: member, outside, nowhere or undecidable.
 */
export function joinGap(rooms, myRooms, decidable = true) {
  const theirs = (rooms ?? []).filter(Boolean).map(String);
  if (!theirs.length) return ['nowhere', []];
  if (!decidable) return ['undecidable', [...new Set(theirs)].sort()];
  const mine = new Set((myRooms ?? []).filter(Boolean).map(String));
  const overlap = [...new Set(theirs.filter((r) => mine.has(r)))].sort();
  if (overlap.length) return ['member', overlap];
  return ['outside', [...new Set(theirs)].sort()];
}

/** The sentence that goes with a verdict. Pure. */
export function repairFor(verdict) {
  return REPAIRS[verdict] ?? REPAIRS.other;
}

/** The file id, from whichever of the three shapes carries it. Pure. */
export function fileIdOf(event) {
  const e = event ?? {};
  const inner = (e.event && typeof e.event === 'object') ? e.event : e;
  const holders = [inner];
  if (inner.file && typeof inner.file === 'object') holders.push(inner.file);
  for (const holder of holders) {
    for (const key of ['file_id', 'id']) {
      const value = holder[key];
      if (typeof value === 'string' && value) return value;
    }
  }
  return '';
}

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

function argAll(args, name) {
  const out = [];
  for (let i = 0; i < args.length; i += 1) {
    if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
  }
  return out;
}

function loadEvents(raw) {
  let rows = raw;
  if (rows && !Array.isArray(rows) && typeof rows === 'object') {
    rows = rows.events ?? rows.payloads ?? [rows];
  }
  const out = [];
  for (const row of rows ?? []) {
    if (typeof row === 'string') out.push({ file_id: row });
    else if (row && typeof row === 'object') out.push(row);
  }
  return out;
}

async function botConversations(headers, maxPages) {
  const rooms = new Set();
  let cursor = '';
  for (let i = 0; i < maxPages; i += 1) {
    const params = new URLSearchParams({
      types: 'public_channel,private_channel,im,mpim',
      limit: '200',
      exclude_archived: 'false',
    });
    if (cursor) params.set('cursor', cursor);
    const body = await (await fetch(`${API}users.conversations?${params}`,
      { headers })).json();
    if (body.ok !== true) {
      console.warn(`member     unavailable    ${body.error}`);
      return [rooms, false];
    }
    for (const c of body.channels ?? []) if (c?.id) rooms.add(String(c.id));
    cursor = (body.response_metadata ?? {}).next_cursor ?? '';
    if (!cursor) break;
  }
  return [rooms, true];
}

async function main() {
  const args = process.argv.slice(2);
  const eventsFile = arg(args, '--events', '');
  const events = eventsFile
    ? loadEvents(JSON.parse(await readFile(eventsFile, 'utf8'))) : [];
  events.push(...argAll(args, '--file').map((f) => ({ file_id: f })));
  if (!events.length) {
    console.error('pass --events FILE, or one or more --file F...');
    process.exitCode = 2;
    return;
  }

  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} in ${who.team}`);

  const granted = new Set((whoResp.headers.get('x-oauth-scopes') ?? '')
    .split(',').map((sc) => sc.trim()).filter(Boolean));
  const shown = ['files:read', 'groups:read'].filter((sc) => granted.has(sc));
  console.log(`scope      ${shown.join(' ') || 'neither'} granted`);
  const decidable = granted.has('groups:read') || granted.size === 0;
  if (!decidable) {
    console.warn('scope      groups:read is missing, so a private channel the bot is in '
      + 'reads the same as one it is not in');
  }

  const [myRooms, listed] = await botConversations(headers,
    Number(arg(args, '--max-pages', '20')));
  if (listed) console.log(`member     bot is in ${myRooms.size} conversation(s)`);

  let unreadable = 0;
  let invitable = 0;
  for (const event of events) {
    const fid = fileIdOf(event);
    if (!fid) {
      console.warn('check      no-file-id     an event with no file id in it');
      continue;
    }
    const params = new URLSearchParams({ file: fid });
    const body = await (await fetch(`${API}files.info?${params}`, { headers })).json();
    const [verdict, detail] = visibility(body);
    if (verdict === 'readable') {
      console.log(`check      readable       ${fid}`);
      continue;
    }
    unreadable += 1;
    console.warn(`check      ${verdict.padEnd(14)} ${fid}  ${detail}`);
    if (verdict !== 'not-in-the-room') {
      console.warn(`  repair: ${repairFor(verdict)}`);
      continue;
    }
    const [where, rooms] = joinGap(eventRooms(event), [...myRooms],
      decidable && listed);
    if (where === 'outside') {
      invitable += 1;
      console.warn(`room       outside        ${fid}  the event named ${rooms.join(', ')} `
        + 'and the bot is not in it');
      console.warn(`  repair: invite the bot to ${rooms[0]}, or have the file shared `
        + 'into a channel it is in');
    } else if (where === 'member') {
      console.warn(`room       member         ${fid}  the bot IS in ${rooms.join(', ')} `
        + 'and still cannot read the file, so the share you were told about is not the '
        + 'one that survived');
    } else if (where === 'undecidable') {
      console.warn(`room       undecidable    ${fid}  named ${rooms.join(', ')}, and `
        + 'this token cannot enumerate private channels');
    } else {
      console.warn(`room       nowhere        ${fid}  the event never named a channel, `
        + 'so keep the whole payload next time');
      console.warn(`  repair: ${repairFor(verdict)}`);
    }
  }

  if (!unreadable) {
    console.log('verdict    clean          every file in this run is readable');
    return;
  }
  console.warn(`verdict    ${unreadable} of ${events.length} file(s) unreadable; `
    + `${invitable} of them ${invitable === 1 ? 'is' : 'are'} one invitation away`);
  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

Two assertions carry this suite. The first is that not_visible and file_not_found never come back as the same verdict, written as an explicit inequality rather than as two separate checks, because the whole note is that those two get merged. The second is that join_gap refuses to answer when the token cannot enumerate private channels: not a member and cannot tell are different, and a script that confidently reports the first when it means the second sends somebody to add a bot that is already there.

test_slack_file_visibility.py
from slack_file_visibility import (event_rooms, file_id_of, join_gap, repair_for,
                                   visibility)


def test_an_ok_response_is_readable():
    assert visibility({"ok": True, "file": {"id": "F1"}})[0] == "readable"


def test_not_visible_is_about_the_room_and_says_the_api_will_not_name_it():
    verdict, detail = visibility({"ok": False, "error": "not_visible"})
    assert verdict == "not-in-the-room"
    assert "will not say where it lives" in detail


def test_file_not_found_is_about_the_id_and_not_about_permission():
    verdict, detail = visibility({"ok": False, "error": "file_not_found"})
    assert verdict == "unknown-id"
    assert "another workspace" in detail


def test_the_two_errors_this_note_exists_for_are_never_the_same_verdict():
    assert visibility({"ok": False, "error": "not_visible"})[0] \
        != visibility({"ok": False, "error": "file_not_found"})[0]


def test_a_deleted_file_is_its_own_verdict():
    assert visibility({"ok": False, "error": "file_deleted"})[0] == "deleted"


def test_every_slack_connect_refusal_lands_on_policy():
    for error in ("access_denied", "slack_connect_file_link_sharing_blocked",
                  "slack_connect_canvas_sharing_blocked"):
        assert visibility({"ok": False, "error": error})[0] == "connect-policy"


def test_a_credential_problem_is_not_a_visibility_problem():
    for error in ("missing_scope", "not_authed", "invalid_auth", "token_revoked"):
        assert visibility({"ok": False, "error": error})[0] == "credential"


def test_an_unknown_error_is_not_forced_into_the_taxonomy():
    verdict, detail = visibility({"ok": False, "error": "ratelimited"})
    assert verdict == "other"
    assert "ratelimited" in detail


def test_a_response_with_neither_ok_nor_error_is_malformed():
    assert visibility({})[0] == "malformed"
    assert visibility(None)[0] == "malformed"


def test_every_verdict_has_a_repair_and_none_of_them_is_a_retry():
    for verdict in ("not-in-the-room", "unknown-id", "deleted", "connect-policy",
                    "credential", "other", "malformed"):
        assert repair_for(verdict)
        assert "retry" not in repair_for(verdict).lower()


def test_the_room_is_read_from_all_three_event_shapes():
    assert event_rooms({"channel_id": "C111"}) == ["C111"]
    assert event_rooms({"event": {"channel_id": "C222"}}) == ["C222"]
    assert event_rooms({"event": {"item": {"channel": "C333"}}}) == ["C333"]


def test_a_repeated_room_is_named_once():
    assert event_rooms({"channel_id": "C111",
                        "item": {"channel": "C111"}}) == ["C111"]


def test_an_event_with_no_channel_names_nothing():
    assert event_rooms({"file_id": "F1"}) == []
    assert event_rooms(None) == []


def test_the_file_id_is_read_from_the_wrapper_or_the_file_object():
    assert file_id_of({"file_id": "F1"}) == "F1"
    assert file_id_of({"event": {"file": {"id": "F2"}}}) == "F2"
    assert file_id_of({}) == ""


def test_a_room_the_bot_is_not_in_is_the_actionable_answer():
    verdict, rooms = join_gap(["C111"], {"C999"})
    assert verdict == "outside"
    assert rooms == ["C111"]


def test_a_room_the_bot_is_in_is_the_interesting_contradiction():
    assert join_gap(["C111"], {"C111", "C999"}) == ("member", ["C111"])


def test_an_event_that_named_no_room_leaves_nowhere_to_go():
    assert join_gap([], {"C111"}) == ("nowhere", [])


def test_membership_is_undecidable_without_the_scope_to_enumerate_it():
    verdict, rooms = join_gap(["C111"], set(), decidable=False)
    assert verdict == "undecidable"
    assert rooms == ["C111"]


def test_undecidable_is_never_reported_as_outside():
    assert join_gap(["C111"], set(), decidable=False)[0] \
        != join_gap(["C111"], set(), decidable=True)[0]
slack-file-visibility.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  eventRooms, fileIdOf, joinGap, repairFor, visibility,
} from './slack-file-visibility.mjs';

test('an ok response is readable', () => {
  assert.equal(visibility({ ok: true, file: { id: 'F1' } })[0], 'readable');
});

test('not_visible is about the room and says the API will not name it', () => {
  const [verdict, detail] = visibility({ ok: false, error: 'not_visible' });
  assert.equal(verdict, 'not-in-the-room');
  assert.match(detail, /will not say where it lives/);
});

test('file_not_found is about the id and not about permission', () => {
  const [verdict, detail] = visibility({ ok: false, error: 'file_not_found' });
  assert.equal(verdict, 'unknown-id');
  assert.match(detail, /another workspace/);
});

test('the two errors this note exists for are never the same verdict', () => {
  assert.notEqual(visibility({ ok: false, error: 'not_visible' })[0],
    visibility({ ok: false, error: 'file_not_found' })[0]);
});

test('a deleted file is its own verdict', () => {
  assert.equal(visibility({ ok: false, error: 'file_deleted' })[0], 'deleted');
});

test('every Slack Connect refusal lands on policy', () => {
  for (const error of ['access_denied', 'slack_connect_file_link_sharing_blocked',
    'slack_connect_canvas_sharing_blocked']) {
    assert.equal(visibility({ ok: false, error })[0], 'connect-policy');
  }
});

test('a credential problem is not a visibility problem', () => {
  for (const error of ['missing_scope', 'not_authed', 'invalid_auth', 'token_revoked']) {
    assert.equal(visibility({ ok: false, error })[0], 'credential');
  }
});

test('an unknown error is not forced into the taxonomy', () => {
  const [verdict, detail] = visibility({ ok: false, error: 'ratelimited' });
  assert.equal(verdict, 'other');
  assert.match(detail, /ratelimited/);
});

test('a response with neither ok nor error is malformed', () => {
  assert.equal(visibility({})[0], 'malformed');
  assert.equal(visibility(null)[0], 'malformed');
});

test('a prototype key is not mistaken for a Slack error', () => {
  assert.equal(visibility({ ok: false, error: 'constructor' })[0], 'other');
});

test('every verdict has a repair and none of them is a retry', () => {
  for (const verdict of ['not-in-the-room', 'unknown-id', 'deleted', 'connect-policy',
    'credential', 'other', 'malformed']) {
    assert.ok(repairFor(verdict));
    assert.doesNotMatch(repairFor(verdict).toLowerCase(), /retry/);
  }
});

test('the room is read from all three event shapes', () => {
  assert.deepEqual(eventRooms({ channel_id: 'C111' }), ['C111']);
  assert.deepEqual(eventRooms({ event: { channel_id: 'C222' } }), ['C222']);
  assert.deepEqual(eventRooms({ event: { item: { channel: 'C333' } } }), ['C333']);
});

test('a repeated room is named once', () => {
  assert.deepEqual(eventRooms({ channel_id: 'C111', item: { channel: 'C111' } }),
    ['C111']);
});

test('an event with no channel names nothing', () => {
  assert.deepEqual(eventRooms({ file_id: 'F1' }), []);
  assert.deepEqual(eventRooms(null), []);
});

test('the file id is read from the wrapper or the file object', () => {
  assert.equal(fileIdOf({ file_id: 'F1' }), 'F1');
  assert.equal(fileIdOf({ event: { file: { id: 'F2' } } }), 'F2');
  assert.equal(fileIdOf({}), '');
});

test('a room the bot is not in is the actionable answer', () => {
  const [verdict, rooms] = joinGap(['C111'], ['C999']);
  assert.equal(verdict, 'outside');
  assert.deepEqual(rooms, ['C111']);
});

test('a room the bot is in is the interesting contradiction', () => {
  assert.deepEqual(joinGap(['C111'], ['C111', 'C999']), ['member', ['C111']]);
});

test('an event that named no room leaves nowhere to go', () => {
  assert.deepEqual(joinGap([], ['C111']), ['nowhere', []]);
});

test('membership is undecidable without the scope to enumerate it', () => {
  const [verdict, rooms] = joinGap(['C111'], [], false);
  assert.equal(verdict, 'undecidable');
  assert.deepEqual(rooms, ['C111']);
});

test('undecidable is never reported as outside', () => {
  assert.notEqual(joinGap(['C111'], [], false)[0], joinGap(['C111'], [], true)[0]);
});

FAQ

Slack sent me the file id in an event. Why can I not read the file?

Because an event is a notification, not a grant. Event payloads carry ids more liberally than the Web API grants access to what they name, and a file shared into several conversations can reach you through a subscription while the conversation you were entitled to see it in is not the one it ended up in. The id is genuine and the access is separate, which is exactly what not_visible is saying.

What is the difference between not_visible and file_not_found?

not_visible means the file exists and this token is not part of its audience. file_not_found means there is no such file for this token at all - a wrong id, a truncated id, or an id from a different workspace. Slack answers file_not_found rather than not_visible in that case on purpose, because confirming that a file exists somewhere you cannot see would itself leak something. One is fixed by an invitation and the other by looking at where the id came from.

Should I retry a not_visible error?

No. None of the four errors in this family is transient. not_visible and file_deleted are permanent until somebody changes the world, missing_scope is permanent until a reinstall, and access_denied is permanent until an administrator changes a policy. An indexer that backs off and retries on not_visible spends its rate limit on a file it will never be allowed to read, and the queue grows for reasons that have nothing to do with load.

How do I find out which channel to be invited to?

Not from the API. A not_visible response contains no file object at all, so there are no channels, groups or ims fields to read. The channel_id on the event payload is the only place the room is named, which is why this script takes saved events rather than bare ids. If your handler logs the file id and discards the envelope, it has thrown away its own diagnosis.

The bot is in the channel the event named and files.info still says not_visible. What now?

That is the one case here that is not a one-line fix, and the script prints it loudly for that reason. It means the share you were told about is not the share that survived: the file was shared into more than one conversation, or moved to a Slack Connect channel where policy applies, or the message carrying it was deleted from the channel the bot is in. Check the channel is not external before assuming anything about membership.

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.