Diagnostic Slack
is_private: the public channel you read was converted
The reader has been pulling history out of #incidents for a year. This morning it returns channel_not_found, and the day before it returned messages. Nothing was deployed, the ID in the config is the ID it has always been, and the channel is still there. Somebody converted it to private on Tuesday, which moved it across a scope boundary without moving its identifier.
Conversion preserves the channel ID and everything in it. What it changes is which scope pair governs access: reads that channels:read and channels:history authorised now need groups:read and groups:history. If the token holds the new pair, conversations.info keeps working and channel.is_private is simply true. If it does not, the same ID that answered yesterday answers channel_not_found today.
That means the finding is a comparison, not a field read. The script below takes a baseline — the visibility your configuration recorded for each channel, and when — and compares it against what conversations.info says now. It also compares channel.created against the recorded one, because a conversion keeps that timestamp and a recycled name does not, which is how you tell "this channel changed" from "this is a different channel".
The problem in plain words
Every other channel fault leaves a mark on the reference. A rename releases the name, an archive sets a boolean you can read, a wrong ID never resolved in the first place. Conversion leaves nothing: the same ID, the same name, the same members, the same history, the same position in the sidebar. The only thing that moved is invisible from the configuration file, and it moved because somebody in the channel decided its conversation had become sensitive.
The way it presents depends entirely on what the token already holds, which is why two teams hit this and describe two different bugs. An app with groups:read granted for some other reason sees nothing at all until it tries to read history, then gets not_in_channel if the bot is not a member. An app with only the channels:* pair loses the channel completely: it vanishes from conversations.list and conversations.info starts denying it exists. One team is debugging membership, the other is debugging a missing channel, and both are looking at the same Tuesday afternoon.
The cruellest variant is an app that never joined anything. chat:write.public lets an app post into public channels it is not a member of, and a great many notification integrations are built entirely on that convenience. It applies to public channels and only public channels. The moment the channel converts, that route closes, and the repair is not a scope: it is a person in the room running /invite.
Why it happens
The ID is a red herring, so stop checking it. Conversion is deliberately identity-preserving: permalinks keep working, references in old messages keep working, and your configuration keeps pointing at exactly the right conversation. Everything a developer instinctively verifies is correct, which is why this one eats an afternoon before anybody says the word "private".
Scopes are typed by conversation, not by channel. Slack governs public channels with channels:* and private ones with groups:*, and a channel can move between those categories while your grant cannot. No scope covers both. That is why a conversion is a permission event even though nobody touched permissions.
The transition needs a baseline, because the current state is not a fault. is_private: true is an ordinary property of an ordinary channel. It is only a finding when your configuration recorded the channel as public, so the script has to be given something to compare against. A file that records visibility, created, and the date it was captured is enough, and it is the same file that dates the change.
created distinguishes conversion from recycling. A converted channel keeps its creation timestamp along with its ID. If the ID resolves to a channel created three weeks ago, you are not looking at a conversion — you are looking at a different conversation, and that is a different note. One integer comparison keeps the two apart.
The conversion is one-way in the product. Slack's UI converts public to private and does not offer the reverse to ordinary admins, so "wait for it to be converted back" is not a plan. Adding the scope pair and getting the bot invited is the whole repair, and the second half needs a human inside the room.
The fix, as a flow
The problem chain is a category moving under an identifier that does not move, which is why every instinctive check comes back clean. The fix branch is a comparison rather than a field read: it needs a recorded past to work at all, and one of its rows exists purely to stop a confident wrong report.
How to fix it
Record what each channel was, once
The baseline is a small JSON object per channel ID: visibility, the created timestamp conversations.info returned, and the date you captured it. Without it there is no finding here at all, only a description of the present. Generate it on a day everything works and commit it beside the configuration it describes.
Read the grant off the header first
One auth.test, and take X-OAuth-Scopes from the response. The grant decides how the conversion will present: with groups:read you will see the converted channel and its new flag, without it you will see an ID that has stopped existing. Interpreting the second case without knowing the grant is guesswork.
Compare visibility against the baseline, not against expectations
conversations.info per target, then channel.is_private against the recorded value. A channel recorded private and still private is not a finding no matter how surprising anybody finds it, and a channel recorded public and now private is the headline whether or not anything has failed yet.
Treat a not-found on a recorded channel as a candidate conversion
An ID that used to answer and now says channel_not_found, on a token with no groups:read, is most likely a conversion. It could also be a deletion, and no read-only token separates those, so the script reports it as the candidate it is and names the scope that would settle it.
Check the creation timestamp before you believe any of it
If channel.created does not match the baseline, the ID is answering for a channel that did not exist when you recorded it. That is not a conversion and the repair is completely different. One comparison, and it prevents the most confident kind of wrong report.
Add the scope pair, reinstall, then get the bot invited
groups:read and groups:history in Bot Token Scopes, then a reinstall, then a member of the channel running /invite @YourApp. If the integration was living on chat:write.public it has never been a member of anything, so the invite is not a formality — it is now the only way in.
How to check it worked
Once the scopes are granted and the bot has been invited, re-run against the same baseline and refresh it. Every channel should agree with what it records.
python3 slack_visibility_change.py --baseline channels.json
# grant: channels:read=yes groups:read=yes groups:history=yes
# unchanged C01ABCDE9 private, and the baseline already recorded it private
# unchanged C02XYZ123 public, and the baseline already recorded it public
# 2 channel(s) checked, 0 converted since the baseline
The full code
The three pure functions are the whole argument. conversion_verdict compares a recorded visibility against a live conversations.info response and treats a not-found as a candidate rather than an answer, same_channel uses the creation timestamp to rule out the case where the ID is answering for something else entirely, and access_route says what way in the app still has now — including the one that has closed for good.
"""Find the Slack channels that were converted to private under an integration.
Read only. One auth.test for the granted-scope header, then one
conversations.info per channel, each compared against a baseline that records
what the channel used to be. Nothing is converted, joined or invited: the
script names the channels whose visibility moved and prints the repair.
"""
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_visibility_change")
API = "https://slack.com/api/"
# Conversion moves a channel from one scope pair to the other without moving its
# ID. That is the entire mechanism, and the reason nothing in a config file looks
# stale afterwards.
PUBLIC_PAIR = ("channels:read", "channels:history")
PRIVATE_PAIR = ("groups:read", "groups:history")
def granted_scopes(header):
"""The scopes the token actually holds, off X-OAuth-Scopes. Pure."""
return {part.strip() for part in str(header or "").split(",") if part.strip()}
def conversion_verdict(record, body, scopes):
"""Compare a recorded visibility against a live conversations.info response.
Pure. Returns (state, detail). A not-found on a channel the baseline records
is deliberately reported as a candidate rather than a conclusion: conversion
and deletion are indistinguishable to a token that cannot see private
channels, and claiming the first would be a guess dressed as a finding.
"""
was = str((record or {}).get("visibility") or "unrecorded").lower()
if body.get("ok") is not True:
error = body.get("error") or "<no error field>"
if error != "channel_not_found":
return ("unreadable",
"conversations.info answered 200 with ok: false, error=%s. "
"Visibility cannot be compared until that is resolved." % error)
if was != "public":
return ("not-visible",
"channel_not_found, and the baseline does not record this "
"channel as public, so there is nothing here to compare.")
if PRIVATE_PAIR[0] not in scopes:
return ("candidate-conversion",
"the baseline recorded this channel as public and the ID now "
"answers channel_not_found on a token without %s. Conversion "
"is the likeliest cause; deletion is the other one, and this "
"token cannot tell them apart." % PRIVATE_PAIR[0])
return ("gone",
"the baseline recorded this channel as public and it is now "
"invisible even with %s granted, so a conversion would still have "
"been readable. Something removed it." % PRIVATE_PAIR[0])
channel = body.get("channel") or {}
now = "private" if channel.get("is_private") else "public"
if was == "unrecorded":
return ("unrecorded",
"no baseline visibility for this channel, so today's %s is a fact "
"rather than a finding. Record it and re-run." % now)
if was == now:
return ("unchanged",
"%s, and the baseline already recorded it %s" % (now, was))
if was == "public":
return ("converted-to-private",
"recorded public, is_private is now true. Same ID, same history, "
"and reads are governed by %s from here on." % PRIVATE_PAIR[0])
return ("converted-to-public",
"recorded private and now public. Rare, because the product converts "
"one way, but it widens who can read what your app writes here.")
def same_channel(record, channel):
"""Is this the conversation the baseline recorded, or a different one?
Pure. A conversion preserves both the ID and channel.created; a name released
by a rename and claimed by a new channel preserves neither. One integer
comparison keeps a conversion report from being filed against a channel that
did not exist when the baseline was written.
"""
was = (record or {}).get("created")
now = (channel or {}).get("created")
if was is None or now is None:
return ("undatable",
"no creation timestamp on one side, so the identity of this "
"channel cannot be confirmed. Re-record the baseline.")
if int(was) == int(now):
return ("same-channel",
"created is unchanged, so this is the same conversation and "
"whatever changed about it changed in place")
return ("different-channel",
"created moved from %s to %s. This ID is answering for a channel that "
"did not exist when the baseline was recorded, so nothing here is a "
"conversion." % (was, now))
def access_route(channel, scopes):
"""What way into this channel the app still has. Pure.
The route that matters is the one that closes: chat:write.public posts into
public channels without membership and has no private equivalent, so an
integration that never joined anything has no route left the moment the
channel converts.
"""
if not (channel or {}).get("is_private"):
return ("public",
"public, so chat:write.public still reaches it without membership")
missing = [s for s in PRIVATE_PAIR if s not in scopes]
if missing:
return ("no-scope",
"private and the token is missing %s. Add them to Bot Token "
"Scopes and reinstall." % ", ".join(missing))
if channel.get("is_member"):
return ("member",
"private, and the bot is a member, so the scopes are the only "
"thing that had to change")
return ("needs-invite",
"private, scopes granted, and the bot is not a member. No API call "
"fixes this: a private channel cannot be self-joined.")
def get(session, method, **params):
"""One GET against a read method. Returns (body, headers)."""
res = session.get(API + method, params=params, timeout=30)
return res.json(), res.headers
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--baseline", required=True,
help="JSON object mapping channel ID to {visibility, created, recorded}")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s (channels:read, and groups:read to see the other side)",
args.token_env)
return 2
baseline = json.loads(open(args.baseline, encoding="utf-8").read())
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
body, headers = get(session, "auth.test")
if body.get("ok") is not True:
log.error("auth.test failed: %s", body.get("error"))
return 2
scopes = granted_scopes(headers.get("X-OAuth-Scopes"))
log.info("grant: %s", " ".join(
"%s=%s" % (s, "yes" if s in scopes else "no")
for s in (PUBLIC_PAIR[0],) + PRIVATE_PAIR))
changed = 0
for channel_id in sorted(baseline):
record = baseline[channel_id]
info, _ = get(session, "conversations.info", channel=channel_id)
state, detail = conversion_verdict(record, info, scopes)
if state in ("unchanged", "not-visible"):
log.info("%-22s %-12s %s", state, channel_id, detail)
continue
changed += 1
log.warning("%-22s %-12s %s", state, channel_id, detail)
log.warning(" recorded on %s", record.get("recorded") or "an unknown date")
if info.get("ok") is True:
identity, why = same_channel(record, info.get("channel") or {})
log.warning(" identity: %s -- %s", identity, why)
if identity == "different-channel":
log.warning(" repair: this is a recycled ID, not a conversion. "
"Re-resolve the channel before changing any scopes.")
continue
route, how = access_route(info.get("channel") or {}, scopes)
log.warning(" access: %s -- %s", route, how)
if route == "needs-invite":
log.warning(" repair: ask a member of %s to run /invite @YourApp",
channel_id)
elif route == "no-scope":
log.warning(" repair: add %s to Bot Token Scopes, reinstall, then "
"get the bot invited", ", ".join(PRIVATE_PAIR))
else:
log.warning(" repair: add %s to Bot Token Scopes and reinstall, then "
"re-run. If the channel reappears it was converted; if it "
"does not, it was deleted.", ", ".join(PRIVATE_PAIR))
log.info("%d channel(s) checked, %d changed since the baseline",
len(baseline), changed)
return 1 if changed else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find the Slack channels that were converted to private under an integration.
*
* Read only. One auth.test for the granted-scope header, then one
* conversations.info per channel, each compared against a baseline that records
* what the channel used to be. Nothing is converted, joined or invited.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Conversion moves a channel from one scope pair to the other without moving its
// ID. That is the entire mechanism, and the reason nothing in a config file
// looks stale afterwards.
const PUBLIC_PAIR = ['channels:read', 'channels:history'];
const PRIVATE_PAIR = ['groups:read', 'groups:history'];
/** The scopes the token actually holds, off X-OAuth-Scopes. Pure. */
export function grantedScopes(header) {
return new Set(String(header ?? '').split(',').map((s) => s.trim()).filter(Boolean));
}
/**
* Compare a recorded visibility against a live conversations.info response.
* Pure. A not-found on a recorded channel is reported as a candidate rather
* than a conclusion: conversion and deletion look identical to a token that
* cannot see private channels.
*/
export function conversionVerdict(record, body, scopes) {
const was = String(record?.visibility ?? 'unrecorded').toLowerCase();
if (body.ok !== true) {
const error = body.error ?? '<no error field>';
if (error !== 'channel_not_found') {
return ['unreadable',
`conversations.info answered 200 with ok: false, error=${error}. ` +
'Visibility cannot be compared until that is resolved.'];
}
if (was !== 'public') {
return ['not-visible',
'channel_not_found, and the baseline does not record this channel as ' +
'public, so there is nothing here to compare.'];
}
if (!scopes.has(PRIVATE_PAIR[0])) {
return ['candidate-conversion',
'the baseline recorded this channel as public and the ID now answers ' +
`channel_not_found on a token without ${PRIVATE_PAIR[0]}. Conversion is ` +
'the likeliest cause; deletion is the other one, and this token cannot ' +
'tell them apart.'];
}
return ['gone',
'the baseline recorded this channel as public and it is now invisible even ' +
`with ${PRIVATE_PAIR[0]} granted, so a conversion would still have been ` +
'readable. Something removed it.'];
}
const channel = body.channel ?? {};
const now = channel.is_private ? 'private' : 'public';
if (was === 'unrecorded') {
return ['unrecorded',
`no baseline visibility for this channel, so today's ${now} is a fact ` +
'rather than a finding. Record it and re-run.'];
}
if (was === now) return ['unchanged', `${now}, and the baseline already recorded it ${was}`];
if (was === 'public') {
return ['converted-to-private',
'recorded public, is_private is now true. Same ID, same history, and reads ' +
`are governed by ${PRIVATE_PAIR[0]} from here on.`];
}
return ['converted-to-public',
'recorded private and now public. Rare, because the product converts one ' +
'way, but it widens who can read what your app writes here.'];
}
/**
* Is this the conversation the baseline recorded, or a different one? Pure.
* A conversion preserves both the ID and channel.created; a recycled name
* preserves neither.
*/
export function sameChannel(record, channel) {
const was = record?.created;
const now = channel?.created;
if (was === undefined || was === null || now === undefined || now === null) {
return ['undatable',
'no creation timestamp on one side, so the identity of this channel cannot ' +
'be confirmed. Re-record the baseline.'];
}
if (Number(was) === Number(now)) {
return ['same-channel',
'created is unchanged, so this is the same conversation and whatever ' +
'changed about it changed in place'];
}
return ['different-channel',
`created moved from ${was} to ${now}. This ID is answering for a channel ` +
'that did not exist when the baseline was recorded, so nothing here is a ' +
'conversion.'];
}
/**
* What way into this channel the app still has. Pure.
* chat:write.public posts into public channels without membership and has no
* private equivalent, so it is the route that closes.
*/
export function accessRoute(channel, scopes) {
if (!channel?.is_private) {
return ['public', 'public, so chat:write.public still reaches it without membership'];
}
const missing = PRIVATE_PAIR.filter((s) => !scopes.has(s));
if (missing.length) {
return ['no-scope',
`private and the token is missing ${missing.join(', ')}. Add them to Bot ` +
'Token Scopes and reinstall.'];
}
if (channel.is_member) {
return ['member',
'private, and the bot is a member, so the scopes are the only thing that ' +
'had to change'];
}
return ['needs-invite',
'private, scopes granted, and the bot is not a member. No API call fixes ' +
'this: a private channel cannot be self-joined.'];
}
async function get(token, method, params) {
const query = new URLSearchParams(params ?? {});
const res = await fetch(`${API}${method}?${query}`, {
headers: { Authorization: `Bearer ${token}` },
});
return [await res.json(), res.headers];
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function main() {
const args = process.argv.slice(2);
const baselinePath = arg(args, '--baseline');
if (!baselinePath) {
console.error('usage: --baseline channels.json [--token-env SLACK_BOT_TOKEN]');
process.exitCode = 2;
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} (channels:read, and groups:read to see the other side)`);
process.exitCode = 2;
return;
}
const baseline = JSON.parse(await readFile(baselinePath, 'utf8'));
const [auth, headers] = await get(token, 'auth.test');
if (auth.ok !== true) {
console.error(`auth.test failed: ${auth.error}`);
process.exitCode = 2;
return;
}
const scopes = grantedScopes(headers.get('x-oauth-scopes'));
console.log('grant: ' + [PUBLIC_PAIR[0], ...PRIVATE_PAIR]
.map((s) => `${s}=${scopes.has(s) ? 'yes' : 'no'}`).join(' '));
let changed = 0;
for (const channelId of Object.keys(baseline).sort()) {
const record = baseline[channelId];
const [info] = await get(token, 'conversations.info', { channel: channelId });
const [state, detail] = conversionVerdict(record, info, scopes);
if (state === 'unchanged' || state === 'not-visible') {
console.log(`${state.padEnd(22)} ${channelId.padEnd(12)} ${detail}`);
continue;
}
changed += 1;
console.warn(`${state.padEnd(22)} ${channelId.padEnd(12)} ${detail}`);
console.warn(` recorded on ${record.recorded ?? 'an unknown date'}`);
if (info.ok === true) {
const [identity, why] = sameChannel(record, info.channel ?? {});
console.warn(` identity: ${identity} -- ${why}`);
if (identity === 'different-channel') {
console.warn(' repair: this is a recycled ID, not a conversion. ' +
'Re-resolve the channel before changing any scopes.');
continue;
}
const [route, how] = accessRoute(info.channel ?? {}, scopes);
console.warn(` access: ${route} -- ${how}`);
if (route === 'needs-invite') {
console.warn(` repair: ask a member of ${channelId} to run /invite @YourApp`);
} else if (route === 'no-scope') {
console.warn(` repair: add ${PRIVATE_PAIR.join(', ')} to Bot Token Scopes, ` +
'reinstall, then get the bot invited');
}
} else {
console.warn(` repair: add ${PRIVATE_PAIR.join(', ')} to Bot Token Scopes and ` +
'reinstall, then re-run. If the channel reappears it was ' +
'converted; if it does not, it was deleted.');
}
}
console.log(`${Object.keys(baseline).length} channel(s) checked, ${changed} changed ` +
'since the baseline');
process.exitCode = changed ? 1 : 0;
}
// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing baseline.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The tests pin the two places this could overclaim. A channel_not_found on a recorded public channel has to come back as candidate-conversion and never as a conversion, because a deletion produces exactly the same response; and a channel whose created timestamp has moved has to be rejected as a different conversation before any verdict about visibility is believed. The rest checks that an unchanged private channel stays boring.
from slack_visibility_change import (access_route, conversion_verdict,
granted_scopes, same_channel)
PUBLIC_ONLY = granted_scopes("channels:read,channels:history,chat:write.public")
BOTH = granted_scopes("channels:read,groups:read,groups:history")
WAS_PUBLIC = {"visibility": "public", "created": 1451648400, "recorded": "2026-01-04"}
def ok(**channel):
return {"ok": True, "channel": dict({"id": "C01ABCDE9", "created": 1451648400},
**channel)}
def test_a_channel_that_matches_its_baseline_is_not_a_finding():
state, _ = conversion_verdict({"visibility": "private"},
ok(is_private=True), BOTH)
assert state == "unchanged"
def test_recorded_public_and_now_private_is_the_headline():
state, detail = conversion_verdict(WAS_PUBLIC, ok(is_private=True), BOTH)
assert state == "converted-to-private"
assert "Same ID" in detail
def test_not_found_without_groups_read_is_a_candidate_not_a_conclusion():
state, detail = conversion_verdict(
WAS_PUBLIC, {"ok": False, "error": "channel_not_found"}, PUBLIC_ONLY)
assert state == "candidate-conversion"
assert "deletion" in detail
def test_not_found_with_groups_read_rules_conversion_out():
state, _ = conversion_verdict(
WAS_PUBLIC, {"ok": False, "error": "channel_not_found"}, BOTH)
assert state == "gone"
def test_another_error_is_not_a_visibility_answer_at_all():
state, _ = conversion_verdict(
WAS_PUBLIC, {"ok": False, "error": "ratelimited"}, BOTH)
assert state == "unreadable"
def test_a_channel_with_no_recorded_visibility_produces_a_fact_not_a_finding():
state, _ = conversion_verdict({}, ok(is_private=True), BOTH)
assert state == "unrecorded"
def test_a_moved_creation_timestamp_means_this_is_not_the_same_channel():
identity, detail = same_channel(WAS_PUBLIC, {"created": 1770000000})
assert identity == "different-channel"
assert "did not exist when the baseline" in detail
def test_an_unchanged_creation_timestamp_confirms_an_in_place_change():
assert same_channel(WAS_PUBLIC, {"created": 1451648400})[0] == "same-channel"
def test_identity_is_undatable_when_the_baseline_never_recorded_created():
assert same_channel({"visibility": "public"}, {"created": 1451648400})[0] == "undatable"
def test_a_private_channel_without_the_scope_pair_names_what_is_missing():
route, detail = access_route({"is_private": True}, PUBLIC_ONLY)
assert route == "no-scope"
assert "groups:history" in detail
def test_scopes_granted_and_no_membership_needs_a_person():
route, detail = access_route({"is_private": True, "is_member": False}, BOTH)
assert route == "needs-invite"
assert "cannot be self-joined" in detail
def test_a_still_public_channel_keeps_the_write_public_route():
route, detail = access_route({"is_private": False}, PUBLIC_ONLY)
assert route == "public"
assert "chat:write.public" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { accessRoute, conversionVerdict, grantedScopes, sameChannel }
from './slack-visibility-change.mjs';
const PUBLIC_ONLY = grantedScopes('channels:read,channels:history,chat:write.public');
const BOTH = grantedScopes('channels:read,groups:read,groups:history');
const WAS_PUBLIC = { visibility: 'public', created: 1451648400, recorded: '2026-01-04' };
const ok = (channel) => ({
ok: true,
channel: { id: 'C01ABCDE9', created: 1451648400, ...channel },
});
test('a channel that matches its baseline is not a finding', () => {
const [state] = conversionVerdict({ visibility: 'private' }, ok({ is_private: true }), BOTH);
assert.equal(state, 'unchanged');
});
test('recorded public and now private is the headline', () => {
const [state, detail] = conversionVerdict(WAS_PUBLIC, ok({ is_private: true }), BOTH);
assert.equal(state, 'converted-to-private');
assert.match(detail, /Same ID/);
});
test('not found without groups read is a candidate not a conclusion', () => {
const [state, detail] = conversionVerdict(
WAS_PUBLIC, { ok: false, error: 'channel_not_found' }, PUBLIC_ONLY);
assert.equal(state, 'candidate-conversion');
assert.match(detail, /deletion/);
});
test('not found with groups read rules conversion out', () => {
const [state] = conversionVerdict(
WAS_PUBLIC, { ok: false, error: 'channel_not_found' }, BOTH);
assert.equal(state, 'gone');
});
test('another error is not a visibility answer at all', () => {
const [state] = conversionVerdict(WAS_PUBLIC, { ok: false, error: 'ratelimited' }, BOTH);
assert.equal(state, 'unreadable');
});
test('a channel with no recorded visibility produces a fact not a finding', () => {
const [state] = conversionVerdict({}, ok({ is_private: true }), BOTH);
assert.equal(state, 'unrecorded');
});
test('a moved creation timestamp means this is not the same channel', () => {
const [identity, detail] = sameChannel(WAS_PUBLIC, { created: 1770000000 });
assert.equal(identity, 'different-channel');
assert.match(detail, /did not exist when the baseline/);
});
test('an unchanged creation timestamp confirms an in place change', () => {
assert.equal(sameChannel(WAS_PUBLIC, { created: 1451648400 })[0], 'same-channel');
});
test('identity is undatable when the baseline never recorded created', () => {
assert.equal(sameChannel({ visibility: 'public' }, { created: 1451648400 })[0], 'undatable');
});
test('a private channel without the scope pair names what is missing', () => {
const [route, detail] = accessRoute({ is_private: true }, PUBLIC_ONLY);
assert.equal(route, 'no-scope');
assert.match(detail, /groups:history/);
});
test('scopes granted and no membership needs a person', () => {
const [route, detail] = accessRoute({ is_private: true, is_member: false }, BOTH);
assert.equal(route, 'needs-invite');
assert.match(detail, /cannot be self-joined/);
});
test('a still public channel keeps the write public route', () => {
const [route, detail] = accessRoute({ is_private: false }, PUBLIC_ONLY);
assert.equal(route, 'public');
assert.match(detail, /chat:write\.public/);
});
FAQ
How do I tell a conversion from a channel that was simply deleted?
With groups:read granted you can tell immediately: a converted channel is still readable and reports is_private true, so if it is invisible even with the scope, it is not a conversion. Without that scope the two are genuinely identical, both arriving as channel_not_found, and no read-only token separates them. The honest sequence is to add the scope, reinstall, and re-run: if the channel reappears it was converted.
Does the channel ID change when a channel is converted to private?
No. The ID, the history, the members, the pins and the permalinks all survive, which is exactly why this is hard to spot. Older documentation and some client libraries talk about private channels having G-prefixed IDs, but a converted channel keeps the C-prefixed ID it was created with, so prefix-sniffing is not a reliable test of visibility.
Our app posts with chat:write.public and never joins channels. What changes?
That route disappears. chat:write.public is defined for public channels, and there is no private equivalent, so after a conversion the app has no way to post until a member invites it. This is the case worth checking first, because an app built that way has never been a member of anything and the invite step has never been in anybody's runbook.
Can the bot convert the channel back, or join itself?
Neither. Conversion back to public is not offered to ordinary admins in the product, and conversations.join works on public channels only, so a private channel is only ever entered by invitation from someone already inside it. Both halves of the repair are human actions, which is why the script prints them rather than pretending it can do them.
Where does the baseline come from if we never recorded one?
Generate it on a day the integration is working: for each configured channel, store the is_private flag and the created timestamp that conversations.info returns, plus today's date. Commit it beside the configuration. It is worth doing even though it feels redundant, because without a recorded past there is no way to distinguish a channel that changed from a channel that was always like this.
Related field notes
- a private channel the token never could see
- visible, and still not a member
- when Slack does name what was needed
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.
- conversations.info method reference — Slack Docs
- Permission scopes — Slack Docs
- auth.test method reference — Slack Docs
- Using the Conversations API — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.