Diagnostic Slack
messages_tab_disabled: the App Home DM surface is off
The onboarding flow is a conversation. The app DMs a new hire, asks four questions, and files the answers. It works beautifully in the demo, because the demo was recorded in the workspace where the app was first built and somebody clicked something there eighteen months ago.
In the customer's workspace it fails at the first message with {"ok": false, "error": "messages_tab_disabled"}, or it succeeds and then nothing comes back, because the person on the other end is looking at a greyed-out composer that says they cannot reply to this conversation. The bot has a DM surface. It is switched off, it was switched off by default the day the app was created, and nothing in the code has any way of knowing.
App Home has three tabs — Home, Messages and About — and the Messages tab is off by default. It is enabled under App Home → Show Tabs, and a second, separate checkbox controls whether people may type back. That gives three configurations that look identical from the outside and behave completely differently: no tab at all, a tab that reads like a broadcast channel, and a tab that is a conversation.
There is a third setting, and it is the one that catches teams who have already found the first two. Turning the tab on does not deliver anything. Replies reach your app only if message.im is in the event subscriptions and the bot token holds im:history. Enable the tab, uncheck read-only, and forget the subscription, and you get the worst version of this: users can type, they do type, and their messages go nowhere at all.
All three live in the app manifest, under features.app_home and settings.event_subscriptions.bot_events, so a single export settles it. The workspace corroborates without any configuration credential at all: list the app's IM conversations, read their history, and if every message in every DM was written by the app and none by a person, you are looking at a one-way surface.
The problem in plain words
The trap in this one is that the default is silence rather than an error. A brand new Slack app can post into channels the moment it is installed, so the first hour of building one teaches you that things work. The DM surface is the exception: it exists, it is visible to users, it appears in the sidebar under Apps, and it is inert until somebody opts in. Nothing in the app creation flow mentions it, and the code you write is identical either way.
The error itself only shows up on one of the three failure shapes. messages_tab_disabled comes back when the app tries to post into a DM with the tab switched off. With the tab on and read-only left checked, your outbound messages succeed perfectly — ok: true, a real ts, a message the user can see — and the failure is entirely on their side, where the composer is disabled and there is no error for anyone to read. Your logs are clean. The feature does not work.
The third shape is the cruellest, because everything visible is correct. Tab on, read-only off, users typing away, and no message.im in the subscription list. Slack accepts the message, shows it in the thread, and delivers it to nobody. From the user's chair the bot is ignoring them. From your chair the app is healthy and idle. The only way to see the gap is to hold the surface configuration against the event configuration, which are two different pages in the app settings and two different objects in the manifest.
And it is workspace-independent in a way that surprises people: this is app configuration, not workspace configuration, so it is the same for every install. If it is off, it is off for everyone who ever installs the app, including the customer who is about to renew. It also survives a reinstall, because a reinstall changes scopes and tokens, not surfaces.
Why it happens
Three settings have to agree, and no single one of them is the answer. A check that reports “the Messages tab is disabled” and stops will send somebody to flip one switch, watch users start typing, and file a second ticket a week later when the replies still go nowhere. So the script reads the pair of App Home flags and the event subscription together, and reports every one of them that is out of step in a single pass.
The read-only case has no error at all, so it must be read from configuration. This is the one shape that cannot be found by instrumenting your code, because your code succeeds. messages_tab_read_only_enabled is a boolean in the manifest and a checkbox in the console, and it is the entire explanation for “the bot talks and nobody can answer”.
The workspace can corroborate without any configuration credential. Most people running this script have a bot token and no app configuration token. So the second half of the check reads IM conversations and their history and classifies the traffic: two-way, outbound-only, silent or no-im-conversations. outbound-only across many DMs over a long window is a strong signal on its own, and it is available to everybody.
Configuration and behaviour are reported separately, then combined. They can disagree, and when they do the disagreement is informative. A correctly configured surface with nothing but outbound traffic is not this note — it means the surface is fine and the handler is not answering — and the script says exactly that instead of blaming a switch that is already on.
The repair is printed as manifest keys, not as a click path. A checkbox flipped in a web console exists in one place, is invisible to review, and is the reason this can regress silently on a second app created next quarter. The script prints the exact manifest lines to set, because a surface declared in a file that lives in your repository is a surface that stays declared.
This is not the note about opening a DM. If your app has never had a conversation with a user at all, the question may be that no IM channel was ever opened, which is a different failure with a different error. This note is about the surface being switched off once the conversation exists.
The fix, as a flow
The red arrow is at the very last hop, past the end of anything you operate. Your call succeeded, Slack stored the message, the user can read it, and the failure is a composer that will not accept a reply. That is why the boxes narrow towards a person rather than towards a server, and why nothing in this chain produces a log line. The fix branch sorts DM traffic instead of settings, because most readers have a bot token and no configuration token, and forty conversations in which no human has ever typed is an answer on its own.
How to fix it
Export the manifest, or run on behaviour alone
apps.manifest.export returns features.app_home and settings.event_subscriptions. It needs an app configuration token, which is a different credential class from your bot token. Without one the script skips straight to the DM traffic, which is the half most readers can run.
Read all three settings at once
app_home_findings returns every code that applies: messages-tab-off, messages-tab-read-only, no-message-im-subscription and no-im-history-scope. It returns a list rather than a verdict on purpose, because these co-occur and fixing one of them is how a team ends up filing this ticket twice.
Classify the DM traffic in the workspace
dm_shape takes the IM conversations and their messages and answers two-way, outbound-only, silent or no-im-conversations. Counted across conversations rather than within one, because a single quiet DM proves nothing and forty DMs in which no human has ever typed proves a great deal.
Hold the configuration against the behaviour
tab_verdict combines the two halves and is allowed to disagree with either. Its most useful answer is config-clean: the surface is set up correctly, the traffic is still one-way, and therefore the problem is in your handler rather than in your app configuration. That is the verdict that stops somebody flipping switches that are already on.
Subscribe to the event, not just the surface
The composer and the delivery path are separate settings on separate pages. message.im under Event Subscriptions and im:history in the bot scopes are what turn a user's reply into a request to your app. Adding scopes requires a reinstall, which is worth planning for rather than discovering.
Write the three settings into the manifest
repair_manifest prints the exact keys: features.app_home.messages_tab_enabled: true, messages_tab_read_only_enabled: false, and message.im in settings.event_subscriptions.bot_events. Put them in the manifest in your repository so the next app you create starts with a DM surface that works.
How to check it worked
Flip the switches, reinstall if the scope changed, and run it again after a few real conversations. dm_shape should move from outbound-only to two-way.
python3 slack_app_home_messages.py --app-id A05NW7XQ1 --max-conversations 40
# manifest ok features.app_home read for A05NW7XQ1
# config messages-tab-off the Messages tab is off, which is the default;
# the app cannot post into a DM at all
# config no-message-im-subscription
# message.im is not subscribed, so a reply typed
# by a user is delivered nowhere
# config no-im-history-scope im:history is not in the bot scopes, so the
# subscription could not deliver even if added
# im 37 conversation(s) 1,204 message(s) read
# traffic outbound-only 37 DM(s), 1,204 message(s) from the app and 0
# from a person
# verdict tab-off configuration and traffic agree
# repair: features.app_home.messages_tab_enabled: true
# repair: features.app_home.messages_tab_read_only_enabled: false
# repair: settings.event_subscriptions.bot_events: add message.im
# repair: oauth_config.scopes.bot: add im:history, then reinstall the app
The full code
The manifest half and the workspace half are deliberately independent, and either one runs without the other. app_home_findings returns a list because the three settings co-occur and a check that reported only the first would send you back here in a week. dm_shape counts across conversations rather than inside one. tab_verdict is where they meet, and its job is as much to say this is not the problem as to confirm that it is. repair_manifest turns whatever was found into the manifest lines that fix it.
"""Decide whether your app has a working DM surface, from both sides.
Read only. Up to three GET methods are used: apps.manifest.export with an app
configuration token for the App Home flags, and conversations.list and
conversations.history with a bot token for the traffic. Nothing is written, no
DM is opened, and no setting is changed - the repair is printed as manifest
keys for you to apply.
Either half runs without the other. Most people have a bot token and no app
configuration token, so the traffic classification is written to stand alone.
"""
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_app_home_messages")
API = "https://slack.com/api/"
# The event that carries a user's reply, and the scope without which the
# subscription cannot deliver it. Both are separate from the tab itself, which
# is the entire reason this note exists.
REPLY_EVENT = "message.im"
REPLY_SCOPE = "im:history"
# What each finding costs, and the manifest key that settles it. Keeping the
# repair next to the finding is what lets the script print manifest lines
# rather than a click path through a web console.
REPAIRS = {
"messages-tab-off": "features.app_home.messages_tab_enabled: true",
"messages-tab-read-only": "features.app_home.messages_tab_read_only_enabled: false",
"no-message-im-subscription":
"settings.event_subscriptions.bot_events: add message.im",
"no-im-history-scope":
"oauth_config.scopes.bot: add im:history, then reinstall the app",
}
def app_home_findings(manifest):
"""Every App Home setting that is out of step, as a list. Pure.
Returns a list of (code, detail). A list rather than a verdict because
these co-occur: the tab, the read-only checkbox, the event subscription and
the scope are four separate switches, and a check that reported only the
first one found would be answered with "I fixed it and it still does not
work" a week later.
"""
if not manifest:
return [("no-manifest", "no manifest was read, so the switches themselves "
"could not be inspected; the traffic below is the "
"evidence that does not need a config token")]
body = manifest.get("manifest") or manifest
features = (body.get("features") or {}).get("app_home") or {}
events = ((body.get("settings") or {}).get("event_subscriptions") or {})
bot_events = set(events.get("bot_events") or [])
scopes = set(((body.get("oauth_config") or {}).get("scopes") or {}).get("bot") or [])
out = []
if not features.get("messages_tab_enabled"):
out.append(("messages-tab-off", "the Messages tab is off, which is the "
"default; the app cannot post into a DM "
"at all"))
elif features.get("messages_tab_read_only_enabled"):
out.append(("messages-tab-read-only", "the tab is on and read only, so the "
"app can post and nobody can answer. "
"This shape produces no error at all"))
if REPLY_EVENT not in bot_events:
out.append(("no-message-im-subscription", "message.im is not subscribed, so a "
"reply typed by a user is delivered "
"nowhere"))
if REPLY_SCOPE not in scopes:
out.append(("no-im-history-scope", "im:history is not in the bot scopes, so "
"the subscription could not deliver even "
"if it were added"))
return out
def dm_shape(conversations, histories, bot_user_id):
"""What does the app's DM traffic look like? Pure.
conversations: the im entries from conversations.list.
histories: {channel_id: [message, ...]}.
Returns (state, counts). Counted across conversations rather than within
one, because a single quiet DM proves nothing and forty DMs in which no
human has ever typed proves a great deal.
no-im-conversations the app has no open DMs to look at.
silent DMs exist and hold no messages at all.
outbound-only every message in every DM was written by the app.
two-way at least one person has replied.
"""
ims = [c for c in (conversations or []) if (c or {}).get("is_im")]
counts = {"conversations": len(ims), "app_messages": 0, "human_messages": 0}
if not ims:
return ("no-im-conversations", counts)
for conv in ims:
for m in (histories or {}).get((conv or {}).get("id")) or []:
author = (m or {}).get("user") or ""
if (m or {}).get("bot_id") or (bot_user_id and author == bot_user_id):
counts["app_messages"] += 1
elif author:
counts["human_messages"] += 1
if not counts["app_messages"] and not counts["human_messages"]:
return ("silent", counts)
if not counts["human_messages"]:
return ("outbound-only", counts)
return ("two-way", counts)
def tab_verdict(codes, shape):
"""Hold the configuration against the behaviour. Pure.
Returns (verdict, detail). This function is allowed to contradict either
half, and its most useful answer is config-clean: the surface is set up
correctly and the traffic is still one-way, so the fault is in the handler
rather than in any switch. That is the verdict that stops somebody
enabling a tab that is already enabled.
"""
found = set(codes or [])
corroborated = shape in ("outbound-only", "silent")
if "messages-tab-off" in found:
return ("tab-off", "configuration and traffic agree" if corroborated else
"the tab is off in the manifest, and yet a person has replied; read "
"the manifest again, it may not be this app")
if "messages-tab-read-only" in found:
return ("read-only", "the app can post and users see a disabled composer; "
"there is no error for either side to log")
if "no-message-im-subscription" in found or "no-im-history-scope" in found:
return ("undeliverable", "the surface is open and the delivery path is not; "
"users can type and nothing reaches the app")
if "no-manifest" in found:
return ("traffic-only", "the switches were not readable, so this verdict "
"rests on the traffic alone")
if shape == "outbound-only":
return ("config-clean", "every switch is set correctly and no person has ever "
"replied, so the fault is in the handler rather than "
"in app configuration")
if shape == "no-im-conversations":
return ("no-evidence", "the app has no open DMs, so there is nothing to "
"conclude from traffic")
return ("consistent", "the surface is configured and people are using it")
def repair_manifest(codes):
"""Turn findings into the exact manifest lines that fix them. Pure."""
return [REPAIRS[code] for code in codes if code in REPAIRS]
def get(session, method, params=None, token=None):
"""One GET against the Web API. Returns the parsed body."""
headers = {"Authorization": "Bearer " + token} if token else None
r = session.get(API + method, params=params or {}, headers=headers, timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def list_ims(session, limit):
"""conversations.list restricted to IMs. A read."""
body = get(session, "conversations.list", {"types": "im", "limit": str(limit)})
if body.get("ok") is not True:
log.warning("im unavailable %s", body.get("error"))
return []
return body.get("channels") or []
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--app-id", default="", help="the app id to export, A...")
ap.add_argument("--config-token-env", default="SLACK_CONFIG_ACCESS_TOKEN",
help="environment variable holding an app configuration token")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding a bot token with im:read")
ap.add_argument("--max-conversations", type=int, default=25)
ap.add_argument("--limit", type=int, default=100)
args = ap.parse_args()
manifest = {}
config_token = os.environ.get(args.config_token_env)
session = requests.Session()
if args.app_id and config_token:
body = get(session, "apps.manifest.export", {"app_id": args.app_id},
token=config_token)
if body.get("ok") is not True:
log.warning("manifest unavailable apps.manifest.export answered "
"ok: false, error=%s", body.get("error"))
else:
manifest = body
log.info("manifest ok features.app_home read for %s",
args.app_id)
else:
log.info("manifest skipped set %s and --app-id to read the switches "
"themselves", args.config_token_env)
findings = app_home_findings(manifest)
for code, detail in findings:
(log.info if code == "no-manifest" else log.warning)(
"config %-22s %s", code, detail)
shape, counts = ("no-im-conversations", {})
token = os.environ.get(args.token_env)
if token:
session.headers.update({"Authorization": "Bearer " + token})
who = get(session, "auth.test")
if who.get("ok") is not True:
log.error("auth.test unavailable %s", who.get("error"))
return 2
ims = list_ims(session, args.max_conversations)
histories = {}
for conv in ims[:args.max_conversations]:
body = get(session, "conversations.history",
{"channel": conv.get("id"), "limit": str(args.limit)})
histories[conv.get("id")] = body.get("messages") or []
shape, counts = dm_shape(ims, histories, who.get("user_id"))
log.info("im %d conversation(s) %d message(s) read", len(ims),
sum(len(v) for v in histories.values()))
(log.info if shape == "two-way" else log.warning)(
"traffic %-22s %s", shape, counts)
else:
log.info("traffic skipped set %s to a bot token with im:read and "
"im:history", args.token_env)
codes = [c for c, _d in findings]
verdict, detail = tab_verdict(codes, shape)
(log.info if verdict in ("consistent", "no-evidence") else log.warning)(
"verdict %-22s %s", verdict, detail)
for line in repair_manifest(codes):
log.warning("repair: %s", line)
if verdict in ("consistent", "no-evidence"):
return 0
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Decide whether your app has a working DM surface, from both sides.
*
* Read only. Up to three GET methods are used: apps.manifest.export with an
* app configuration token for the App Home flags, and conversations.list and
* conversations.history with a bot token for the traffic. Nothing is written,
* no DM is opened, and no setting is changed - the repair is printed as
* manifest keys for you to apply.
*
* Either half runs without the other.
*/
const API = 'https://slack.com/api/';
// The event that carries a user reply, and the scope without which the
// subscription cannot deliver it. Both separate from the tab itself.
export const REPLY_EVENT = 'message.im';
export const REPLY_SCOPE = 'im:history';
// Keeping the repair next to the finding is what lets the script print
// manifest lines rather than a click path through a web console.
export const REPAIRS = {
'messages-tab-off': 'features.app_home.messages_tab_enabled: true',
'messages-tab-read-only':
'features.app_home.messages_tab_read_only_enabled: false',
'no-message-im-subscription':
'settings.event_subscriptions.bot_events: add message.im',
'no-im-history-scope':
'oauth_config.scopes.bot: add im:history, then reinstall the app',
};
/** Every App Home setting that is out of step, as a list. Pure. */
export function appHomeFindings(manifest) {
if (!manifest || !Object.keys(manifest).length) {
return [['no-manifest', 'no manifest was read, so the switches themselves could '
+ 'not be inspected; the traffic below is the evidence that does not need a '
+ 'config token']];
}
const body = manifest.manifest ?? manifest;
const features = (body.features ?? {}).app_home ?? {};
const events = (body.settings ?? {}).event_subscriptions ?? {};
const botEvents = new Set(events.bot_events ?? []);
const scopes = new Set(((body.oauth_config ?? {}).scopes ?? {}).bot ?? []);
const out = [];
if (!features.messages_tab_enabled) {
out.push(['messages-tab-off', 'the Messages tab is off, which is the default; the '
+ 'app cannot post into a DM at all']);
} else if (features.messages_tab_read_only_enabled) {
out.push(['messages-tab-read-only', 'the tab is on and read only, so the app can '
+ 'post and nobody can answer. This shape produces no error at all']);
}
if (!botEvents.has(REPLY_EVENT)) {
out.push(['no-message-im-subscription', 'message.im is not subscribed, so a reply '
+ 'typed by a user is delivered nowhere']);
}
if (!scopes.has(REPLY_SCOPE)) {
out.push(['no-im-history-scope', 'im:history is not in the bot scopes, so the '
+ 'subscription could not deliver even if it were added']);
}
return out;
}
/**
* What does the app DM traffic look like? Pure.
* Returns [state, counts]; no-im-conversations, silent, outbound-only, two-way.
*/
export function dmShape(conversations, histories, botUserId) {
const ims = (conversations ?? []).filter((c) => (c ?? {}).is_im);
const counts = { conversations: ims.length, app_messages: 0, human_messages: 0 };
if (!ims.length) return ['no-im-conversations', counts];
for (const conv of ims) {
for (const m of (histories ?? {})[(conv ?? {}).id] ?? []) {
const author = (m ?? {}).user ?? '';
if ((m ?? {}).bot_id || (botUserId && author === botUserId)) {
counts.app_messages += 1;
} else if (author) {
counts.human_messages += 1;
}
}
}
if (!counts.app_messages && !counts.human_messages) return ['silent', counts];
if (!counts.human_messages) return ['outbound-only', counts];
return ['two-way', counts];
}
/** Hold the configuration against the behaviour. Pure. */
export function tabVerdict(codes, shape) {
const found = new Set(codes ?? []);
const corroborated = shape === 'outbound-only' || shape === 'silent';
if (found.has('messages-tab-off')) {
return ['tab-off', corroborated ? 'configuration and traffic agree'
: 'the tab is off in the manifest, and yet a person has replied; read the '
+ 'manifest again, it may not be this app'];
}
if (found.has('messages-tab-read-only')) {
return ['read-only', 'the app can post and users see a disabled composer; there '
+ 'is no error for either side to log'];
}
if (found.has('no-message-im-subscription') || found.has('no-im-history-scope')) {
return ['undeliverable', 'the surface is open and the delivery path is not; users '
+ 'can type and nothing reaches the app'];
}
if (found.has('no-manifest')) {
return ['traffic-only', 'the switches were not readable, so this verdict rests on '
+ 'the traffic alone'];
}
if (shape === 'outbound-only') {
return ['config-clean', 'every switch is set correctly and no person has ever '
+ 'replied, so the fault is in the handler rather than in app configuration'];
}
if (shape === 'no-im-conversations') {
return ['no-evidence', 'the app has no open DMs, so there is nothing to conclude '
+ 'from traffic'];
}
return ['consistent', 'the surface is configured and people are using it'];
}
/** Turn findings into the exact manifest lines that fix them. Pure. */
export function repairManifest(codes) {
return (codes ?? []).filter((c) => c in REPAIRS).map((c) => REPAIRS[c]);
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function get(method, params = {}, token = null) {
const query = new URLSearchParams(params);
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const r = await fetch(`${API}${method}?${query}`, { headers });
try {
return await r.json();
} catch {
return { ok: false, error: 'unparseable_body' };
}
}
async function main() {
const args = process.argv.slice(2);
const appId = arg(args, '--app-id');
const configTokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_ACCESS_TOKEN');
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const maxConversations = Number(arg(args, '--max-conversations', '25'));
const limit = arg(args, '--limit', '100');
let manifest = {};
const configToken = process.env[configTokenEnv];
if (appId && configToken) {
const body = await get('apps.manifest.export', { app_id: appId }, configToken);
if (body.ok !== true) {
console.warn('manifest unavailable apps.manifest.export answered ok: false, '
+ `error=${body.error}`);
} else {
manifest = body;
console.log(`manifest ok features.app_home read for ${appId}`);
}
} else {
console.log(`manifest skipped set ${configTokenEnv} and --app-id to read `
+ 'the switches themselves');
}
const findings = appHomeFindings(manifest);
for (const [code, detail] of findings) {
const line = `config ${code.padEnd(22)} ${detail}`;
if (code === 'no-manifest') console.log(line);
else console.warn(line);
}
let shape = 'no-im-conversations';
let counts = {};
const token = process.env[tokenEnv];
if (token) {
const who = await get('auth.test', {}, token);
if (who.ok !== true) {
console.error(`auth.test unavailable ${who.error}`);
process.exitCode = 2;
return;
}
const listed = await get('conversations.list',
{ types: 'im', limit: String(maxConversations) }, token);
if (listed.ok !== true) {
console.warn(`im unavailable ${listed.error}`);
}
const ims = (listed.channels ?? []).slice(0, maxConversations);
const histories = {};
let read = 0;
for (const conv of ims) {
const body = await get('conversations.history',
{ channel: conv.id, limit: String(limit) }, token);
histories[conv.id] = body.messages ?? [];
read += histories[conv.id].length;
}
[shape, counts] = dmShape(ims, histories, who.user_id);
console.log(`im ${ims.length} conversation(s) ${read} message(s) read`);
const line = `traffic ${shape.padEnd(22)} ${JSON.stringify(counts)}`;
if (shape === 'two-way') console.log(line);
else console.warn(line);
} else {
console.log(`traffic skipped set ${tokenEnv} to a bot token with `
+ 'im:read and im:history');
}
const codes = findings.map(([c]) => c);
const [verdict, detail] = tabVerdict(codes, shape);
const line = `verdict ${verdict.padEnd(22)} ${detail}`;
if (verdict === 'consistent' || verdict === 'no-evidence') console.log(line);
else console.warn(line);
for (const repair of repairManifest(codes)) console.warn(`repair: ${repair}`);
if (verdict !== 'consistent' && verdict !== 'no-evidence') process.exitCode = 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
There are no tokens in these fixtures at all, because nothing in the diagnosis needs one: a manifest fragment and a handful of messages are the entire input. The assertions to look at are the ones that keep the check honest in both directions. The read-only finding is only raised when the tab is actually on, since a disabled tab makes the second checkbox meaningless. And tab_verdict is tested for the case where every switch is correct and the traffic is still one-way, where the right answer is that this note is not your problem.
from slack_app_home_messages import (
app_home_findings, dm_shape, repair_manifest, tab_verdict,
)
def manifest(tab=True, read_only=False, events=("message.im",),
scopes=("im:history",)):
return {"manifest": {
"features": {"app_home": {"messages_tab_enabled": tab,
"messages_tab_read_only_enabled": read_only}},
"settings": {"event_subscriptions": {"bot_events": list(events)}},
"oauth_config": {"scopes": {"bot": list(scopes)}},
}}
def codes(m):
return [c for c, _d in app_home_findings(m)]
def test_a_healthy_configuration_produces_no_findings():
assert app_home_findings(manifest()) == []
def test_the_tab_being_off_is_named_as_the_default_it_is():
found = app_home_findings(manifest(tab=False))
assert found[0][0] == "messages-tab-off"
assert "default" in found[0][1]
def test_read_only_is_only_raised_when_the_tab_is_actually_on():
assert "messages-tab-read-only" in codes(manifest(read_only=True))
assert "messages-tab-read-only" not in codes(manifest(tab=False, read_only=True))
def test_a_missing_subscription_is_reported_alongside_the_tab_not_instead():
found = codes(manifest(tab=False, events=()))
assert found == ["messages-tab-off", "no-message-im-subscription"]
def test_a_missing_scope_is_its_own_finding():
assert "no-im-history-scope" in codes(manifest(scopes=()))
def test_no_manifest_is_a_stated_gap_rather_than_a_clean_bill():
assert codes({}) == ["no-manifest"]
assert codes(None) == ["no-manifest"]
def test_dms_where_only_the_app_ever_spoke_are_outbound_only():
convs = [{"id": "D1", "is_im": True}, {"id": "D2", "is_im": True}]
hist = {"D1": [{"user": "UBOT"}, {"bot_id": "B1"}], "D2": [{"user": "UBOT"}]}
state, counts = dm_shape(convs, hist, "UBOT")
assert state == "outbound-only"
assert counts == {"conversations": 2, "app_messages": 3, "human_messages": 0}
def test_one_human_reply_anywhere_makes_it_two_way():
convs = [{"id": "D1", "is_im": True}, {"id": "D2", "is_im": True}]
hist = {"D1": [{"user": "UBOT"}], "D2": [{"user": "U9"}]}
assert dm_shape(convs, hist, "UBOT")[0] == "two-way"
def test_open_dms_with_nothing_in_them_are_silent_rather_than_outbound():
convs = [{"id": "D1", "is_im": True}]
assert dm_shape(convs, {"D1": []}, "UBOT")[0] == "silent"
def test_no_dms_at_all_is_no_evidence():
assert dm_shape([], {}, "UBOT")[0] == "no-im-conversations"
assert dm_shape([{"id": "C1", "is_channel": True}], {}, "UBOT")[0] == \
"no-im-conversations"
def test_the_tab_being_off_and_the_traffic_agreeing_is_the_note():
verdict, detail = tab_verdict(["messages-tab-off"], "outbound-only")
assert verdict == "tab-off"
assert "agree" in detail
def test_a_reply_in_a_workspace_whose_manifest_says_the_tab_is_off_is_doubted():
verdict, detail = tab_verdict(["messages-tab-off"], "two-way")
assert verdict == "tab-off"
assert "may not be this app" in detail
def test_read_only_is_reported_as_the_shape_with_no_error():
verdict, detail = tab_verdict(["messages-tab-read-only"], "outbound-only")
assert verdict == "read-only"
assert "no error" in detail
def test_an_open_surface_with_no_delivery_path_is_undeliverable():
assert tab_verdict(["no-message-im-subscription"], "outbound-only")[0] == \
"undeliverable"
assert tab_verdict(["no-im-history-scope"], "silent")[0] == "undeliverable"
def test_every_switch_correct_and_still_one_way_hands_the_problem_back():
verdict, detail = tab_verdict([], "outbound-only")
assert verdict == "config-clean"
assert "handler" in detail
def test_a_working_surface_is_reported_as_consistent():
assert tab_verdict([], "two-way")[0] == "consistent"
def test_without_a_manifest_the_verdict_says_what_it_rests_on():
verdict, detail = tab_verdict(["no-manifest"], "outbound-only")
assert verdict == "traffic-only"
assert "traffic alone" in detail
def test_the_repair_is_printed_as_manifest_keys():
lines = repair_manifest(["messages-tab-off", "no-message-im-subscription"])
assert lines == ["features.app_home.messages_tab_enabled: true",
"settings.event_subscriptions.bot_events: add message.im"]
def test_a_finding_with_no_manifest_repair_is_skipped_rather_than_faked():
assert repair_manifest(["no-manifest"]) == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
appHomeFindings, dmShape, repairManifest, tabVerdict,
} from './slack-app-home-messages.mjs';
function manifest({ tab = true, readOnly = false, events = ['message.im'],
scopes = ['im:history'] } = {}) {
return {
manifest: {
features: {
app_home: {
messages_tab_enabled: tab,
messages_tab_read_only_enabled: readOnly,
},
},
settings: { event_subscriptions: { bot_events: [...events] } },
oauth_config: { scopes: { bot: [...scopes] } },
},
};
}
const codes = (m) => appHomeFindings(m).map(([c]) => c);
test('a healthy configuration produces no findings', () => {
assert.deepEqual(appHomeFindings(manifest()), []);
});
test('the tab being off is named as the default it is', () => {
const found = appHomeFindings(manifest({ tab: false }));
assert.equal(found[0][0], 'messages-tab-off');
assert.match(found[0][1], /default/);
});
test('read only is only raised when the tab is actually on', () => {
assert.equal(codes(manifest({ readOnly: true }))
.includes('messages-tab-read-only'), true);
assert.equal(codes(manifest({ tab: false, readOnly: true }))
.includes('messages-tab-read-only'), false);
});
test('a missing subscription is reported alongside the tab not instead', () => {
assert.deepEqual(codes(manifest({ tab: false, events: [] })),
['messages-tab-off', 'no-message-im-subscription']);
});
test('a missing scope is its own finding', () => {
assert.equal(codes(manifest({ scopes: [] })).includes('no-im-history-scope'), true);
});
test('no manifest is a stated gap rather than a clean bill', () => {
assert.deepEqual(codes({}), ['no-manifest']);
assert.deepEqual(codes(null), ['no-manifest']);
});
test('dms where only the app ever spoke are outbound only', () => {
const convs = [{ id: 'D1', is_im: true }, { id: 'D2', is_im: true }];
const hist = { D1: [{ user: 'UBOT' }, { bot_id: 'B1' }], D2: [{ user: 'UBOT' }] };
const [state, counts] = dmShape(convs, hist, 'UBOT');
assert.equal(state, 'outbound-only');
assert.deepEqual(counts, { conversations: 2, app_messages: 3, human_messages: 0 });
});
test('one human reply anywhere makes it two way', () => {
const convs = [{ id: 'D1', is_im: true }, { id: 'D2', is_im: true }];
const hist = { D1: [{ user: 'UBOT' }], D2: [{ user: 'U9' }] };
assert.equal(dmShape(convs, hist, 'UBOT')[0], 'two-way');
});
test('open dms with nothing in them are silent rather than outbound', () => {
assert.equal(dmShape([{ id: 'D1', is_im: true }], { D1: [] }, 'UBOT')[0], 'silent');
});
test('no dms at all is no evidence', () => {
assert.equal(dmShape([], {}, 'UBOT')[0], 'no-im-conversations');
assert.equal(dmShape([{ id: 'C1', is_channel: true }], {}, 'UBOT')[0],
'no-im-conversations');
});
test('the tab being off and the traffic agreeing is the note', () => {
const [verdict, detail] = tabVerdict(['messages-tab-off'], 'outbound-only');
assert.equal(verdict, 'tab-off');
assert.match(detail, /agree/);
});
test('a reply in a workspace whose manifest says the tab is off is doubted', () => {
const [verdict, detail] = tabVerdict(['messages-tab-off'], 'two-way');
assert.equal(verdict, 'tab-off');
assert.match(detail, /may not be this app/);
});
test('read only is reported as the shape with no error', () => {
const [verdict, detail] = tabVerdict(['messages-tab-read-only'], 'outbound-only');
assert.equal(verdict, 'read-only');
assert.match(detail, /no error/);
});
test('an open surface with no delivery path is undeliverable', () => {
assert.equal(tabVerdict(['no-message-im-subscription'], 'outbound-only')[0],
'undeliverable');
assert.equal(tabVerdict(['no-im-history-scope'], 'silent')[0], 'undeliverable');
});
test('every switch correct and still one way hands the problem back', () => {
const [verdict, detail] = tabVerdict([], 'outbound-only');
assert.equal(verdict, 'config-clean');
assert.match(detail, /handler/);
});
test('a working surface is reported as consistent', () => {
assert.equal(tabVerdict([], 'two-way')[0], 'consistent');
});
test('without a manifest the verdict says what it rests on', () => {
const [verdict, detail] = tabVerdict(['no-manifest'], 'outbound-only');
assert.equal(verdict, 'traffic-only');
assert.match(detail, /traffic alone/);
});
test('the repair is printed as manifest keys', () => {
assert.deepEqual(repairManifest(['messages-tab-off', 'no-message-im-subscription']),
['features.app_home.messages_tab_enabled: true',
'settings.event_subscriptions.bot_events: add message.im']);
});
test('a finding with no manifest repair is skipped rather than faked', () => {
assert.deepEqual(repairManifest(['no-manifest']), []);
});
FAQ
Why is the Messages tab off by default?
Because App Home is a surface rather than a capability, and Slack does not assume an app wants one. A new app can post into channels the moment it is installed, which teaches you in the first hour that things work; the DM surface is the exception, and there is nothing in the creation flow that mentions it. It is also app configuration rather than workspace configuration, so if it is off it is off for every workspace that ever installs the app, and no reinstall will change that.
We enabled the tab and users still get no answer. What is left?
The delivery path, which is a different page in the settings. Enabling the tab lets people type; message.im in the event subscriptions and im:history in the bot scopes are what turn what they typed into a request to your app. Without both, Slack accepts the message, shows it in the conversation, and delivers it nowhere. The script reports that as undeliverable, and adding the scope means a reinstall, so plan for one.
Users see a message saying they cannot reply to this conversation. Which setting is that?
The read-only checkbox, which sits next to the tab switch and is the second half of the same feature. In the manifest it is features.app_home.messages_tab_read_only_enabled. This is the one shape of the problem that produces no error anywhere: your outbound messages succeed with ok: true and a real timestamp, and the failure is entirely in a disabled composer on the user's screen. It cannot be found by instrumenting your own code, only by reading the setting.
Can a bot token read these flags?
No. features.app_home lives in the app manifest, and reading it needs an app configuration token with app_configurations:read, which is a different credential class from the bot token your app runs on. That is why the second half of this check exists: listing the app's DMs and reading their history needs only im:read and im:history, and a pattern of many conversations in which no person has ever typed is strong evidence on its own.
Is this the same as the app never having opened a DM with the user?
No, and the errors differ. If no IM channel exists, the failure is that the conversation id does not resolve, and the fix is on your side. This note is about the surface being switched off once the conversation exists: the DM is there, the user can see it in the sidebar under Apps, and either the app cannot post into it or the person cannot answer. dm_shape distinguishes them for you, because no-im-conversations and outbound-only are different findings with different repairs.
Related field notes
- the DM that does not exist yet, which is a different failure
- a delivery path with nothing routed down it
- a subscribed event whose scope was never granted
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.
- App Home surfaces - Slack Docs
- message.im event reference - Slack Docs
- Stack Overflow: cannot send a DM, messages_tab_disabled
- apps.manifest.export method reference - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.