Diagnostic Slack
channel_not_found: a channel name where an ID belongs
{"ok": false, "error": "channel_not_found"} for a channel that is open on the other monitor. The name in the config is spelled right, the bot is in the room, the token is fine. And the daily digest that posts to that same string has been working for a year. The difference is not the channel. It is which method you handed the string to.
Slack's canonical channel identifier is the C-prefixed ID. chat.postMessage kept legacy name resolution, so #alerts works there; conversations.info, conversations.history, conversations.members and the files.* family never accepted a name and answer channel_not_found. One string, two behaviours, and no error message anywhere that mentions the word "name".
So the check is a grammar check, and it needs no network: does every configured channel value match ^[CG][A-Z0-9]{7,}$? The script below classifies each reference by shape — ID, #name, bare name, a U user ID somebody pasted, a permalink — then prints which method families accept that shape and which refuse it. Only then does it paginate conversations.list once to hand you the ID to paste in.
The problem in plain words
There is deliberately no name-to-ID lookup method in the Web API. That single fact explains why this bug is thirteen years old and still arriving. A developer who wants to send to #alerts reaches for the obvious thing, discovers that chat.postMessage accepts it, and reasonably concludes that names are how you address a channel. Everything built on that assumption works until the first read call.
Then the failure is split by code path rather than by configuration. The alert sender works. The thread-reply feature, which has to call conversations.replies first, does not. The file upload, which finishes with files.completeUploadExternal, does not. Three features share one config value and only one of them is broken, which is exactly the shape that sends people looking at the broken feature's code instead of at the string all three of them read.
The neighbouring mistakes are the same mistake wearing a different hat. A U-prefixed user ID in the channel slot does not error at all on chat.postMessage — Slack opens a DM and delivers the message to one person, which is a far worse outcome than a failure. A permalink pasted out of the browser carries the real ID inside it and matches nothing as a whole string. And on Enterprise Grid a name handed to an org-wide token comes back team_not_found, because the same name can exist in several workspaces and Slack will not guess.
Why it happens
IDs are stable, names are not. A channel ID is assigned once and never changes, survives renames, and survives a conversion from public to private. A name is a display label that any member with permission can change, and that is released for reuse when they do. Configuration that stores the label is storing the one field that is designed to move.
The tolerance is legacy, not policy. chat.postMessage resolves names because it predates the conversations.* family and Slack did not break it. Nothing in the documentation promises it will keep doing so, and no new method has been given the behaviour. Building on it means building on the one exception.
The grammar is checkable offline. Channel IDs match ^[CG][A-Z0-9]{7,}$, DMs start D, users start U or W. A regular expression over your configuration finds every one of these before a request is sent, which means the check belongs in the startup assertion rather than in the incident.
Resolving per message is its own bug. The tempting fix — keep names in config, look them up when sending — means paginating conversations.list on every send. That is a Tier 2 method over a workspace with thousands of channels, so the fix trades channel_not_found for ratelimited. Resolve once at startup, cache the ID, and treat the name as a comment.
A user ID in the channel slot succeeds. That is the case worth the extra branch in the classifier. There is no error to grep for, no failed run, no alert: the message is delivered, to one person, in a DM, and the channel it was meant for stays silent.
The fix, as a flow
The grammar settles most of this before a request is sent, which is why the fix branch is drawn as a sort rather than as a probe. The one network call exists to hand back the ID, and it only runs when something in the configuration still needs resolving.
How to fix it
Collect every place a channel is named
Environment variables, YAML, Terraform, database rows, the hardcoded string in the one script nobody owns. The script takes a JSON object mapping a config key to its value, because the finding has to be reported against the key a human can go and edit, not against an anonymous string.
Classify by shape before you classify by result
reference_form is pure and answers offline. An ID passes. A #name or a bare name is the headline case. A D is a DM conversation, a U or W is a person, and a permalink is an ID wrapped in a URL that no method will unwrap for you.
Print which methods accept that shape
This is the step that ends the argument about whether the value is wrong, because the same value is genuinely right for one method and wrong for another. Showing chat.postMessage: accepts beside conversations.history: refuses explains the intermittent failure in one line.
Resolve the names once, with one paginated sweep
conversations.list with exclude_archived=false, following next_cursor to the end. Include archived channels deliberately: a name that resolves only to an archived channel is a different finding from a name that resolves to nothing, and hiding the archive turns the first into the second.
Refuse to guess when two channels share a name
Names are unique among live channels, but an archived channel releases its name for reuse, so a sweep that includes archives can return two rows for one string. Report both and let a human choose. A script that silently picks the live one is a script that quietly rewrites where your alerts go.
Paste the IDs and keep the name as a comment
The script prints a ready config line: the ID as the value, the old name after a # so the file still reads like something a person wrote. Add a startup assertion on the ID grammar, and the next person who types a name gets a boot failure instead of a mystery.
How to check it worked
Re-run over the edited configuration. Every reference should classify as an ID and no lookup should be needed at all, which is visible in the run taking no network calls.
python3 slack_channel_reference_form.py --config channels.json
# canonical ALERTS_CHANNEL C01ABCDE9 accepted by every method family
# canonical DIGEST_CHANNEL C02XYZ123 accepted by every method family
# 2 reference(s) checked, 0 that are not channel IDs
The full code
Two of the three functions never touch the network. reference_form names the shape of a configured string, path_outcomes turns that shape into the per-method truth table that explains why one feature works and another does not, and resolve_name matches a name against the workspace inventory without picking a winner when there are two. The only I/O is one paginated conversations.list, and it runs only if something needs resolving.
"""Check that every Slack channel reference in your configuration is an ID.
Read only, and mostly offline: the shape of a reference settles most of the
finding before a request is sent. One paginated conversations.list resolves the
names that are left. Nothing is written; the replacement config line is printed
for you to paste.
"""
import argparse
import json
import logging
import os
import re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_channel_reference_form")
API = "https://slack.com/api/"
ID_RE = re.compile(r"^[CG][A-Z0-9]{7,}$")
DM_RE = re.compile(r"^D[A-Z0-9]{7,}$")
USER_RE = re.compile(r"^[UW][A-Z0-9]{7,}$")
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$")
LINK_RE = re.compile(r"/archives/([CGD][A-Z0-9]{7,})")
# chat.postMessage kept legacy name resolution from before the conversations.*
# family existed. Nothing else did. That split is the whole reason this survives
# code review: the send path works and the read path does not.
TOLERANT = ("chat.postMessage",)
STRICT = ("conversations.info", "conversations.history", "conversations.members",
"files.completeUploadExternal")
FAMILIES = TOLERANT + STRICT
NEEDS_LOOKUP = ("hash-name", "bare-name")
def reference_form(value):
"""Name the shape of one configured channel reference. Pure, so it is offline.
Returns (form, detail). Order matters: a permalink contains a valid ID and
would otherwise fall through to the malformed bucket, and a bare name is only
a name once every ID pattern has been ruled out.
"""
text = ("" if value is None else str(value)).strip()
if not text:
return ("empty", "no value at all. Something upstream resolved to an empty "
"string and the call will fail on every method.")
link = LINK_RE.search(text)
if link and not ID_RE.match(text):
return ("permalink",
"a channel permalink. The ID is inside it (%s) but no method will "
"unwrap a URL for you." % link.group(1))
if ID_RE.match(text):
return ("channel-id", "a channel ID, which is what every method wants")
if DM_RE.match(text):
return ("dm-id",
"a DM conversation ID. Every method accepts it and none of them "
"will tell you it reaches one person rather than a channel.")
if USER_RE.match(text):
return ("user-id",
"a user ID in the channel slot. chat.postMessage opens a DM and "
"delivers to that person, so there is no error to find.")
if text.startswith("#"):
body = text[1:]
if NAME_RE.match(body):
return ("hash-name",
"a channel name with the display hash. The hash is UI syntax; "
"no method has ever treated it as part of an identifier.")
return ("malformed", "starts with # but the rest is not a legal channel name")
if NAME_RE.match(text):
return ("bare-name",
"a channel name. Names are display labels: mutable, reusable, and "
"accepted by exactly one method family.")
return ("malformed",
"not an ID, not a legal channel name, and not a permalink. Whatever "
"built this string built it wrong.")
def path_outcomes(form):
"""Turn a reference shape into the per-method truth table. Pure.
Returns (verdict, rows) with rows as [(method, outcome), ...]. The verdict is
the half worth reading: `split-brain` means this exact string is correct for
one method family and wrong for another, which is why the failure looks
intermittent rather than configured.
"""
if form == "channel-id":
return ("canonical", [(m, "accepts") for m in FAMILIES])
if form in NEEDS_LOOKUP:
return ("split-brain",
[(m, "accepts") for m in TOLERANT] + [(m, "refuses") for m in STRICT])
if form == "user-id":
return ("wrong-entity",
[(m, "delivers elsewhere") for m in TOLERANT]
+ [(m, "refuses") for m in STRICT])
if form == "dm-id":
return ("not-a-channel", [(m, "accepts") for m in FAMILIES])
if form == "permalink":
return ("recoverable", [(m, "refuses") for m in FAMILIES])
return ("unusable", [(m, "refuses") for m in FAMILIES])
def resolve_name(value, channels):
"""Match a configured name against the workspace inventory. Pure.
Names are unique among live channels, but archiving releases a name for
reuse, so a sweep that includes archives can return two rows for one string.
Returns (state, channel, detail) and never picks a winner when the answer is
genuinely two channels.
"""
wanted = str(value).lstrip("#").strip().lower()
hits = [c for c in channels if str(c.get("name") or "").lower() == wanted]
live = [c for c in hits if not c.get("is_archived")]
if not hits:
return ("unresolved", None,
"no channel in the workspace carries this name. It was renamed, "
"deleted, or belongs to a workspace this token cannot see.")
if not live:
return ("archived-only", hits[0],
"the only match is archived (%s). The ID resolves and the channel "
"accepts nothing." % hits[0].get("id"))
if len(live) > 1:
return ("ambiguous", None,
"%d live channels answer to this name, which happens across an "
"Enterprise Grid org. A name cannot address one of them."
% len(live))
if len(hits) > 1:
return ("resolved-with-archived-twin", live[0],
"resolves to %s, and an archived channel holds the same name. The "
"name is already contested." % live[0].get("id"))
return ("resolved", live[0], "resolves to %s" % live[0].get("id"))
def config_line(key, channel, original):
"""The replacement line, with the old name kept as a comment. Pure."""
return "%s=%s # was %s, now #%s" % (key, channel.get("id"), original,
channel.get("name"))
def list_channels(session):
"""Every channel the token can enumerate, archives included. GET only."""
out, cursor = [], ""
while True:
params = {"types": "public_channel,private_channel",
"exclude_archived": "false", "limit": 1000}
if cursor:
params["cursor"] = cursor
body = session.get(API + "conversations.list", params=params, timeout=30).json()
if body.get("ok") is not True:
raise SystemExit("conversations.list answered 200 with ok: false, "
"error=%s" % body.get("error"))
out.extend(body.get("channels") or [])
cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
if not cursor:
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--config", required=True,
help="JSON object mapping a config key to its channel reference")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
args = ap.parse_args()
entries = json.loads(open(args.config, encoding="utf-8").read())
forms = {k: reference_form(v) for k, v in entries.items()}
channels = []
if any(f[0] in NEEDS_LOOKUP for f in forms.values()):
token = os.environ.get(args.token_env)
if not token:
log.error("set %s (channels:read and groups:read are enough)", args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
channels = list_channels(s)
log.info("inventory: %d channel(s) the token can enumerate", len(channels))
bad = 0
for key in sorted(entries):
value = entries[key]
form, detail = forms[key]
verdict, rows = path_outcomes(form)
if verdict == "canonical":
log.info("%-14s %-16s %-12s accepted by every method family",
verdict, key, value)
continue
bad += 1
log.warning("%-14s %-16s %-12s %s", verdict, key, value, detail)
for method, outcome in rows:
if outcome != "accepts":
log.warning(" %-30s %s", method, outcome)
if form == "permalink":
log.warning(" repair: %s=%s", key, LINK_RE.search(str(value)).group(1))
continue
if form not in NEEDS_LOOKUP:
log.warning(" repair: put a channel ID in %s; copy it from the channel "
"details panel in Slack", key)
continue
state, channel, why = resolve_name(value, channels)
log.warning(" lookup: %s -- %s", state, why)
if channel is not None and state != "archived-only":
log.warning(" repair: %s", config_line(key, channel, value))
elif state == "archived-only":
log.warning(" repair: this name only exists on an archived channel; "
"point %s at a live one", key)
else:
log.warning(" repair: find the channel in Slack, copy its ID from the "
"details panel, and store that in %s", key)
log.info("%d reference(s) checked, %d that are not channel IDs", len(entries), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Check that every Slack channel reference in your configuration is an ID.
*
* Read only, and mostly offline: the shape of a reference settles most of the
* finding before a request is sent. One paginated conversations.list resolves
* the names that are left. Nothing is written; the replacement config line is
* printed for you to paste.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
const ID_RE = /^[CG][A-Z0-9]{7,}$/;
const DM_RE = /^D[A-Z0-9]{7,}$/;
const USER_RE = /^[UW][A-Z0-9]{7,}$/;
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
const LINK_RE = /\/archives\/([CGD][A-Z0-9]{7,})/;
// chat.postMessage kept legacy name resolution from before the conversations.*
// family existed. Nothing else did. That split is the whole reason this survives
// code review: the send path works and the read path does not.
const TOLERANT = ['chat.postMessage'];
const STRICT = ['conversations.info', 'conversations.history',
'conversations.members', 'files.completeUploadExternal'];
const FAMILIES = [...TOLERANT, ...STRICT];
const NEEDS_LOOKUP = new Set(['hash-name', 'bare-name']);
/**
* Name the shape of one configured channel reference. Pure, so it is offline.
* Order matters: a permalink contains a valid ID and would otherwise fall through
* to the malformed bucket.
*/
export function referenceForm(value) {
const text = (value === null || value === undefined ? '' : String(value)).trim();
if (!text) {
return ['empty', 'no value at all. Something upstream resolved to an empty ' +
'string and the call will fail on every method.'];
}
const link = LINK_RE.exec(text);
if (link && !ID_RE.test(text)) {
return ['permalink',
`a channel permalink. The ID is inside it (${link[1]}) but no method will ` +
'unwrap a URL for you.'];
}
if (ID_RE.test(text)) return ['channel-id', 'a channel ID, which is what every method wants'];
if (DM_RE.test(text)) {
return ['dm-id',
'a DM conversation ID. Every method accepts it and none of them will tell ' +
'you it reaches one person rather than a channel.'];
}
if (USER_RE.test(text)) {
return ['user-id',
'a user ID in the channel slot. chat.postMessage opens a DM and delivers to ' +
'that person, so there is no error to find.'];
}
if (text.startsWith('#')) {
const body = text.slice(1);
if (NAME_RE.test(body)) {
return ['hash-name',
'a channel name with the display hash. The hash is UI syntax; no method ' +
'has ever treated it as part of an identifier.'];
}
return ['malformed', 'starts with # but the rest is not a legal channel name'];
}
if (NAME_RE.test(text)) {
return ['bare-name',
'a channel name. Names are display labels: mutable, reusable, and accepted ' +
'by exactly one method family.'];
}
return ['malformed',
'not an ID, not a legal channel name, and not a permalink. Whatever built ' +
'this string built it wrong.'];
}
/**
* Turn a reference shape into the per-method truth table. Pure.
* `split-brain` means this exact string is correct for one method family and
* wrong for another, which is why the failure looks intermittent.
*/
export function pathOutcomes(form) {
if (form === 'channel-id') return ['canonical', FAMILIES.map((m) => [m, 'accepts'])];
if (NEEDS_LOOKUP.has(form)) {
return ['split-brain', [
...TOLERANT.map((m) => [m, 'accepts']),
...STRICT.map((m) => [m, 'refuses']),
]];
}
if (form === 'user-id') {
return ['wrong-entity', [
...TOLERANT.map((m) => [m, 'delivers elsewhere']),
...STRICT.map((m) => [m, 'refuses']),
]];
}
if (form === 'dm-id') return ['not-a-channel', FAMILIES.map((m) => [m, 'accepts'])];
if (form === 'permalink') return ['recoverable', FAMILIES.map((m) => [m, 'refuses'])];
return ['unusable', FAMILIES.map((m) => [m, 'refuses'])];
}
/**
* Match a configured name against the workspace inventory. Pure.
* Never picks a winner when the answer is genuinely two channels.
*/
export function resolveName(value, channels) {
const wanted = String(value).replace(/^#+/, '').trim().toLowerCase();
const hits = channels.filter((c) => String(c.name ?? '').toLowerCase() === wanted);
const live = hits.filter((c) => !c.is_archived);
if (hits.length === 0) {
return ['unresolved', null,
'no channel in the workspace carries this name. It was renamed, deleted, or ' +
'belongs to a workspace this token cannot see.'];
}
if (live.length === 0) {
return ['archived-only', hits[0],
`the only match is archived (${hits[0].id}). The ID resolves and the channel ` +
'accepts nothing.'];
}
if (live.length > 1) {
return ['ambiguous', null,
`${live.length} live channels answer to this name, which happens across an ` +
'Enterprise Grid org. A name cannot address one of them.'];
}
if (hits.length > 1) {
return ['resolved-with-archived-twin', live[0],
`resolves to ${live[0].id}, and an archived channel holds the same name. The ` +
'name is already contested.'];
}
return ['resolved', live[0], `resolves to ${live[0].id}`];
}
/** The replacement line, with the old name kept as a comment. Pure. */
export function configLine(key, channel, original) {
return `${key}=${channel.id} # was ${original}, now #${channel.name}`;
}
async function listChannels(token) {
const out = [];
let cursor = '';
for (;;) {
const params = new URLSearchParams({
types: 'public_channel,private_channel',
exclude_archived: 'false',
limit: '1000',
});
if (cursor) params.set('cursor', cursor);
const res = await fetch(`${API}conversations.list?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
if (body.ok !== true) {
throw new Error(`conversations.list answered 200 with ok: false, error=${body.error}`);
}
out.push(...(body.channels ?? []));
cursor = body.response_metadata?.next_cursor ?? '';
if (!cursor) return out;
}
}
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 configPath = arg(args, '--config');
if (!configPath) {
console.error('usage: --config channels.json [--token-env SLACK_BOT_TOKEN]');
process.exitCode = 2;
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const entries = JSON.parse(await readFile(configPath, 'utf8'));
const forms = new Map(Object.entries(entries).map(([k, v]) => [k, referenceForm(v)]));
let channels = [];
if ([...forms.values()].some(([f]) => NEEDS_LOOKUP.has(f))) {
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} (channels:read and groups:read are enough)`);
process.exitCode = 2;
return;
}
channels = await listChannels(token);
console.log(`inventory: ${channels.length} channel(s) the token can enumerate`);
}
let bad = 0;
for (const key of Object.keys(entries).sort()) {
const value = entries[key];
const [form, detail] = forms.get(key);
const [verdict, rows] = pathOutcomes(form);
if (verdict === 'canonical') {
console.log(`${verdict.padEnd(14)} ${key.padEnd(16)} ${String(value).padEnd(12)} ` +
'accepted by every method family');
continue;
}
bad += 1;
console.warn(`${verdict.padEnd(14)} ${key.padEnd(16)} ${String(value).padEnd(12)} ${detail}`);
for (const [method, outcome] of rows) {
if (outcome !== 'accepts') console.warn(` ${method.padEnd(30)} ${outcome}`);
}
if (form === 'permalink') {
console.warn(` repair: ${key}=${LINK_RE.exec(String(value))[1]}`);
continue;
}
if (!NEEDS_LOOKUP.has(form)) {
console.warn(` repair: put a channel ID in ${key}; copy it from the channel ` +
'details panel in Slack');
continue;
}
const [state, channel, why] = resolveName(value, channels);
console.warn(` lookup: ${state} -- ${why}`);
if (channel !== null && state !== 'archived-only') {
console.warn(` repair: ${configLine(key, channel, value)}`);
} else if (state === 'archived-only') {
console.warn(' repair: this name only exists on an archived channel; point ' +
`${key} at a live one`);
} else {
console.warn(' repair: find the channel in Slack, copy its ID from the details ' +
`panel, and store that in ${key}`);
}
}
console.log(`${Object.keys(entries).length} reference(s) checked, ${bad} that are ` +
'not channel IDs');
process.exitCode = bad ? 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 config.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The case worth pinning hardest is the user ID, because it is the only shape here that produces no error anywhere: chat.postMessage takes a U value and delivers a DM. The tests assert that it classifies as wrong-entity rather than joining the names in the split-brain bucket, and that a name matching both a live and an archived channel is reported as contested instead of quietly resolved.
from slack_channel_reference_form import (config_line, path_outcomes,
reference_form, resolve_name)
LIVE = {"id": "C01ABCDE9", "name": "alerts", "is_archived": False}
DEAD = {"id": "C09OLDONE", "name": "alerts", "is_archived": True}
def test_a_channel_id_is_the_canonical_form():
form, _ = reference_form("C01ABCDE9")
assert form == "channel-id"
assert path_outcomes(form)[0] == "canonical"
def test_a_hash_name_is_accepted_by_one_family_and_refused_by_the_rest():
form, _ = reference_form("#alerts")
assert form == "hash-name"
verdict, rows = path_outcomes(form)
assert verdict == "split-brain"
assert dict(rows)["chat.postMessage"] == "accepts"
assert dict(rows)["conversations.history"] == "refuses"
def test_a_bare_name_lands_in_the_same_bucket_as_a_hash_name():
assert reference_form("alerts")[0] == "bare-name"
assert path_outcomes("bare-name")[0] == "split-brain"
def test_a_user_id_is_not_a_name_problem_because_nothing_errors():
form, detail = reference_form("U024BE7LH")
assert form == "user-id"
assert "DM" in detail
verdict, rows = path_outcomes(form)
assert verdict == "wrong-entity"
assert dict(rows)["chat.postMessage"] == "delivers elsewhere"
def test_a_permalink_carries_the_id_it_needs():
form, detail = reference_form("https://acme.slack.com/archives/C01ABCDE9")
assert form == "permalink"
assert "C01ABCDE9" in detail
def test_an_empty_string_is_reported_as_its_own_shape():
assert reference_form("")[0] == "empty"
assert reference_form(None)[0] == "empty"
assert path_outcomes("empty")[0] == "unusable"
def test_a_name_resolves_to_the_live_channel():
state, channel, _ = resolve_name("#alerts", [LIVE])
assert state == "resolved"
assert channel["id"] == "C01ABCDE9"
def test_a_name_held_by_a_live_and_an_archived_channel_is_reported_as_contested():
state, channel, detail = resolve_name("alerts", [DEAD, LIVE])
assert state == "resolved-with-archived-twin"
assert channel["id"] == "C01ABCDE9"
assert "contested" in detail
def test_a_name_that_only_matches_an_archive_is_not_a_resolution():
state, _, _ = resolve_name("alerts", [DEAD])
assert state == "archived-only"
def test_two_live_channels_with_one_name_are_never_silently_picked():
twin = dict(LIVE, id="C07OTHER1")
state, channel, _ = resolve_name("alerts", [LIVE, twin])
assert state == "ambiguous"
assert channel is None
def test_the_repair_line_keeps_the_old_name_as_a_comment():
line = config_line("ALERTS_CHANNEL", LIVE, "#alerts")
assert line.startswith("ALERTS_CHANNEL=C01ABCDE9")
assert "#alerts" in line
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { configLine, pathOutcomes, referenceForm, resolveName }
from './slack-channel-reference-form.mjs';
const LIVE = { id: 'C01ABCDE9', name: 'alerts', is_archived: false };
const DEAD = { id: 'C09OLDONE', name: 'alerts', is_archived: true };
test('a channel id is the canonical form', () => {
const [form] = referenceForm('C01ABCDE9');
assert.equal(form, 'channel-id');
assert.equal(pathOutcomes(form)[0], 'canonical');
});
test('a hash name is accepted by one family and refused by the rest', () => {
const [form] = referenceForm('#alerts');
assert.equal(form, 'hash-name');
const [verdict, rows] = pathOutcomes(form);
assert.equal(verdict, 'split-brain');
const table = Object.fromEntries(rows);
assert.equal(table['chat.postMessage'], 'accepts');
assert.equal(table['conversations.history'], 'refuses');
});
test('a bare name lands in the same bucket as a hash name', () => {
assert.equal(referenceForm('alerts')[0], 'bare-name');
assert.equal(pathOutcomes('bare-name')[0], 'split-brain');
});
test('a user id is not a name problem because nothing errors', () => {
const [form, detail] = referenceForm('U024BE7LH');
assert.equal(form, 'user-id');
assert.match(detail, /DM/);
const [verdict, rows] = pathOutcomes(form);
assert.equal(verdict, 'wrong-entity');
assert.equal(Object.fromEntries(rows)['chat.postMessage'], 'delivers elsewhere');
});
test('a permalink carries the id it needs', () => {
const [form, detail] = referenceForm('https://acme.slack.com/archives/C01ABCDE9');
assert.equal(form, 'permalink');
assert.match(detail, /C01ABCDE9/);
});
test('an empty string is reported as its own shape', () => {
assert.equal(referenceForm('')[0], 'empty');
assert.equal(referenceForm(null)[0], 'empty');
assert.equal(pathOutcomes('empty')[0], 'unusable');
});
test('a name resolves to the live channel', () => {
const [state, channel] = resolveName('#alerts', [LIVE]);
assert.equal(state, 'resolved');
assert.equal(channel.id, 'C01ABCDE9');
});
test('a name held by a live and an archived channel is reported as contested', () => {
const [state, channel, detail] = resolveName('alerts', [DEAD, LIVE]);
assert.equal(state, 'resolved-with-archived-twin');
assert.equal(channel.id, 'C01ABCDE9');
assert.match(detail, /contested/);
});
test('a name that only matches an archive is not a resolution', () => {
assert.equal(resolveName('alerts', [DEAD])[0], 'archived-only');
});
test('two live channels with one name are never silently picked', () => {
const twin = { ...LIVE, id: 'C07OTHER1' };
const [state, channel] = resolveName('alerts', [LIVE, twin]);
assert.equal(state, 'ambiguous');
assert.equal(channel, null);
});
test('the repair line keeps the old name as a comment', () => {
const line = configLine('ALERTS_CHANNEL', LIVE, '#alerts');
assert.ok(line.startsWith('ALERTS_CHANNEL=C01ABCDE9'));
assert.match(line, /#alerts/);
});
FAQ
Why does chat.postMessage accept #alerts when conversations.info does not?
Because chat.postMessage predates the conversations.* family and Slack kept its legacy name resolution for compatibility. The newer methods were never given the behaviour, and nothing in the documentation promises the old one keeps it. Treating that single exception as the rule is what produces a configuration that works on the send path and fails on every read.
Is there a method that converts a channel name into an ID?
No, and that absence is deliberate rather than an oversight. The supported way is to paginate conversations.list and match on the name field yourself. Do it once at startup and cache the ID: doing it per message means a Tier 2 call over every channel in the workspace on every send, which trades channel_not_found for ratelimited.
Where do I find a channel ID without writing code?
Open the channel in Slack, click the channel name to open its details, and the ID is at the bottom of that panel with a copy button. The web client also puts it in the URL after /archives/, which is why permalinks turn up in configuration files: the ID a developer needed was visible in the address bar.
Could channel_not_found mean something other than a name in an ID slot?
Yes, and the script does not claim otherwise. The same error covers a private channel the token has no scope to see, a channel in a different workspace on Enterprise Grid, and a deleted channel. Ruling out the reference form first is worthwhile because it is the only one of those you can settle offline, in a regular expression, before making any request.
What happens if I put a user ID in the channel field?
chat.postMessage opens a DM with that person and delivers the message, and returns ok: true. There is no error, no failed run and no alert. Meanwhile the channel that was supposed to receive it stays silent. It is the only shape in this note that is more dangerous than an outright failure, which is why the classifier gives it a verdict of its own.
Related field notes
- the ID is right and the bot is not a member
- every failure arrives as HTTP 200
- next_cursor and the channels you never saw
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.list method reference — Slack Docs
- conversations.info method reference — Slack Docs
- chat.postMessage 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.