Diagnostic Slack
posting_to_general_channel_denied: the default channel
Every other channel takes the message. The one that does not is the channel the integration was pointed at on the afternoon somebody wired it up, because it was the channel that already existed and already had everyone in it. The webhook comes back HTTP 403 with three words of plain text. chat.postMessage comes back restricted_action. No scope you add will move either of them.
Neither will renaming anything, and that is the part worth knowing before you start: the channel this is about is not necessarily called #general.
A workspace has exactly one default channel, and it is the only channel in Slack whose posting rules live in workspace settings rather than in the channel. An admin sets them under Settings & administration, Workspace settings, Permissions, Messaging, and apps are routinely left off the list. A bot token cannot read that preference at all — there is no method for it, and there is not going to be one.
What a bot token can read is which channel it is. conversations.info and conversations.list both return is_general, and that boolean is the whole detection. It survives the rename that the name does not: the default channel can be called #team-hq, and an ordinary channel can be sitting next to it called #general. The script below finds the channel carrying the flag, reports any configured target that lands on it, and attributes an error you already have in your logs to that policy or to something else entirely.
The problem in plain words
Integrations default to the default channel because it is the one room that is guaranteed to exist and guaranteed to contain the audience. A quickstart posts to #general. A Terraform module has it as the fallback. A colleague sets up an alert at four on a Friday and picks the channel everyone is in. None of that is unreasonable, and all of it ages badly: a workspace that grows past a couple of hundred people gets its default channel locked down, which is the correct thing for an admin to do, and the integration nobody remembers is the one that stops.
The refusal then looks different depending on which surface sent the message. chat.postMessage returns the usual HTTP 200 with restricted_action in the body — an error string it shares with per-channel posting restrictions, so it does not identify the cause. An incoming webhook returns a genuine HTTP 403 whose body is the plain string posting_to_general_channel_denied, not JSON at all. A client that calls .json() on every response throws a parse error there and the incident report says "malformed response from Slack", which is how a permissions problem gets triaged as a network one.
And then the name. The default channel can be renamed, which releases the string general for anyone to claim, so a workspace can perfectly well have a default channel called #team-hq and an ordinary, entirely unrestricted channel called #general. A configuration audit that greps for the word gets that backwards in both directions at once: it flags the harmless channel and misses the restricted one.
Why it happens
The policy is not in the API, and the channel is. No read method returns "who may post in the default channel", so no script can tell you whether this app is on the list. What every script can tell you is that a target carries is_general, which is a warning worth raising on its own, because the restriction is the normal end state for that channel rather than an unusual one.
is_general is the stable handle; the name is not. The flag is set at workspace creation and follows the channel through renames. Anything that identifies the default channel by its name is identifying a label that a workspace admin can move in ten seconds and that a different channel can then adopt.
There can be more than one. is_general is a per-workspace property. An org-wide token on Enterprise Grid enumerates channels across several workspaces, so the honest answer to "which one is the default" is a list, and a script that takes the first match is right only until the second workspace is added.
The audience is the reason it is locked, so asking for an exception rarely works. The default channel contains every member of the workspace and cannot be narrowed. A restriction on it is an audience decision, not a security one, and the argument "our bot is fine" does not engage with it. Moving the integration to a purpose-built channel is both the faster repair and the better one.
Bare restricted_action is ambiguous, and is_general disambiguates it. The same string comes back from a per-channel posting restriction, which is a different admin screen and a different note. Attributing it correctly is the difference between emailing a workspace admin and asking a channel owner.
The fix, as a flow
The chain is drawn as an investigation walking away from the answer: the refusal is real, the surface that reported it mangled the body, and every subsequent step is spent on a grant that has nothing to do with it. The fix branch sorts on one boolean, and gives the channel that merely carries the name a row of its own.
How to fix it
Find the default channel instead of assuming its name
One paginated conversations.list, then filter on is_general. This is the only step that has to happen before anything else, because every later verdict is measured against the answer. Keep the result as a list: an org-wide Grid token will return one per workspace.
Resolve targets by ID first, then by name
Configuration holds both, and the check has to work on either or it will not get run. Resolving by ID is exact; resolving by name is what surfaces the interesting case, where the string in the config and the channel carrying the flag are two different rooms.
Count the audience you would have been posting to
conversations.info with include_num_members=true. It costs one call and it changes the conversation: "this posts to all 812 people in the workspace" is an argument a reader acts on, where "this targets the default channel" is a line in a report.
Attribute the refusal you already recorded, offline
The script takes the error your integration logged and says what it means. It does not send a message to find out, and neither should you: discovering whether you may post by posting is the one experiment in this section that puts a message in front of a few hundred people to answer a question a boolean already answered.
Move the integration rather than the policy
The script prints both repairs and puts them in that order deliberately. A purpose-built channel is a change you can make today and it improves the alerting regardless. Relaxing the workspace restriction needs an admin, needs a reason, and widens who may post in the room that reaches everybody.
Assert the flag at boot
Add is_general to the same startup check that already validates the token, and refuse to start when a send target carries it. That turns a class of misconfiguration into a deploy-time failure, which is the only time anybody is looking.
How to check it worked
Re-run against the repointed configuration. Every target should come back ordinary, with a member count that is visibly smaller than the workspace.
python3 slack_general_channel_target.py C0ALERTS99 C0DEPLOY11
# default channel C0TEAMHQ11 #team-hq 812 member(s)
# ordinary C0ALERTS99 #alerts is not the workspace default, 34 member(s)
# ordinary C0DEPLOY11 #deploys is not the workspace default, 12 member(s)
# 2 target(s) checked, 0 on or confusable with the workspace default
The full code
Three pure functions and two GET methods. default_channels returns a list rather than a channel, because the flag is per workspace and a Grid token sees several. target_verdict sorts one configured target against that list and keeps the #general-that-is-not-the-default case in a bucket of its own. attribute_refusal is completely offline: it reads an error you already have, which is how this script answers a question about posting without posting.
"""Find the integration targets that point at the workspace default channel.
Read only. One paginated conversations.list finds the channel carrying
is_general, one conversations.info per target adds the size of the audience.
Nothing is sent: who may post in the default channel is a workspace preference
no bot token can read, so this reports which target lands on that channel and
prints the admin path that would have to change if it stays there.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_general_channel_target")
API = "https://slack.com/api/"
# Refusals an integration writes to its log. Only the first belongs exclusively
# to the default channel policy. The restricted_action family is a per-channel
# posting permission wearing nearly the same words, and the rest are not posting
# policy at all.
REFUSALS = {
"posting_to_general_channel_denied": (
"default-channel-policy",
"the workspace restricts who may post in its default channel and this app "
"is not on the list. Incoming webhooks return it as a real HTTP 403 with a "
"plain-text body rather than JSON, so a client that parses every response "
"will report it as a transport failure."),
"restricted_action": (
"ambiguous-policy",
"returned both by the default-channel restriction and by a per-channel "
"posting restriction. Whether the target carries is_general is what "
"decides which admin screen this is about."),
"restricted_action_read_only_channel": (
"channel-policy",
"a per-channel posting restriction, not the default-channel one. The "
"repair is a channel setting and a channel owner."),
"restricted_action_thread_only_channel": (
"channel-policy",
"the channel accepts replies and refuses top-level posts. Nothing to do "
"with the default channel, and nothing to do with scopes either."),
"restricted_action_non_threadable_channel": (
"channel-policy",
"the channel refuses threaded replies. A posting-mode mismatch rather "
"than a permission."),
"restricted_action_thread_locked": (
"channel-policy",
"one thread was locked. The channel itself is still writable."),
"not_in_channel": (
"membership",
"the app is not a member. That is a membership finding and it applies to "
"any channel, default or not."),
"is_archived": (
"channel-state",
"the channel is frozen. It refuses everyone, including workspace owners."),
"channel_not_found": (
"visibility",
"the ID did not resolve for this token. Slack will not say whether that "
"is a wrong ID or a channel the token may not know about."),
"missing_scope": (
"scopes",
"a grant problem, and the only one on this list a reinstall fixes."),
}
REPAIR_MOVE = ("point the integration at a purpose-built channel; the default "
"channel is every person in the workspace and cannot be narrowed")
REPAIR_POLICY = ("if it has to stay, an admin opens Settings and administration, "
"Workspace settings, Permissions, Messaging, and adds apps to the "
"people who may post in the default channel")
def default_channels(channels):
"""Every channel carrying is_general. Pure, and a list on purpose.
is_general is a per-workspace property. An org-wide Enterprise Grid token
enumerates several workspaces in one sweep, so there is genuinely more than
one answer, and none of them has to be named "general": the default channel
can be renamed, after which any channel may claim the name it released.
"""
return [c for c in channels if c.get("is_general") is True]
def target_verdict(target, channels):
"""Sort one configured target against the workspace default. Pure.
Accepts an ID or a name because configuration holds both, and because the
name is exactly the thing that cannot be trusted here. Returns
(verdict, channel, detail).
"""
wanted = str(target or "").strip()
if not wanted:
return ("unresolved", None,
"an empty target. Something upstream resolved to nothing and the "
"send will fail before any policy is consulted.")
key = wanted.lstrip("#").lower()
hit = next((c for c in channels if str(c.get("id") or "") == wanted), None)
if hit is None:
hit = next((c for c in channels
if str(c.get("name") or "").lower() == key), None)
if hit is None:
return ("unresolved", None,
"nothing in the sweep answers to %s, so this check cannot say "
"whether it is the default channel. Resolve the reference first."
% wanted)
name = str(hit.get("name") or "?")
if hit.get("is_general") is True:
return ("default-channel", hit,
"#%s carries is_general. It is the workspace default channel, it "
"holds every member of the workspace, and who may post in it is a "
"workspace preference this token cannot read." % name)
if key == "general" or name.lower() == "general":
others = default_channels(channels)
instead = ("#" + str(others[0].get("name"))) if others else "a channel this token cannot see"
return ("general-by-name-only", hit,
"#general here is an ordinary channel: the workspace default is "
"%s. The default-channel restriction does not apply to this one, "
"and an audit that greps configuration for the word general gets "
"both channels backwards at once." % instead)
return ("ordinary", hit, "#%s is not the workspace default" % name)
def attribute_refusal(error, status=None):
"""Attribute a refusal the integration already recorded. Pure and offline.
This script never sends a message, so it cannot produce one of these itself.
Finding out whether you may post by posting is the experiment that puts a
test message in front of everybody in the workspace to learn what one boolean
already told you.
"""
text = str(error or "").strip()
if status == 403 and "general" in text.lower():
text = "posting_to_general_channel_denied"
known = REFUSALS.get(text)
if known is not None:
return known
if text.startswith("restricted_action"):
return ("channel-policy",
"an unfamiliar restricted_action variant. Every member of that "
"family is a posting policy rather than a grant, so no scope "
"change and no reinstall will move it.")
if not text:
return ("none-recorded",
"no error was supplied, so nothing was attributed. The structural "
"finding above stands on its own.")
return ("unattributed",
"%s is not a posting restriction. Whatever stopped the message, the "
"default channel policy is not it." % text)
def sweep(session):
"""Every channel the token can enumerate. GET only, cursor paginated."""
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 member_count(session, cid):
"""How many people the message would reach. GET only, best effort."""
body = session.get(API + "conversations.info",
params={"channel": cid, "include_num_members": "true"},
timeout=30).json()
if body.get("ok") is not True:
return "?"
return (body.get("channel") or {}).get("num_members", "?")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("targets", nargs="+",
help="channel IDs or names the integration posts to")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
ap.add_argument("--observed-error", default="",
help="an error the integration already logged, attributed offline")
ap.add_argument("--observed-status", type=int, default=0,
help="the HTTP status that came with it, if a webhook sent it")
args = ap.parse_args()
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 = sweep(s)
defaults = default_channels(channels)
if not defaults:
log.warning("no channel in the sweep carries is_general, so this token "
"cannot see the workspace default. Every verdict below is "
"limited to the channels it can enumerate")
for d in defaults:
log.info("default channel %-12s #%-18s %s member(s)", d.get("id"),
d.get("name"), member_count(s, d.get("id")))
hits = 0
for target in args.targets:
verdict, channel, detail = target_verdict(target, channels)
if verdict == "ordinary":
log.info("%-20s %-12s %s, %s member(s)", verdict, channel.get("id"),
detail, member_count(s, channel.get("id")))
continue
hits += 1
cid = (channel or {}).get("id") or target
log.warning("%-20s %-12s %s", verdict, cid, detail)
if verdict == "default-channel":
log.warning(" reaches: %s member(s), which is the whole workspace",
member_count(s, cid))
log.warning(" repair: %s", REPAIR_MOVE)
log.warning(" repair: %s", REPAIR_POLICY)
elif verdict == "general-by-name-only":
log.warning(" repair: store %s as the ID and stop matching on the "
"name; the two questions have drifted apart in this "
"workspace", cid)
else:
log.warning(" repair: resolve the reference to a channel ID first, "
"then re-run this check")
if args.observed_error or args.observed_status:
source, why = attribute_refusal(args.observed_error,
args.observed_status or None)
log.warning("recorded refusal %-22s %s", source, why)
log.info("%d target(s) checked, %d on or confusable with the workspace default",
len(args.targets), hits)
return 1 if hits else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find the integration targets that point at the workspace default channel.
*
* Read only. One paginated conversations.list finds the channel carrying
* is_general, one conversations.info per target adds the size of the audience.
* Nothing is sent: who may post in the default channel is a workspace
* preference no bot token can read, so this reports which target lands on that
* channel and prints the admin path that would have to change.
*/
const API = 'https://slack.com/api/';
// Refusals an integration writes to its log. Only the first belongs exclusively
// to the default channel policy. The restricted_action family is a per-channel
// posting permission wearing nearly the same words.
const REFUSALS = new Map([
['posting_to_general_channel_denied', ['default-channel-policy',
'the workspace restricts who may post in its default channel and this app is ' +
'not on the list. Incoming webhooks return it as a real HTTP 403 with a ' +
'plain-text body rather than JSON, so a client that parses every response ' +
'will report it as a transport failure.']],
['restricted_action', ['ambiguous-policy',
'returned both by the default-channel restriction and by a per-channel ' +
'posting restriction. Whether the target carries is_general is what decides ' +
'which admin screen this is about.']],
['restricted_action_read_only_channel', ['channel-policy',
'a per-channel posting restriction, not the default-channel one. The repair ' +
'is a channel setting and a channel owner.']],
['restricted_action_thread_only_channel', ['channel-policy',
'the channel accepts replies and refuses top-level posts. Nothing to do with ' +
'the default channel, and nothing to do with scopes either.']],
['restricted_action_non_threadable_channel', ['channel-policy',
'the channel refuses threaded replies. A posting-mode mismatch rather than a ' +
'permission.']],
['restricted_action_thread_locked', ['channel-policy',
'one thread was locked. The channel itself is still writable.']],
['not_in_channel', ['membership',
'the app is not a member. That is a membership finding and it applies to any ' +
'channel, default or not.']],
['is_archived', ['channel-state',
'the channel is frozen. It refuses everyone, including workspace owners.']],
['channel_not_found', ['visibility',
'the ID did not resolve for this token. Slack will not say whether that is a ' +
'wrong ID or a channel the token may not know about.']],
['missing_scope', ['scopes',
'a grant problem, and the only one on this list a reinstall fixes.']],
]);
const REPAIR_MOVE = 'point the integration at a purpose-built channel; the ' +
'default channel is every person in the workspace and cannot be narrowed';
const REPAIR_POLICY = 'if it has to stay, an admin opens Settings and ' +
'administration, Workspace settings, Permissions, Messaging, and adds apps to ' +
'the people who may post in the default channel';
/**
* Every channel carrying is_general. Pure, and a list on purpose: the flag is
* per workspace and an org-wide Grid token enumerates several at once.
*/
export function defaultChannels(channels) {
return channels.filter((c) => c.is_general === true);
}
/**
* Sort one configured target against the workspace default. Pure.
* Accepts an ID or a name, because the name is exactly the thing that cannot be
* trusted here.
*/
export function targetVerdict(target, channels) {
const wanted = String(target ?? '').trim();
if (!wanted) {
return ['unresolved', null,
'an empty target. Something upstream resolved to nothing and the send will ' +
'fail before any policy is consulted.'];
}
const key = wanted.replace(/^#+/, '').toLowerCase();
let hit = channels.find((c) => String(c.id ?? '') === wanted) ?? null;
if (hit === null) {
hit = channels.find((c) => String(c.name ?? '').toLowerCase() === key) ?? null;
}
if (hit === null) {
return ['unresolved', null,
`nothing in the sweep answers to ${wanted}, so this check cannot say whether ` +
'it is the default channel. Resolve the reference first.'];
}
const name = String(hit.name ?? '?');
if (hit.is_general === true) {
return ['default-channel', hit,
`#${name} carries is_general. It is the workspace default channel, it holds ` +
'every member of the workspace, and who may post in it is a workspace ' +
'preference this token cannot read.'];
}
if (key === 'general' || name.toLowerCase() === 'general') {
const others = defaultChannels(channels);
const instead = others.length > 0
? `#${others[0].name}` : 'a channel this token cannot see';
return ['general-by-name-only', hit,
`#general here is an ordinary channel: the workspace default is ${instead}. ` +
'The default-channel restriction does not apply to this one, and an audit ' +
'that greps configuration for the word general gets both channels backwards ' +
'at once.'];
}
return ['ordinary', hit, `#${name} is not the workspace default`];
}
/**
* Attribute a refusal the integration already recorded. Pure and offline.
* This script never sends a message, so it cannot produce one of these itself.
*/
export function attributeRefusal(error, status = null) {
let text = String(error ?? '').trim();
if (status === 403 && text.toLowerCase().includes('general')) {
text = 'posting_to_general_channel_denied';
}
const known = REFUSALS.get(text);
if (known !== undefined) return known;
if (text.startsWith('restricted_action')) {
return ['channel-policy',
'an unfamiliar restricted_action variant. Every member of that family is a ' +
'posting policy rather than a grant, so no scope change and no reinstall ' +
'will move it.'];
}
if (!text) {
return ['none-recorded',
'no error was supplied, so nothing was attributed. The structural finding ' +
'above stands on its own.'];
}
return ['unattributed',
`${text} is not a posting restriction. Whatever stopped the message, the ` +
'default channel policy is not it.'];
}
async function sweep(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;
}
}
async function memberCount(token, cid) {
const params = new URLSearchParams({ channel: cid, include_num_members: 'true' });
const res = await fetch(`${API}conversations.info?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
if (body.ok !== true) return '?';
return body.channel?.num_members ?? '?';
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
/** Everything that is not a flag and not the value of one. */
function positionals(args) {
const out = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i].startsWith('--')) { i += 1; continue; }
out.push(args[i]);
}
return out;
}
async function main() {
const args = process.argv.slice(2);
const targets = positionals(args);
if (targets.length === 0) {
console.error('usage: <channel id or name>... [--token-env SLACK_BOT_TOKEN] ' +
'[--observed-error restricted_action] [--observed-status 403]');
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 are enough)`);
process.exitCode = 2;
return;
}
const channels = await sweep(token);
const defaults = defaultChannels(channels);
if (defaults.length === 0) {
console.warn('no channel in the sweep carries is_general, so this token cannot ' +
'see the workspace default. Every verdict below is limited to the channels ' +
'it can enumerate');
}
for (const d of defaults) {
console.log(`default channel ${String(d.id).padEnd(12)} ` +
`#${String(d.name).padEnd(18)} ${await memberCount(token, d.id)} member(s)`);
}
let hits = 0;
for (const target of targets) {
const [verdict, channel, detail] = targetVerdict(target, channels);
if (verdict === 'ordinary') {
console.log(`${verdict.padEnd(20)} ${String(channel.id).padEnd(12)} ${detail}, ` +
`${await memberCount(token, channel.id)} member(s)`);
continue;
}
hits += 1;
const cid = channel?.id ?? target;
console.warn(`${verdict.padEnd(20)} ${String(cid).padEnd(12)} ${detail}`);
if (verdict === 'default-channel') {
console.warn(` reaches: ${await memberCount(token, cid)} member(s), which is ` +
'the whole workspace');
console.warn(` repair: ${REPAIR_MOVE}`);
console.warn(` repair: ${REPAIR_POLICY}`);
} else if (verdict === 'general-by-name-only') {
console.warn(` repair: store ${cid} as the ID and stop matching on the name; ` +
'the two questions have drifted apart in this workspace');
} else {
console.warn(' repair: resolve the reference to a channel ID first, then ' +
're-run this check');
}
}
const observedError = arg(args, '--observed-error', '');
const observedStatus = Number(arg(args, '--observed-status', 0));
if (observedError || observedStatus) {
const [source, why] = attributeRefusal(observedError, observedStatus || null);
console.warn(`recorded refusal ${source.padEnd(22)} ${why}`);
}
console.log(`${targets.length} target(s) checked, ${hits} on or confusable with ` +
'the workspace default');
process.exitCode = hits ? 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 token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two fixtures carry the whole point: a default channel named #team-hq and an ordinary channel named #general, in the same workspace, which is the arrangement every name-based check gets wrong. The tests pin that target_verdict flags the first and explicitly clears the second, that default_channels returns both entries when a Grid sweep contains two workspaces, and that a bare restricted_action is reported as ambiguous rather than confidently attributed to the default channel.
from slack_general_channel_target import (attribute_refusal, default_channels,
target_verdict)
# The arrangement that defeats every name-based check: the default channel was
# renamed and an ordinary channel picked up the name it released.
DEFAULT = {"id": "C0TEAMHQ11", "name": "team-hq", "is_general": True}
DECOY = {"id": "C0GENERAL1", "name": "general", "is_general": False}
ALERTS = {"id": "C0ALERTS99", "name": "alerts", "is_general": False}
WORKSPACE = [DEFAULT, DECOY, ALERTS]
def test_the_default_channel_is_found_by_its_flag_not_its_name():
assert default_channels(WORKSPACE) == [DEFAULT]
def test_a_grid_sweep_can_hold_more_than_one_default_channel():
other = {"id": "C0SECOND11", "name": "general", "is_general": True}
assert default_channels(WORKSPACE + [other]) == [DEFAULT, other]
def test_targeting_the_default_channel_by_id_is_the_headline_finding():
verdict, channel, detail = target_verdict("C0TEAMHQ11", WORKSPACE)
assert verdict == "default-channel"
assert channel["id"] == "C0TEAMHQ11"
assert "is_general" in detail
def test_targeting_the_default_channel_by_name_reaches_the_same_verdict():
assert target_verdict("#team-hq", WORKSPACE)[0] == "default-channel"
def test_a_channel_called_general_that_is_not_the_default_is_cleared():
verdict, channel, detail = target_verdict("#general", WORKSPACE)
assert verdict == "general-by-name-only"
assert channel["id"] == "C0GENERAL1"
assert "#team-hq" in detail
def test_an_ordinary_channel_is_ordinary():
verdict, channel, _ = target_verdict("C0ALERTS99", WORKSPACE)
assert verdict == "ordinary"
assert channel["name"] == "alerts"
def test_a_target_that_does_not_resolve_is_not_guessed_at():
verdict, channel, _ = target_verdict("C0NOTHERE1", WORKSPACE)
assert verdict == "unresolved"
assert channel is None
assert target_verdict("", WORKSPACE)[0] == "unresolved"
def test_the_webhook_403_is_attributed_to_the_default_channel_policy():
source, why = attribute_refusal("posting_to_general_channel_denied", 403)
assert source == "default-channel-policy"
assert "403" in why
def test_a_plain_text_403_body_is_recognised_even_when_it_is_not_the_error_name():
source, _ = attribute_refusal("posting to general channel denied", 403)
assert source == "default-channel-policy"
def test_bare_restricted_action_is_reported_as_ambiguous():
source, why = attribute_refusal("restricted_action")
assert source == "ambiguous-policy"
assert "is_general" in why
def test_the_read_only_variant_belongs_to_a_different_note():
assert attribute_refusal("restricted_action_read_only_channel")[0] == "channel-policy"
assert attribute_refusal("restricted_action_invented_variant")[0] == "channel-policy"
def test_a_membership_error_is_never_dressed_up_as_a_policy():
assert attribute_refusal("not_in_channel")[0] == "membership"
assert attribute_refusal("missing_scope")[0] == "scopes"
assert attribute_refusal("timeout")[0] == "unattributed"
assert attribute_refusal("")[0] == "none-recorded"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { attributeRefusal, defaultChannels, targetVerdict }
from './slack-general-channel-target.mjs';
// The arrangement that defeats every name-based check: the default channel was
// renamed and an ordinary channel picked up the name it released.
const DEFAULT = { id: 'C0TEAMHQ11', name: 'team-hq', is_general: true };
const DECOY = { id: 'C0GENERAL1', name: 'general', is_general: false };
const ALERTS = { id: 'C0ALERTS99', name: 'alerts', is_general: false };
const WORKSPACE = [DEFAULT, DECOY, ALERTS];
test('the default channel is found by its flag not its name', () => {
assert.deepEqual(defaultChannels(WORKSPACE), [DEFAULT]);
});
test('a grid sweep can hold more than one default channel', () => {
const other = { id: 'C0SECOND11', name: 'general', is_general: true };
assert.deepEqual(defaultChannels([...WORKSPACE, other]), [DEFAULT, other]);
});
test('targeting the default channel by id is the headline finding', () => {
const [verdict, channel, detail] = targetVerdict('C0TEAMHQ11', WORKSPACE);
assert.equal(verdict, 'default-channel');
assert.equal(channel.id, 'C0TEAMHQ11');
assert.match(detail, /is_general/);
});
test('targeting the default channel by name reaches the same verdict', () => {
assert.equal(targetVerdict('#team-hq', WORKSPACE)[0], 'default-channel');
});
test('a channel called general that is not the default is cleared', () => {
const [verdict, channel, detail] = targetVerdict('#general', WORKSPACE);
assert.equal(verdict, 'general-by-name-only');
assert.equal(channel.id, 'C0GENERAL1');
assert.match(detail, /#team-hq/);
});
test('an ordinary channel is ordinary', () => {
const [verdict, channel] = targetVerdict('C0ALERTS99', WORKSPACE);
assert.equal(verdict, 'ordinary');
assert.equal(channel.name, 'alerts');
});
test('a target that does not resolve is not guessed at', () => {
const [verdict, channel] = targetVerdict('C0NOTHERE1', WORKSPACE);
assert.equal(verdict, 'unresolved');
assert.equal(channel, null);
assert.equal(targetVerdict('', WORKSPACE)[0], 'unresolved');
});
test('the webhook 403 is attributed to the default channel policy', () => {
const [source, why] = attributeRefusal('posting_to_general_channel_denied', 403);
assert.equal(source, 'default-channel-policy');
assert.match(why, /403/);
});
test('a plain text 403 body is recognised even when it is not the error name', () => {
assert.equal(attributeRefusal('posting to general channel denied', 403)[0],
'default-channel-policy');
});
test('bare restricted_action is reported as ambiguous', () => {
const [source, why] = attributeRefusal('restricted_action');
assert.equal(source, 'ambiguous-policy');
assert.match(why, /is_general/);
});
test('the read only variant belongs to a different note', () => {
assert.equal(attributeRefusal('restricted_action_read_only_channel')[0],
'channel-policy');
assert.equal(attributeRefusal('restricted_action_invented_variant')[0],
'channel-policy');
});
test('a membership error is never dressed up as a policy', () => {
assert.equal(attributeRefusal('not_in_channel')[0], 'membership');
assert.equal(attributeRefusal('missing_scope')[0], 'scopes');
assert.equal(attributeRefusal('timeout')[0], 'unattributed');
assert.equal(attributeRefusal('')[0], 'none-recorded');
});
FAQ
Can a bot token read who is allowed to post in the default channel?
No. It is a workspace preference, it lives in the admin console, and there is no read method that returns it. That is why this check is structural rather than conclusive: it tells you a target carries is_general, which is the channel most likely to be restricted and the channel with the largest possible audience, and it leaves the certainty to the error your integration already recorded.
Why does the webhook return 403 when chat.postMessage returns 200?
Incoming webhooks are one of the few Slack surfaces that use real HTTP status codes, and they return a plain-text body rather than JSON. So the same restriction produces a 403 with the string posting_to_general_channel_denied through a webhook and an HTTP 200 carrying restricted_action through the Web API. Clients that assume JSON on every response tend to misreport the webhook case as a transport error.
Our default channel is not called #general. Does any of this still apply?
All of it, and more so. The restriction follows the is_general flag, not the name, so a renamed default channel is restricted exactly as before while every name-based check in your codebase stops finding it. The reverse case is just as common: a new channel claims the released name #general and gets flagged by audits that it has nothing to do with.
Should the script just try posting to find out?
No, and it is worth being explicit about why. A test post to the default channel is a message delivered to every person in the workspace, and it answers a question that conversations.info answers for free. Every script in this section holds a token that could post and none of them do; this is the note where that rule earns itself.
What should an integration post to instead?
A channel created for it, with the people who care about the alerts in it. That is a better outcome than an exception in the workspace posting policy even when the exception is easy to get: the default channel cannot be narrowed, so anything sent there is sent to everybody, and alert fatigue in the channel that reaches the whole company is its own incident.
Related field notes
- the same words, a per-channel restriction
- another channel that resolves and refuses
- every failure arrives as HTTP 200
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
- conversations.list method reference — Slack Docs
- chat.postMessage method reference — Slack Docs
- Sending messages using incoming webhooks — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.