Diagnostic Slack
views.publish succeeds and the Home tab is switched off
The dashboard is finished. Four sections, a refresh button, a chart that took a fortnight, and views.publish returns {"ok": true} with a view object and a real view id every single time it runs. You open the app in Slack to admire it and there is no Home tab on the profile at all — just About, and a Messages tab you never asked for.
Nothing failed. Nothing will fail. views.publish will keep answering ok: true for as long as you keep calling it, because the method's job is to store a view for a user, and it does that faithfully whether or not there is a tab through which anybody can see it.
The Home tab is a switch in app configuration, under App Home → Show Tabs → Home Tab, and in the manifest it is features.app_home.home_tab_enabled. With it off, views.publish still succeeds — the view is stored against the user — and the surface it was stored for is not rendered. There is no error, no warning and no field on the response that says so.
The second half is a subscription. The published view is per user and per installation, so there is no such thing as publishing the Home tab once. Slack sends app_home_opened when a person opens the tab, and that event is the cue to publish for that person. An app that publishes at startup for the users it happens to know about leaves everybody who joined afterwards looking at an empty tab, forever, with no error on either side.
This is a different switch from the Messages tab, and the difference is worth stating plainly because they sit next to each other in the same settings panel. The Messages tab governs whether a person may type at your app, and refusing it produces an actual error string, messages_tab_disabled. The Home tab governs whether a view you published is reachable, and refusing it produces nothing at all. One fails loudly on the way in; the other succeeds quietly on the way out.
The problem in plain words
Every signal available to your code says the feature works. The call returns ok: true. The response carries a view object with an id, a hash and the blocks you sent, echoed back. Your integration tests assert on that response and pass. Your metrics show a healthy publish rate. The only place the failure exists is on a profile page in a Slack client, and there is no API that will tell you what that page looks like.
The switch is off by default on a new app, which means this is not something that broke. It is something that was never on, so there is no change to bisect, no deploy to roll back, and no correlation with anything. Teams reach for the blocks first, because a Home tab that renders empty and a Home tab that does not exist look identical from the outside, and they spend an afternoon simplifying a view that was never going to be shown.
The staleness shape is worse, because it works for the person testing it. The developer opens the app, the handler fires, a view is published for them, the tab looks perfect. Every other member of the workspace has never triggered a publish, so they open the same app and see the empty state. The bug report reads “it works for you and not for me”, which is the least tractable sentence in software, and the cause is that the view is stored per user and nobody thought to ask what happens for a user who has never been stored against.
Then there is drift, which is the version of this that arrives months later. A view published in March against a user who has not opened the app since is still sitting there in March's state, showing March's numbers, with a button wired to a March action id your handler no longer recognises. Nothing expires it. The Home tab has no notion of a stale view, so a publish-once app degrades into a museum, and the only clue is that the numbers on it are wrong rather than absent.
Why it happens
The failure has no error, so it has to be read from configuration and from coverage. There is no call that returns “this user cannot see a Home tab”, and views.publish is a write, so a diagnostic cannot even reproduce the symptom without publishing. Everything here is read: the manifest for the switch and the subscription, users.list for who exists, and your own publish log for who has been served.
Coverage is the measurement, because the surface is per user. A publish rate is not evidence; forty thousand publishes for the same eleven people is a broken feature with excellent metrics. What settles it is the set difference between the members of the workspace and the users who have ever appeared in a publish record, and that number is usually startling the first time somebody looks at it.
The cadence is a separate finding from the coverage, and both are separate from the switch. An app can have the tab enabled, the subscription in place, and still publish only at boot because the handler was written before the event was wired up. So the script reports the switch, the trigger mix and the coverage independently, and only then combines them, because each one has a different repair and fixing the wrong one costs a week.
Staleness is measured per user, not per app. The question is not when the app last published anything; it is how long ago each individual user's view was written, because that is the state they are looking at right now. A single recent publish for one active user makes an app look healthy while four hundred people stare at last quarter.
This is not the Messages tab note, and the script says so out loud. If the manifest shows the Home tab off and the Messages tab on, that is an app with a DM surface and no dashboard, and the two have different switches, different symptoms and different repairs. The check prints the pointer rather than quietly folding them together, because “the App Home is disabled” is a sentence that has sent a great many people to flip the wrong checkbox.
The repair is printed as manifest keys. A checkbox in a web console exists in one place and is invisible to review, which is exactly how an app ships with the switch off in the first place. features.app_home.home_tab_enabled: true in a manifest that lives in your repository is a switch that stays on for the next app too.
The fix, as a flow
The red arrow is the fourth, which is the latest in this batch and the reason this failure survives review. Every step before it is a real success: the view was built, the method returned ok true with a view id, and the test that asserts on that response passes. The failure is a page that is not rendered, and no call in the API will tell you what that page looks like. It is worth holding this against the Messages tab, which is the switch next door: that one refuses a person on the way in and hands your code an error string to catch, while this one accepts everything and shows nobody anything. The fix branch sorts members rather than settings, because the surface is stored per person and a publish rate cannot tell you how many people have one.
How to fix it
Read the switch and the subscription from the manifest
apps.manifest.export returns features.app_home and settings.event_subscriptions. It needs an app configuration token, which is a different credential class from the bot token your app runs on; without one the script skips to the coverage half, which needs only users:read and your own log.
Take every finding at once, not the first one
home_tab_findings returns home-tab-off, no-app-home-opened-subscription and messages-tab-only as a list. The last of those is a pointer rather than a fault: it fires when the Home tab is off and the Messages tab is on, which means you are looking at the other note.
Classify what triggers your publishes
publish_cadence reads the trigger field on your own publish records and answers on-open, startup-only, mixed or no-records. startup-only is the finding that explains “it works for you and not for me”, because the set of users published to at boot is the set of users you already knew about.
Measure coverage against the actual member list
publish_coverage holds users.list, with bots and deactivated accounts removed, against the user ids in your publish log. It answers covered, partial, none-published or no-members, and the count of people who have never had a view stored for them is the number to put in the ticket.
Ask how old each user's view is
staleness takes the most recent publish per user rather than the most recent publish overall, because the second number is always reassuring and never relevant. A user whose view was written four months ago is looking at four-month-old blocks with four-month-old action ids in them.
Publish in the app_home_opened handler and nowhere else
The repair is a shape rather than a setting. Subscribe to app_home_opened, publish the view inside that handler for event.user, and let every open refresh it. Startup publishing then becomes unnecessary, staleness stops being possible, and coverage takes care of itself.
How to check it worked
Turn the switch on, move the publish into the event handler, and re-run after a day of real use. Coverage climbs on its own as people open the app, and cadence should read on-open.
python3 slack_home_tab.py --app-id A05NW7XQ1 --publishes publishes.json
# manifest ok features.app_home read for A05NW7XQ1
# config home-tab-off the Home tab is off, so views.publish stores a
# view for a surface that is not rendered
# config no-app-home-opened-subscription
# app_home_opened is not subscribed, so nothing
# tells the app when to publish for a user
# members 412 member(s) bots and deactivated accounts removed
# cadence startup-only 97 record(s), all triggered at startup; a user
# who joined since the last restart has no view
# coverage partial 11 of 412 member(s) have ever been published to;
# 401 have never had a view stored
# stale stale 9 of 11 user(s) were last published to more than
# 30 days ago
# verdict tab-off the switch, the subscription and the coverage all
# point the same way
# repair: features.app_home.home_tab_enabled: true
# repair: settings.event_subscriptions.bot_events: add app_home_opened
# repair: publish inside the app_home_opened handler, for event.user, on every open
The full code
Two halves that do not need each other. home_tab_findings reads the manifest and returns a list, because the switch and the subscription are separate settings that fail together and get fixed one at a time. The other half needs no configuration credential at all: publish_cadence reads what triggers your publishes, publish_coverage is a set difference between the workspace's members and the users you have ever published for, and staleness asks the per-user question rather than the flattering per-app one. home_verdict combines them and is allowed to say that the switch is fine and the loop is not.
"""Decide whether your App Home Home tab is reachable, and by whom.
Read only. Two GET methods are used: apps.manifest.export with an app
configuration token for the switch and the subscription, and users.list with a
bot token for the member list. views.publish is never called - it is a write,
it stores a view against a user, and a diagnostic that runs it changes what the
user sees in order to find out what the user sees.
The third input is your own publish log, as JSON: a list of records carrying at
least user, trigger and ts. That is the only place the per-user history of this
surface exists, because Slack offers no method that reports which users have a
published view.
Either half runs without the other. Most readers have a bot token and no app
configuration token, and the coverage half is the one that does not need it.
"""
import argparse
import json
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_home_tab")
API = "https://slack.com/api/"
# The event that tells the app a particular person is looking at the tab right
# now. Without it there is no cue to publish for anyone in particular, which is
# the whole reason a publish-once app leaves most of a workspace with an empty
# surface.
OPEN_EVENT = "app_home_opened"
# The triggers that mean "we published for the users we already knew about",
# as opposed to publishing for the person who just opened the tab.
STARTUP_TRIGGERS = ("startup", "boot", "deploy", "cron", "schedule")
REPAIRS = {
"home-tab-off": "features.app_home.home_tab_enabled: true",
"no-app-home-opened-subscription":
"settings.event_subscriptions.bot_events: add app_home_opened",
}
DAY = 86400.0
def home_tab_findings(manifest):
"""Every App Home setting relevant to the Home tab, as a list. Pure.
Returns a list of (code, detail). A list rather than a verdict because the
switch and the subscription are two settings that fail together and get
fixed one at a time, and a check that reported only the first would be
answered with "I turned it on and it is still empty" a week later.
messages-tab-only is a pointer rather than a fault: the Messages tab is a
different switch with a different symptom, and conflating the two is how
somebody ends up flipping the wrong checkbox.
"""
if not manifest:
return [("no-manifest", "no manifest was read, so the switch itself could "
"not be inspected; the coverage below needs no "
"configuration 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 [])
out = []
if not features.get("home_tab_enabled"):
out.append(("home-tab-off", "the Home tab is off, so views.publish stores a "
"view for a surface that is not rendered and "
"still answers ok true"))
if features.get("messages_tab_enabled"):
out.append(("messages-tab-only", "the Messages tab is on and the Home tab "
"is off; those are different switches "
"with different symptoms"))
if OPEN_EVENT not in bot_events:
out.append(("no-app-home-opened-subscription", "app_home_opened is not "
"subscribed, so nothing tells "
"the app when to publish for a "
"particular user"))
return out
def publish_cadence(records):
"""What triggers your publishes? Pure.
records: [{"user": "U...", "trigger": "app_home_opened", "ts": 1.7e9}, ...]
Returns (state, counts).
no-records nothing to read.
on-open every publish was triggered by app_home_opened. Correct.
startup-only every publish happened at boot, for the users the process
already knew about. This is the shape that produces "it
works for you and not for me".
mixed both, which is usually a migration in progress.
unknown-trigger the records carry no trigger the script recognises.
"""
rows = [r for r in (records or []) if isinstance(r, dict)]
counts = {"records": len(rows), "on_open": 0, "startup": 0, "other": 0}
if not rows:
return ("no-records", counts)
for row in rows:
trigger = str(row.get("trigger") or "").strip().lower()
if trigger == OPEN_EVENT:
counts["on_open"] += 1
elif trigger in STARTUP_TRIGGERS:
counts["startup"] += 1
else:
counts["other"] += 1
if counts["on_open"] and counts["startup"]:
return ("mixed", counts)
if counts["on_open"]:
return ("on-open", counts)
if counts["startup"]:
return ("startup-only", counts)
return ("unknown-trigger", counts)
def publish_coverage(members, records):
"""How much of the workspace has ever had a view stored for it? Pure.
members: user ids from users.list, already filtered.
records: your publish log.
Returns (state, counts). A publish rate is not evidence: forty thousand
publishes for the same eleven people is a broken feature with excellent
metrics. The set difference is the measurement.
"""
people = [str(m) for m in (members or []) if str(m or "")]
served = {str((r or {}).get("user") or "") for r in (records or [])
if isinstance(r, dict)}
served.discard("")
covered = [m for m in people if m in served]
counts = {"members": len(people), "covered": len(covered),
"never": len(people) - len(covered)}
if not people:
return ("no-members", counts)
if not covered:
return ("none-published", counts)
if counts["never"]:
return ("partial", counts)
return ("covered", counts)
def staleness(records, now=None, max_age_days=30):
"""How old is each user's view? Pure.
Takes the most recent publish per user rather than the most recent publish
overall, because the second number is always reassuring and never relevant:
one active user refreshed this morning tells you nothing about the four
hundred people looking at last quarter.
Returns (state, counts).
"""
now = float(now if now is not None else time.time())
latest = {}
for row in (records or []):
if not isinstance(row, dict):
continue
user = str(row.get("user") or "")
try:
ts = float(row.get("ts") or 0)
except (TypeError, ValueError):
ts = 0.0
if not user or not ts:
continue
latest[user] = max(latest.get(user, 0.0), ts)
counts = {"users": len(latest), "stale": 0, "fresh": 0}
if not latest:
return ("no-records", counts)
cutoff = now - float(max_age_days) * DAY
for ts in latest.values():
if ts < cutoff:
counts["stale"] += 1
else:
counts["fresh"] += 1
return (("stale", counts) if counts["stale"] else ("fresh", counts))
def home_verdict(codes, cadence, coverage):
"""Hold the switch, the cadence and the coverage against each other. Pure.
Returns (verdict, detail). Allowed to contradict any of the three, and its
most useful answer is publish-loop: every switch is correct, the
subscription is in place, and most of the workspace has still never had a
view stored, which puts the fault in your handler rather than in a setting.
"""
found = set(codes or [])
if "home-tab-off" in found:
return ("tab-off", "the Home tab is off, so nothing published for anybody is "
"reachable; fix this before reading the coverage")
if "no-app-home-opened-subscription" in found:
return ("no-subscription", "the tab is on and nothing tells the app when to "
"fill it, so only the users it publishes to by "
"some other route have a view")
if "no-manifest" in found:
return ("coverage-only", "the switch was not readable, so this verdict rests "
"on the publish log and the member list alone")
if cadence == "startup-only":
return ("publish-once", "every publish happened at startup, so anybody who "
"joined since the last restart has an empty tab")
if coverage in ("none-published", "partial"):
return ("under-published", "the configuration is correct and most of the "
"workspace has still never had a view stored")
if cadence == "no-records":
return ("no-evidence", "no publish records were given, so there is nothing to "
"conclude about coverage")
return ("consistent", "the tab is on, the event is subscribed, and every member "
"has a published view")
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 real_members(session, limit):
"""users.list with bots and deactivated accounts removed. A read."""
body = get(session, "users.list", {"limit": str(limit)})
if body.get("ok") is not True:
log.warning("members unavailable %s", body.get("error"))
return []
out = []
for user in body.get("members") or []:
if (user or {}).get("is_bot") or (user or {}).get("deleted"):
continue
if (user or {}).get("id") == "USLACKBOT":
continue
out.append((user or {}).get("id"))
return [u for u in out if u]
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 users:read")
ap.add_argument("--publishes", default="",
help="your own views.publish log as JSON: a list of records "
"carrying user, trigger and ts")
ap.add_argument("--max-age-days", type=int, default=30)
ap.add_argument("--limit", type=int, default=200)
args = ap.parse_args()
session = requests.Session()
manifest = {}
config_token = os.environ.get(args.config_token_env)
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 "
"switch itself", args.config_token_env)
findings = home_tab_findings(manifest)
for code, detail in findings:
(log.info if code in ("no-manifest", "messages-tab-only") else log.warning)(
"config %-22s %s", code, detail)
if any(code == "messages-tab-only" for code, _d in findings):
log.info(" see also /slack/messages-tab-disabled/")
records = []
if args.publishes:
with open(args.publishes, encoding="utf-8") as handle:
records = json.load(handle) or []
log.info("publishes %d record(s)", len(records))
members = []
token = os.environ.get(args.token_env)
if token:
session.headers.update({"Authorization": "Bearer " + token})
members = real_members(session, args.limit)
log.info("members %d member(s) bots and deactivated accounts "
"removed", len(members))
else:
log.info("members skipped set %s to a bot token with "
"users:read", args.token_env)
cadence, cadence_counts = publish_cadence(records)
(log.info if cadence == "on-open" else log.warning)(
"cadence %-22s %s", cadence, cadence_counts)
coverage, coverage_counts = publish_coverage(members, records)
(log.info if coverage in ("covered", "no-members") else log.warning)(
"coverage %-22s %s", coverage, coverage_counts)
stale, stale_counts = staleness(records, max_age_days=args.max_age_days)
(log.info if stale != "stale" else log.warning)(
"stale %-22s %s", stale, stale_counts)
codes = [c for c, _d in findings]
verdict, detail = home_verdict(codes, cadence, coverage)
(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 != "consistent":
log.warning("repair: publish inside the app_home_opened handler, for "
"event.user, on every open")
return 0 if verdict in ("consistent", "no-evidence") else 1
if __name__ == "__main__":
sys.exit(main())
/**
* Decide whether your App Home Home tab is reachable, and by whom.
*
* Read only. Two GET methods are used: apps.manifest.export with an app
* configuration token for the switch and the subscription, and users.list with
* a bot token for the member list. views.publish is never called - it is a
* write, and a diagnostic that runs it changes what the user sees in order to
* find out what the user sees.
*
* The third input is your own publish log, as JSON. That is the only place the
* per-user history of this surface exists.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// The event that tells the app a particular person is looking at the tab now.
export const OPEN_EVENT = 'app_home_opened';
// Triggers that mean "we published for the users we already knew about".
export const STARTUP_TRIGGERS = ['startup', 'boot', 'deploy', 'cron', 'schedule'];
export const REPAIRS = {
'home-tab-off': 'features.app_home.home_tab_enabled: true',
'no-app-home-opened-subscription':
'settings.event_subscriptions.bot_events: add app_home_opened',
};
const DAY = 86400;
/** Every App Home setting relevant to the Home tab, as a list. Pure. */
export function homeTabFindings(manifest) {
if (!manifest || !Object.keys(manifest).length) {
return [['no-manifest', 'no manifest was read, so the switch itself could not be '
+ 'inspected; the coverage below needs no configuration 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 out = [];
if (!features.home_tab_enabled) {
out.push(['home-tab-off', 'the Home tab is off, so views.publish stores a view '
+ 'for a surface that is not rendered and still answers ok true']);
if (features.messages_tab_enabled) {
out.push(['messages-tab-only', 'the Messages tab is on and the Home tab is off; '
+ 'those are different switches with different symptoms']);
}
}
if (!botEvents.has(OPEN_EVENT)) {
out.push(['no-app-home-opened-subscription', 'app_home_opened is not subscribed, '
+ 'so nothing tells the app when to publish for a particular user']);
}
return out;
}
/**
* What triggers your publishes? Pure.
* Returns [state, counts]; no-records, on-open, startup-only, mixed,
* unknown-trigger.
*/
export function publishCadence(records) {
const rows = (records ?? []).filter((r) => r && typeof r === 'object');
const counts = { records: rows.length, on_open: 0, startup: 0, other: 0 };
if (!rows.length) return ['no-records', counts];
for (const row of rows) {
const trigger = String(row.trigger ?? '').trim().toLowerCase();
if (trigger === OPEN_EVENT) counts.on_open += 1;
else if (STARTUP_TRIGGERS.includes(trigger)) counts.startup += 1;
else counts.other += 1;
}
if (counts.on_open && counts.startup) return ['mixed', counts];
if (counts.on_open) return ['on-open', counts];
if (counts.startup) return ['startup-only', counts];
return ['unknown-trigger', counts];
}
/**
* How much of the workspace has ever had a view stored for it? Pure.
* Returns [state, counts]; no-members, none-published, partial, covered.
*/
export function publishCoverage(members, records) {
const people = (members ?? []).map((m) => String(m ?? '')).filter(Boolean);
const served = new Set((records ?? [])
.filter((r) => r && typeof r === 'object')
.map((r) => String(r.user ?? '')).filter(Boolean));
const covered = people.filter((m) => served.has(m));
const counts = {
members: people.length,
covered: covered.length,
never: people.length - covered.length,
};
if (!people.length) return ['no-members', counts];
if (!covered.length) return ['none-published', counts];
if (counts.never) return ['partial', counts];
return ['covered', counts];
}
/**
* How old is each user's view? Pure. Takes the most recent publish per user
* rather than the most recent publish overall, because the second number is
* always reassuring and never relevant.
*/
export function staleness(records, now = null, maxAgeDays = 30) {
const at = Number(now ?? Date.now() / 1000);
const latest = new Map();
for (const row of records ?? []) {
if (!row || typeof row !== 'object') continue;
const user = String(row.user ?? '');
const ts = Number(row.ts ?? 0);
if (!user || !ts || Number.isNaN(ts)) continue;
latest.set(user, Math.max(latest.get(user) ?? 0, ts));
}
const counts = { users: latest.size, stale: 0, fresh: 0 };
if (!latest.size) return ['no-records', counts];
const cutoff = at - Number(maxAgeDays) * DAY;
for (const ts of latest.values()) {
if (ts < cutoff) counts.stale += 1;
else counts.fresh += 1;
}
return [counts.stale ? 'stale' : 'fresh', counts];
}
/** Hold the switch, the cadence and the coverage against each other. Pure. */
export function homeVerdict(codes, cadence, coverage) {
const found = new Set(codes ?? []);
if (found.has('home-tab-off')) {
return ['tab-off', 'the Home tab is off, so nothing published for anybody is '
+ 'reachable; fix this before reading the coverage'];
}
if (found.has('no-app-home-opened-subscription')) {
return ['no-subscription', 'the tab is on and nothing tells the app when to fill '
+ 'it, so only the users it publishes to by some other route have a view'];
}
if (found.has('no-manifest')) {
return ['coverage-only', 'the switch was not readable, so this verdict rests on '
+ 'the publish log and the member list alone'];
}
if (cadence === 'startup-only') {
return ['publish-once', 'every publish happened at startup, so anybody who joined '
+ 'since the last restart has an empty tab'];
}
if (coverage === 'none-published' || coverage === 'partial') {
return ['under-published', 'the configuration is correct and most of the workspace '
+ 'has still never had a view stored'];
}
if (cadence === 'no-records') {
return ['no-evidence', 'no publish records were given, so there is nothing to '
+ 'conclude about coverage'];
}
return ['consistent', 'the tab is on, the event is subscribed, and every member has '
+ 'a published view'];
}
/** Turn findings into the exact manifest lines that fix them. Pure. */
export function repairManifest(codes) {
return (codes ?? []).filter((c) => REPAIRS[c]).map((c) => REPAIRS[c]);
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function read(method, params, token) {
const qs = new URLSearchParams(params ?? {}).toString();
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const r = await fetch(`${API}${method}${qs ? `?${qs}` : ''}`, { 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 maxAgeDays = Number(arg(args, '--max-age-days', '30'));
let manifest = {};
const configToken = process.env[configTokenEnv];
if (appId && configToken) {
const body = await read('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 switch itself');
}
const findings = homeTabFindings(manifest);
for (const [code, detail] of findings) {
const line = `config ${code.padEnd(22)} ${detail}`;
if (code === 'no-manifest' || code === 'messages-tab-only') console.log(line);
else console.warn(line);
}
if (findings.some(([code]) => code === 'messages-tab-only')) {
console.log(' see also /slack/messages-tab-disabled/');
}
let records = [];
const publishes = arg(args, '--publishes');
if (publishes) {
records = JSON.parse(await readFile(publishes, 'utf8')) ?? [];
console.log(`publishes ${records.length} record(s)`);
}
let members = [];
const token = process.env[tokenEnv];
if (token) {
const body = await read('users.list', { limit: arg(args, '--limit', '200') }, token);
if (body.ok !== true) {
console.warn(`members unavailable ${body.error}`);
} else {
members = (body.members ?? [])
.filter((u) => u && !u.is_bot && !u.deleted && u.id !== 'USLACKBOT')
.map((u) => u.id);
console.log(`members ${members.length} member(s) bots and `
+ 'deactivated accounts removed');
}
} else {
console.log(`members skipped set ${tokenEnv} to a bot token `
+ 'with users:read');
}
const [cadence, cadenceCounts] = publishCadence(records);
const cadenceLine = `cadence ${cadence.padEnd(22)} ${JSON.stringify(cadenceCounts)}`;
if (cadence === 'on-open') console.log(cadenceLine);
else console.warn(cadenceLine);
const [coverage, coverageCounts] = publishCoverage(members, records);
const coverageLine = `coverage ${coverage.padEnd(22)} `
+ `${JSON.stringify(coverageCounts)}`;
if (coverage === 'covered' || coverage === 'no-members') console.log(coverageLine);
else console.warn(coverageLine);
const [stale, staleCounts] = staleness(records, null, maxAgeDays);
const staleLine = `stale ${stale.padEnd(22)} ${JSON.stringify(staleCounts)}`;
if (stale === 'stale') console.warn(staleLine);
else console.log(staleLine);
const codes = findings.map(([code]) => code);
const [verdict, detail] = homeVerdict(codes, cadence, coverage);
const verdictLine = `verdict ${verdict.padEnd(22)} ${detail}`;
if (verdict === 'consistent' || verdict === 'no-evidence') console.log(verdictLine);
else console.warn(verdictLine);
for (const line of repairManifest(codes)) console.warn(`repair: ${line}`);
if (verdict !== 'consistent') {
console.warn('repair: publish inside the app_home_opened handler, for event.user, '
+ 'on every open');
}
if (verdict !== 'consistent' && verdict !== 'no-evidence') process.exitCode = 1;
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The fixtures carry no credential, because nothing here needs one: a manifest fragment, a list of user ids, and a publish log with a trigger and a timestamp on each row. The assertions worth reading are the ones that keep the three findings apart. A manifest with the Home tab off and the Messages tab on has to produce the pointer to the other note rather than silently folding them together. Coverage has to be a set difference rather than a count of records, so eleven users and forty thousand publishes still reads as partial. And staleness has to be measured per user, so one refresh this morning does not make four hundred stale views look fresh.
from slack_home_tab import (
home_tab_findings, home_verdict, publish_cadence, publish_coverage,
repair_manifest, staleness,
)
NOW = 1_800_000_000.0
DAY = 86400.0
def manifest(home=True, messages=False, events=("app_home_opened",)):
return {"manifest": {
"features": {"app_home": {"home_tab_enabled": home,
"messages_tab_enabled": messages}},
"settings": {"event_subscriptions": {"bot_events": list(events)}},
}}
def codes_of(found):
return [c for c, _d in found]
def test_a_correct_manifest_produces_no_findings_at_all():
assert home_tab_findings(manifest()) == []
def test_the_home_tab_switch_is_reported_with_the_reason_there_is_no_error():
found = home_tab_findings(manifest(home=False))
assert "home-tab-off" in codes_of(found)
assert "ok true" in dict(found)["home-tab-off"]
def test_a_missing_subscription_is_its_own_finding_on_an_enabled_tab():
found = home_tab_findings(manifest(home=True, events=()))
assert codes_of(found) == ["no-app-home-opened-subscription"]
def test_both_settings_wrong_are_both_reported_rather_than_the_first_one():
found = home_tab_findings(manifest(home=False, events=()))
assert "home-tab-off" in codes_of(found)
assert "no-app-home-opened-subscription" in codes_of(found)
def test_a_messages_tab_without_a_home_tab_points_at_the_other_note():
found = home_tab_findings(manifest(home=False, messages=True))
assert "messages-tab-only" in codes_of(found)
assert "different switches" in dict(found)["messages-tab-only"]
def test_no_manifest_says_what_is_still_readable_without_one():
found = home_tab_findings({})
assert codes_of(found) == ["no-manifest"]
assert "configuration token" in dict(found)["no-manifest"]
def test_publishing_on_open_is_the_correct_cadence():
state, counts = publish_cadence([{"user": "U1", "trigger": "app_home_opened"},
{"user": "U2", "trigger": "app_home_opened"}])
assert state == "on-open"
assert counts["on_open"] == 2
def test_publishing_at_boot_only_is_the_shape_that_works_for_the_developer():
state, counts = publish_cadence([{"user": "U1", "trigger": "startup"},
{"user": "U2", "trigger": "deploy"}])
assert state == "startup-only"
assert counts["startup"] == 2
def test_a_migration_in_progress_reads_as_mixed():
state, _counts = publish_cadence([{"user": "U1", "trigger": "startup"},
{"user": "U2", "trigger": "app_home_opened"}])
assert state == "mixed"
def test_an_unrecognised_trigger_is_named_rather_than_counted_as_correct():
state, counts = publish_cadence([{"user": "U1", "trigger": "manual"}])
assert state == "unknown-trigger"
assert counts["other"] == 1
def test_an_empty_publish_log_is_no_records():
assert publish_cadence([])[0] == "no-records"
def test_coverage_is_a_set_difference_not_a_count_of_publishes():
records = [{"user": "U1"} for _ in range(40000)]
state, counts = publish_coverage(["U1", "U2", "U3"], records)
assert state == "partial"
assert counts["never"] == 2
assert counts["covered"] == 1
def test_everybody_served_is_covered():
state, counts = publish_coverage(["U1", "U2"],
[{"user": "U1"}, {"user": "U2"}])
assert state == "covered"
assert counts["never"] == 0
def test_nobody_served_is_reported_separately_from_a_partial_gap():
assert publish_coverage(["U1", "U2"], [])[0] == "none-published"
def test_no_members_is_not_mistaken_for_full_coverage():
assert publish_coverage([], [{"user": "U1"}])[0] == "no-members"
def test_staleness_is_measured_per_user_not_per_app():
records = [{"user": "U1", "ts": NOW - 1}] + [
{"user": "U%d" % i, "ts": NOW - 200 * DAY} for i in range(2, 6)]
state, counts = staleness(records, now=NOW, max_age_days=30)
assert state == "stale"
assert counts["stale"] == 4
assert counts["fresh"] == 1
def test_a_users_most_recent_publish_is_the_one_that_counts():
records = [{"user": "U1", "ts": NOW - 400 * DAY}, {"user": "U1", "ts": NOW - 1}]
state, counts = staleness(records, now=NOW, max_age_days=30)
assert state == "fresh"
assert counts["users"] == 1
def test_records_without_a_usable_timestamp_are_skipped_rather_than_counted():
assert staleness([{"user": "U1"}, {"user": "U2", "ts": "nope"}], now=NOW)[0] \
== "no-records"
def test_the_switch_outranks_everything_else_in_the_verdict():
verdict, detail = home_verdict(["home-tab-off"], "on-open", "covered")
assert verdict == "tab-off"
assert "before reading the coverage" in detail
def test_a_missing_subscription_outranks_the_publish_log():
assert home_verdict(["no-app-home-opened-subscription"], "on-open",
"covered")[0] == "no-subscription"
def test_publishing_once_is_named_even_when_every_switch_is_correct():
verdict, detail = home_verdict([], "startup-only", "partial")
assert verdict == "publish-once"
assert "last restart" in detail
def test_correct_config_and_poor_coverage_hands_the_problem_back():
verdict, detail = home_verdict([], "on-open", "partial")
assert verdict == "under-published"
assert "never had a view stored" in detail
def test_without_a_manifest_the_verdict_says_what_it_rests_on():
verdict, detail = home_verdict(["no-manifest"], "on-open", "covered")
assert verdict == "coverage-only"
assert "publish log" in detail
def test_a_working_surface_is_consistent():
assert home_verdict([], "on-open", "covered")[0] == "consistent"
def test_the_repair_is_printed_as_manifest_keys_and_pointers_are_skipped():
assert repair_manifest(["home-tab-off", "messages-tab-only"]) == [
"features.app_home.home_tab_enabled: true"]
assert repair_manifest(["no-manifest"]) == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
homeTabFindings, homeVerdict, publishCadence, publishCoverage, repairManifest,
staleness,
} from './slack-home-tab.mjs';
const NOW = 1800000000;
const DAY = 86400;
const manifest = (home = true, messages = false, events = ['app_home_opened']) => ({
manifest: {
features: { app_home: { home_tab_enabled: home, messages_tab_enabled: messages } },
settings: { event_subscriptions: { bot_events: events } },
},
});
const codesOf = (found) => found.map(([c]) => c);
const detailOf = (found, code) => Object.fromEntries(found)[code];
test('a correct manifest produces no findings at all', () => {
assert.deepEqual(homeTabFindings(manifest()), []);
});
test('the home tab switch is reported with the reason there is no error', () => {
const found = homeTabFindings(manifest(false));
assert.equal(codesOf(found).includes('home-tab-off'), true);
assert.match(detailOf(found, 'home-tab-off'), /ok true/);
});
test('a missing subscription is its own finding on an enabled tab', () => {
assert.deepEqual(codesOf(homeTabFindings(manifest(true, false, []))),
['no-app-home-opened-subscription']);
});
test('both settings wrong are both reported rather than the first one', () => {
const found = codesOf(homeTabFindings(manifest(false, false, [])));
assert.equal(found.includes('home-tab-off'), true);
assert.equal(found.includes('no-app-home-opened-subscription'), true);
});
test('a messages tab without a home tab points at the other note', () => {
const found = homeTabFindings(manifest(false, true));
assert.equal(codesOf(found).includes('messages-tab-only'), true);
assert.match(detailOf(found, 'messages-tab-only'), /different switches/);
});
test('no manifest says what is still readable without one', () => {
const found = homeTabFindings({});
assert.deepEqual(codesOf(found), ['no-manifest']);
assert.match(detailOf(found, 'no-manifest'), /configuration token/);
});
test('publishing on open is the correct cadence', () => {
const [state, counts] = publishCadence([{ user: 'U1', trigger: 'app_home_opened' },
{ user: 'U2', trigger: 'app_home_opened' }]);
assert.equal(state, 'on-open');
assert.equal(counts.on_open, 2);
});
test('publishing at boot only is the shape that works for the developer', () => {
const [state, counts] = publishCadence([{ user: 'U1', trigger: 'startup' },
{ user: 'U2', trigger: 'deploy' }]);
assert.equal(state, 'startup-only');
assert.equal(counts.startup, 2);
});
test('a migration in progress reads as mixed', () => {
assert.equal(publishCadence([{ user: 'U1', trigger: 'startup' },
{ user: 'U2', trigger: 'app_home_opened' }])[0], 'mixed');
});
test('an unrecognised trigger is named rather than counted as correct', () => {
const [state, counts] = publishCadence([{ user: 'U1', trigger: 'manual' }]);
assert.equal(state, 'unknown-trigger');
assert.equal(counts.other, 1);
});
test('an empty publish log is no records', () => {
assert.equal(publishCadence([])[0], 'no-records');
});
test('coverage is a set difference not a count of publishes', () => {
const records = Array.from({ length: 40000 }, () => ({ user: 'U1' }));
const [state, counts] = publishCoverage(['U1', 'U2', 'U3'], records);
assert.equal(state, 'partial');
assert.equal(counts.never, 2);
assert.equal(counts.covered, 1);
});
test('everybody served is covered', () => {
const [state, counts] = publishCoverage(['U1', 'U2'],
[{ user: 'U1' }, { user: 'U2' }]);
assert.equal(state, 'covered');
assert.equal(counts.never, 0);
});
test('nobody served is reported separately from a partial gap', () => {
assert.equal(publishCoverage(['U1', 'U2'], [])[0], 'none-published');
});
test('no members is not mistaken for full coverage', () => {
assert.equal(publishCoverage([], [{ user: 'U1' }])[0], 'no-members');
});
test('staleness is measured per user not per app', () => {
const records = [{ user: 'U1', ts: NOW - 1 }];
for (let i = 2; i < 6; i += 1) records.push({ user: `U${i}`, ts: NOW - 200 * DAY });
const [state, counts] = staleness(records, NOW, 30);
assert.equal(state, 'stale');
assert.equal(counts.stale, 4);
assert.equal(counts.fresh, 1);
});
test('a users most recent publish is the one that counts', () => {
const [state, counts] = staleness([{ user: 'U1', ts: NOW - 400 * DAY },
{ user: 'U1', ts: NOW - 1 }], NOW, 30);
assert.equal(state, 'fresh');
assert.equal(counts.users, 1);
});
test('records without a usable timestamp are skipped rather than counted', () => {
assert.equal(staleness([{ user: 'U1' }, { user: 'U2', ts: 'nope' }], NOW)[0],
'no-records');
});
test('the switch outranks everything else in the verdict', () => {
const [verdict, detail] = homeVerdict(['home-tab-off'], 'on-open', 'covered');
assert.equal(verdict, 'tab-off');
assert.match(detail, /before reading the coverage/);
});
test('a missing subscription outranks the publish log', () => {
assert.equal(homeVerdict(['no-app-home-opened-subscription'], 'on-open',
'covered')[0], 'no-subscription');
});
test('publishing once is named even when every switch is correct', () => {
const [verdict, detail] = homeVerdict([], 'startup-only', 'partial');
assert.equal(verdict, 'publish-once');
assert.match(detail, /last restart/);
});
test('correct config and poor coverage hands the problem back', () => {
const [verdict, detail] = homeVerdict([], 'on-open', 'partial');
assert.equal(verdict, 'under-published');
assert.match(detail, /never had a view stored/);
});
test('without a manifest the verdict says what it rests on', () => {
const [verdict, detail] = homeVerdict(['no-manifest'], 'on-open', 'covered');
assert.equal(verdict, 'coverage-only');
assert.match(detail, /publish log/);
});
test('a working surface is consistent', () => {
assert.equal(homeVerdict([], 'on-open', 'covered')[0], 'consistent');
});
test('the repair is printed as manifest keys and pointers are skipped', () => {
assert.deepEqual(repairManifest(['home-tab-off', 'messages-tab-only']),
['features.app_home.home_tab_enabled: true']);
assert.deepEqual(repairManifest(['no-manifest']), []);
});
FAQ
How is this different from the Messages tab being disabled?
Different switch, different surface, different symptom. The Messages tab governs whether a person may type at your app, it lives at features.app_home.messages_tab_enabled, and calling into a DM with it off produces a real error string, messages_tab_disabled, that your code can catch. The Home tab governs whether a view you published is reachable, it lives at features.app_home.home_tab_enabled, and publishing to it with the switch off produces ok: true and a view id. One refuses you on the way in; the other accepts everything and renders nothing. They sit next to each other in the same settings panel, which is why the script prints a pointer when it finds the Home tab off and the Messages tab on.
views.publish returns ok: true and a view object. How can that not mean it worked?
Because the method did exactly what it says: it stored a view for a user. Whether there is a tab through which that user can reach it is a separate question about app configuration, and views.publish is not the thing that answers it. This is the general shape of Slack failures pushed to its limit, and it is worth internalising: a successful response tells you the call was accepted, never that the outcome you had in mind occurred.
Why does the script need my own publish log? Cannot it just ask Slack who has a view?
There is no such method. Slack offers no read that enumerates the users with a published view for your app, so the per-user history exists in exactly one place, which is your side of the conversation. The alternative would be to publish a view in order to observe it, and that is a write: it would change what those users see in order to find out what they see. The log is a list of records carrying a user, a trigger and a timestamp, which most apps are already emitting.
Everything is configured correctly and most of the workspace still has an empty tab. What now?
That is the under-published verdict, and it means the fault is in your handler rather than in a setting. The usual cause is publishing at startup for the users the process happened to know about, which serves the people who were already there and nobody who arrived afterwards. Move the publish into the app_home_opened handler and publish for event.user on every open. Coverage then fixes itself as people open the app, and staleness stops being possible at all, because every view is written at the moment it is looked at.
Do I have to publish the Home tab again after a reinstall?
Yes, and this is the part that surprises people. The view is stored per user and per installation, so a reinstall gives you a new installation with no published views at all, and every user is back to the empty state until something publishes for them again. An app that publishes on app_home_opened does not notice, because the first open after the reinstall does the work. An app that publishes at startup notices only when somebody complains.
Related field notes
- the other App Home tab, and why it fails loudly
- a subscription list with nothing routed down it
- the credential this check reads the manifest with
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
- views.publish method reference - Slack Docs
- app_home_opened event reference - Slack Docs
- App manifest 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.